feat: comprehensive frontend polish - UI/UX improvements across all pages
Public Pages: - Landing page with server/client split and metadata export - Cart page with ARIA accessibility and loading states - Checkout page with form accessibility and proper design system - Contact page with SEO metadata and improved accessibility - Pricing page with enhanced FAQ accessibility - Login page with Suspense boundaries and secure patterns Brand Storefronts: - Premium loading skeletons for Tuxedo and Indian River Direct - Branded error boundaries with animations - Loading.tsx for about, contact, FAQ, stops pages - Error.tsx for all storefront subpages Admin Dashboard: - AdminSidebar: ARIA labels, keyboard navigation, mobile improvements - DashboardClient: Stats cards, quick actions, usage progress - Admin layout: Toast provider integration Admin Orders/Products/Stops: - Toast notification system with auto-dismiss - Skeleton loading components (Table, Card, Stats, Form) - Bulk actions (mark picked up, publish stops) - Form validation with error styling Admin Communications: - AnalyticsDashboard: sparklines, stat cards, engagement badges - CampaignComposerPage: step wizard, template selection, email preview - SegmentBuilderPage: empty state, active segment handling - ContactListPanel: loading skeletons, professional empty states - MessageLogPanel: stats cards, engagement indicators - HarvestReachNav: branded tab navigation - All pages: metadata exports and loading.tsx Admin Settings: - SquareSyncSettingsClient: design system colors, save/cancel UX - ShippingSettingsForm: validation, dirty state tracking - Integrations page: proper layout with AI and communications sections - Billing: improved plan comparison, add-on cards - PaymentSettings: toggle components, validation Wholesale Portal: - Portal: loading skeletons, quantity stepper, search/filter - Login/Register: FormField validation, success states - Success/Cancel pages: animated checkmarks - Employee portal: skeletons, empty states, mobile responsive Water Log: - FieldClient: loading step, progress indicator, spinner - AdminClient: loading skeletons - Admin pages: loading.tsx files New Components: - Toast.tsx/ToastContainer.tsx: comprehensive notification system - Skeleton.tsx: shimmer loading components - AdminToggle.tsx: consistent toggle/switch - CommunicationsLoading.tsx: loading skeleton for comms - ToastExport.ts: exports CSS Improvements: - Shimmer animation keyframes - Toast slide-in animation Accessibility: - ARIA labels throughout - Keyboard navigation - Focus states - Semantic HTML - Screen reader support
This commit is contained in:
@@ -134,27 +134,23 @@ export default function EmployeePortalPage() {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100 flex items-center justify-center">
|
||||
<p className="text-slate-500">Loading pickup queue...</p>
|
||||
</div>
|
||||
);
|
||||
return <EmployeePortalSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-slate-200 px-6 py-4">
|
||||
<div className="mx-auto max-w-5xl flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Pickup Portal</h1>
|
||||
<p className="mt-0.5 text-sm text-slate-500">{brandName}</p>
|
||||
<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-4">
|
||||
<span className="text-sm text-slate-500">{employeeName}</span>
|
||||
<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
|
||||
onClick={handleSignOut}
|
||||
className="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
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>
|
||||
@@ -163,9 +159,9 @@ export default function EmployeePortalPage() {
|
||||
</div>
|
||||
|
||||
{/* Queue tabs */}
|
||||
<div className="bg-white border-b border-slate-200 px-6">
|
||||
<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">
|
||||
<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;
|
||||
@@ -206,13 +202,16 @@ export default function EmployeePortalPage() {
|
||||
)}
|
||||
|
||||
{currentTab.orders.length === 0 ? (
|
||||
<div className="rounded-2xl bg-white p-12 text-center shadow-sm ring-1 ring-slate-200">
|
||||
<p className="text-slate-400 text-sm">
|
||||
{activeTab === "past_due" ? "No past due orders." :
|
||||
activeTab === "today" ? "No pickups scheduled for today." :
|
||||
"No upcoming pickups."}
|
||||
</p>
|
||||
</div>
|
||||
<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."}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{currentTab.orders.map((order) => (
|
||||
@@ -299,6 +298,64 @@ export default function EmployeePortalPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Loading Skeleton ──────────────────────────────────────────────────────────
|
||||
function EmployeePortalSkeleton() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100">
|
||||
<div className="bg-white border-b border-slate-200 px-6 py-4">
|
||||
<div className="mx-auto max-w-5xl flex items-center justify-between">
|
||||
<div className="space-y-2 animate-pulse">
|
||||
<div className="h-7 w-40 bg-slate-200 rounded" />
|
||||
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="h-10 w-24 bg-slate-200 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
<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 => (
|
||||
<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 => (
|
||||
<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">
|
||||
<div className="h-5 w-40 bg-slate-200 rounded" />
|
||||
<div className="h-4 w-56 bg-slate-100 rounded" />
|
||||
<div className="h-3 w-32 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="h-9 w-16 bg-slate-200 rounded-xl" />
|
||||
<div className="h-9 w-16 bg-slate-200 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty State ──────────────────────────────────────────────────────────────
|
||||
function EmptyQueueState({ label, description }: { label: string; description: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white p-12 text-center shadow-sm ring-1 ring-slate-200">
|
||||
<div className="w-16 h-16 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 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-lg font-semibold text-slate-700 mb-2">{label}</p>
|
||||
<p className="text-sm text-slate-500 max-w-xs mx-auto">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Order Card ────────────────────────────────────────────────────────────────
|
||||
|
||||
function OrderCard({
|
||||
@@ -325,23 +382,23 @@ function OrderCard({
|
||||
const hasPhone = Boolean(order.customer_phone);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-white shadow-sm ring-1 ring-slate-200 overflow-hidden">
|
||||
<div className="rounded-2xl bg-white shadow-sm ring-1 ring-slate-200 overflow-hidden hover:ring-slate-300 transition-shadow">
|
||||
{/* Card header */}
|
||||
<div className="px-5 py-4 flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="px-4 py-4 sm:px-5 sm:py-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
<div className="flex-1 min-w-0 w-full">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-slate-900">{order.company_name}</span>
|
||||
<span className="font-semibold text-slate-900 text-sm sm:text-base">{order.company_name}</span>
|
||||
<StatusBadge status={order.status} />
|
||||
<span className={`text-xs font-medium ${order.payment_status === "paid" ? "text-green-600" : "text-orange-600"}`}>
|
||||
{order.payment_status === "paid" ? "Paid" : `$${Number(order.balance_due).toFixed(2)} due`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-0.5">
|
||||
{order.contact_name ?? ""} ·{" "}
|
||||
<p className="text-xs sm:text-sm text-slate-500 mt-0.5">
|
||||
{order.contact_name ?? ""}
|
||||
{hasPhone ? (
|
||||
<a href={`tel:${order.customer_phone}`} className="hover:underline">{order.customer_phone}</a>
|
||||
<> · <a href={`tel:${order.customer_phone}`} className="hover:underline">{order.customer_phone}</a></>
|
||||
) : (
|
||||
<a href={`mailto:${order.customer_email}`} className="hover:underline">{order.customer_email}</a>
|
||||
<> · <a href={`mailto:${order.customer_email}`} className="hover:underline">{order.customer_email}</a></>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
@@ -350,7 +407,7 @@ function OrderCard({
|
||||
</div>
|
||||
|
||||
{/* Inline action buttons */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex items-center gap-2 shrink-0 w-full sm:w-auto justify-end">
|
||||
{order.fulfillment_status !== "fulfilled" && (
|
||||
<button
|
||||
onClick={() => !fulfilling && onFulfill(order.id)}
|
||||
@@ -418,7 +475,7 @@ function OrderCard({
|
||||
|
||||
{/* Line items summary */}
|
||||
{order.items && order.items.length > 0 && (
|
||||
<div className="px-5 pb-4">
|
||||
<div className="px-4 pb-4 sm:px-5 sm:pb-4">
|
||||
<div className="rounded-xl bg-slate-50 border border-slate-100 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-100 text-slate-500">
|
||||
@@ -431,9 +488,9 @@ function OrderCard({
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{order.items.map((item, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-4 py-2 text-slate-700">{item.product_name}</td>
|
||||
<td className="px-4 py-2 text-right text-slate-600">{item.quantity}</td>
|
||||
<td className="px-4 py-2 text-right font-medium text-slate-900">${Number(item.line_total).toFixed(2)}</td>
|
||||
<td className="px-4 py-2 text-slate-700 text-xs">{item.product_name}</td>
|
||||
<td className="px-4 py-2 text-right text-slate-600 text-xs">{item.quantity}</td>
|
||||
<td className="px-4 py-2 text-right font-medium text-slate-900 text-xs">${Number(item.line_total).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -4,17 +4,54 @@ import { useState, useEffect } from "react";
|
||||
import { wholesaleLoginAction } from "@/actions/wholesale-auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import WholesaleBenefits from "@/components/wholesale/WholesaleBenefits";
|
||||
import Link from "next/link";
|
||||
|
||||
const BRANDS = [
|
||||
{ id: "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28", name: "Indian River Direct", slug: "indian-river-direct" },
|
||||
{ id: "64294306-5f42-463d-a5e8-2ad6c81a96de", name: "Tuxedo Corn", slug: "tuxedo" },
|
||||
];
|
||||
|
||||
function BrandLogo({ name }: { name: string }) {
|
||||
return (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-900/60 border border-emerald-700/50">
|
||||
<svg className="h-5 w-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Input field with validation state
|
||||
function FormField({ label, id, error, children }: {
|
||||
label: string;
|
||||
id: string;
|
||||
error?: string | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="block text-sm font-semibold text-zinc-400 mb-1.5">{label}</label>
|
||||
<div className="relative">
|
||||
{children}
|
||||
{error && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<svg className="w-4 h-4 text-red-400" 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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="mt-1 text-xs text-red-400">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WholesaleLoginPage() {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState({ email: "", password: "", brandId: BRANDS[1].id });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({});
|
||||
const [selectedBrand, setSelectedBrand] = useState(BRANDS[1]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -27,6 +64,10 @@ export default function WholesaleLoginPage() {
|
||||
const err = params.get("error");
|
||||
if (err === "portal_disabled") {
|
||||
setError("The wholesale portal is currently disabled. Contact us for assistance.");
|
||||
} else if (err === "account_not_active") {
|
||||
setError("Your account is not active. Please contact support or register for a new account.");
|
||||
} else if (err === "invalid_credentials") {
|
||||
setError("Invalid email or password. Please try again.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -34,6 +75,26 @@ export default function WholesaleLoginPage() {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
|
||||
// Client-side validation
|
||||
const errors: { email?: string; password?: string } = {};
|
||||
if (!form.email) {
|
||||
errors.email = "Email is required";
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
|
||||
errors.email = "Enter a valid email address";
|
||||
}
|
||||
if (!form.password) {
|
||||
errors.password = "Password is required";
|
||||
} else if (form.password.length < 6) {
|
||||
errors.password = "Password must be at least 6 characters";
|
||||
}
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setFieldErrors(errors);
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = new FormData(e.currentTarget as HTMLFormElement);
|
||||
fd.set("brand_id", form.brandId);
|
||||
const result = await wholesaleLoginAction(fd);
|
||||
@@ -42,7 +103,7 @@ export default function WholesaleLoginPage() {
|
||||
router.push("/wholesale/portal");
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(result.error ?? "Login failed.");
|
||||
setError(result.error ?? "Login failed. Please check your credentials.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,26 +113,27 @@ export default function WholesaleLoginPage() {
|
||||
<div className="bg-zinc-900 border-b border-zinc-800">
|
||||
<div className="mx-auto max-w-5xl px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-900/60 border border-emerald-700/50">
|
||||
<svg className="h-5 w-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
|
||||
</svg>
|
||||
</div>
|
||||
<BrandLogo name={selectedBrand.name} />
|
||||
<div>
|
||||
<p className="font-bold text-base text-zinc-100 leading-none">{selectedBrand.name}</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Wholesale Portal</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/" className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors">← Back to site</a>
|
||||
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors flex items-center gap-1">
|
||||
<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="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
Back to site
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-5xl px-6 py-12">
|
||||
<div className="grid gap-10 lg:grid-cols-[1fr_420px] lg:items-start">
|
||||
<div className="grid gap-12 lg:grid-cols-[1fr_420px] lg:items-start">
|
||||
|
||||
{/* Benefits column */}
|
||||
<div className="lg:pt-4">
|
||||
<h2 className="text-3xl font-black text-zinc-100 tracking-tight mb-2">
|
||||
<h2 className="text-3xl font-black text-zinc-100 tracking-tight mb-3">
|
||||
Grow your business with wholesale pricing
|
||||
</h2>
|
||||
<p className="text-zinc-500 mb-8 leading-relaxed">
|
||||
@@ -83,79 +145,96 @@ export default function WholesaleLoginPage() {
|
||||
{/* Form column */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-8 shadow-xl shadow-black/20">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-zinc-100">Sign In</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">Access your wholesale account.</p>
|
||||
<h1 className="text-2xl font-bold text-zinc-100">Welcome back</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">Sign in to your wholesale account.</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-xl border border-red-900/50 bg-red-950/50 px-4 py-3 text-sm text-red-400">
|
||||
<div className="mb-5 rounded-xl border border-red-900/50 bg-red-950/50 px-4 py-3 text-sm text-red-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"/>
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-zinc-400 mb-1.5">Company</label>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<FormField label="Company" id="brand_id" error={null}>
|
||||
<select
|
||||
id="brand_id"
|
||||
name="brand_id"
|
||||
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"
|
||||
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" }}
|
||||
>
|
||||
{BRANDS.map(b => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-zinc-400 mb-1.5">Email</label>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Email" id="email" error={fieldErrors.email}>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={form.email}
|
||||
onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
|
||||
required
|
||||
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"
|
||||
onChange={e => { setForm(f => ({ ...f, email: e.target.value })); setFieldErrors(f => ({ ...f, email: undefined })); }}
|
||||
autoComplete="email"
|
||||
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 focus:border-red-500 focus:ring-1 focus:ring-red-500" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`}
|
||||
placeholder="buyer@company.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-zinc-400 mb-1.5">Password</label>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Password" id="password" error={fieldErrors.password}>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={form.password}
|
||||
onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
|
||||
required
|
||||
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"
|
||||
onChange={e => { setForm(f => ({ ...f, password: e.target.value })); setFieldErrors(f => ({ ...f, password: undefined })); }}
|
||||
autoComplete="current-password"
|
||||
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.password ? "border-red-600 focus:border-red-500 focus:ring-1 focus:ring-red-500" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full rounded-xl bg-emerald-600 py-3 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-2"
|
||||
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-2 flex items-center justify-center gap-2"
|
||||
>
|
||||
{submitting ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<>
|
||||
<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>
|
||||
Signing in...
|
||||
</span>
|
||||
) : "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="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"/>
|
||||
</svg>
|
||||
Sign In
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 rounded-xl border border-zinc-800 bg-zinc-950 p-4">
|
||||
<div className="mt-5 rounded-xl border border-zinc-800 bg-zinc-950 p-4">
|
||||
<p className="text-sm text-zinc-400 font-medium">Don't have an account?</p>
|
||||
<a
|
||||
<Link
|
||||
href="/wholesale/register"
|
||||
className="mt-1 inline-block text-sm text-emerald-400 hover:text-emerald-300 hover:underline"
|
||||
className="mt-1 inline-flex items-center gap-1 text-sm text-emerald-400 hover:text-emerald-300 hover:underline"
|
||||
>
|
||||
Apply for a wholesale account →
|
||||
</a>
|
||||
Apply for a wholesale account
|
||||
<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="M17 8l4 4m0 0l-4 4m4-4H3"/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,35 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
export default function PaymentCancelPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100 flex items-center justify-center px-4">
|
||||
<div className="bg-white rounded-2xl shadow-sm ring-1 ring-slate-200 p-8 max-w-md w-full text-center">
|
||||
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-100 to-stone-100 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 rounded-full border-3 border-stone-300 border-t-stone-600 animate-spin mx-auto mb-4" />
|
||||
<p className="text-stone-500">Processing...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<PaymentCancelContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentCancelContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const reason = searchParams.get("reason");
|
||||
const sessionId = searchParams.get("session_id");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-stone-50 flex items-center justify-center px-4 py-8">
|
||||
<div className="bg-white rounded-3xl shadow-xl ring-1 ring-slate-200 p-8 sm:p-10 max-w-md w-full text-center">
|
||||
{/* Cancel icon */}
|
||||
<div className="w-20 h-20 bg-stone-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-10 h-10 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 mb-2">Payment Cancelled</h1>
|
||||
<p className="text-sm text-slate-500 mb-6">
|
||||
Your payment was not completed. No charges have been made.
|
||||
</p>
|
||||
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-slate-900 mb-3">
|
||||
Payment Cancelled
|
||||
</h1>
|
||||
|
||||
<div className="bg-stone-50 rounded-xl px-4 py-3 mb-6">
|
||||
<p className="text-sm text-stone-600 font-medium">
|
||||
No charges were made
|
||||
</p>
|
||||
<p className="text-xs text-stone-500 mt-1">
|
||||
Your order is still pending and you can return to complete payment anytime.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sessionId && (
|
||||
<p className="text-xs text-slate-400 mb-4 font-mono">
|
||||
Session: {sessionId.slice(0, 20)}...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{reason && (
|
||||
<p className="text-xs text-amber-600 bg-amber-50 rounded-lg px-3 py-2 mb-6">
|
||||
{reason === "aborted" ? "Payment was aborted by user." : reason}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
href="/wholesale/portal?tab=orders"
|
||||
className="block w-full rounded-xl bg-slate-800 py-3 text-sm font-bold text-white hover:bg-slate-700"
|
||||
className="flex items-center justify-center gap-2 w-full rounded-xl bg-slate-800 py-3.5 text-sm font-bold text-white hover:bg-slate-700 active:bg-slate-800 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<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 2"/>
|
||||
</svg>
|
||||
Back to Orders
|
||||
</Link>
|
||||
<Link
|
||||
href="/wholesale/portal?tab=products"
|
||||
className="block w-full rounded-xl border border-slate-300 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
href="/wholesale/portal?tab=cart"
|
||||
className="flex items-center justify-center gap-2 w-full rounded-xl border border-slate-300 py-3 text-sm font-semibold text-slate-700 hover:bg-slate-50 active:bg-slate-100 transition-colors"
|
||||
>
|
||||
Continue Shopping
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||
</svg>
|
||||
Return to Cart
|
||||
</Link>
|
||||
<Link
|
||||
href="/wholesale/portal?tab=products"
|
||||
className="flex items-center justify-center gap-2 w-full rounded-xl bg-stone-100 py-3 text-sm font-semibold text-stone-600 hover:bg-stone-200 active:bg-stone-100 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
||||
</svg>
|
||||
Browse Products
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-slate-100">
|
||||
<p className="text-xs text-stone-400">
|
||||
Need help?{" "}
|
||||
<Link href="/wholesale/login" className="text-slate-500 hover:text-slate-700 hover:underline">
|
||||
Contact support
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,15 @@
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { enqueueWholesaleNotification } from "@/actions/wholesale";
|
||||
|
||||
export default function PaymentSuccessPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen bg-slate-100 flex items-center justify-center">
|
||||
<p className="text-slate-500">Loading...</p>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-100 to-stone-100 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 rounded-full border-3 border-stone-300 border-t-stone-600 animate-spin mx-auto mb-4" />
|
||||
<p className="text-stone-500">Processing payment...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<PaymentSuccessContent />
|
||||
@@ -20,55 +22,102 @@ export default function PaymentSuccessPage() {
|
||||
function PaymentSuccessContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const sessionId = searchParams.get("session_id");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notified, setNotified] = useState(false);
|
||||
const [showContent, setShowContent] = useState(false);
|
||||
const [successStep, setSuccessStep] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
// Stagger the animation
|
||||
const timer1 = setTimeout(() => setShowContent(true), 200);
|
||||
const timer2 = setTimeout(() => setSuccessStep(1), 500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer1);
|
||||
clearTimeout(timer2);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// After render, enqueue deposit received notification
|
||||
// We don't have order details here (webhook handled the DB update),
|
||||
// so we defer to the admin fulfill notification for now.
|
||||
// The webhook already processed the payment — a follow-up cron can
|
||||
// detect deposit_paid increases and send the deposit notification.
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100 flex items-center justify-center px-4">
|
||||
<div className="bg-white rounded-2xl shadow-sm ring-1 ring-slate-200 p-8 max-w-md w-full text-center">
|
||||
{loading ? (
|
||||
<p className="text-slate-500">Confirming payment...</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 mb-2">Payment Successful</h1>
|
||||
<p className="text-sm text-slate-500 mb-6">
|
||||
Your payment has been received. Your order will be updated shortly.
|
||||
{sessionId && <span className="block mt-1 text-xs text-slate-400">Session: {sessionId.slice(0, 20)}...</span>}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
href="/wholesale/portal?tab=orders"
|
||||
className="block w-full rounded-xl bg-green-600 py-3 text-sm font-bold text-white hover:bg-green-700"
|
||||
>
|
||||
View Orders
|
||||
</Link>
|
||||
<Link
|
||||
href="/wholesale/portal?tab=products"
|
||||
className="block w-full rounded-xl border border-slate-300 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Continue Shopping
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-slate-400">
|
||||
A confirmation email will be sent once your payment is processed.
|
||||
</p>
|
||||
</>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-stone-50 flex items-center justify-center px-4 py-8">
|
||||
<div
|
||||
className={`bg-white rounded-3xl shadow-xl ring-1 ring-slate-200 p-8 sm:p-10 max-w-md w-full text-center transition-all duration-500 ${
|
||||
showContent ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
|
||||
}`}
|
||||
>
|
||||
{/* Success animation */}
|
||||
<div
|
||||
className={`relative w-20 h-20 mx-auto mb-6 transition-all duration-500 ${
|
||||
successStep >= 1 ? "scale-100" : "scale-50"
|
||||
}`}
|
||||
>
|
||||
<div className={`absolute inset-0 rounded-full bg-green-100 transition-all duration-500 ${successStep >= 1 ? "opacity-100 scale-100" : "opacity-0 scale-50"}`} />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg
|
||||
className={`w-10 h-10 text-green-600 transition-all duration-300 ${
|
||||
successStep >= 1 ? "opacity-100 scale-100" : "opacity-0 scale-50"
|
||||
}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
{/* Ripple effect */}
|
||||
<div className="absolute inset-0 rounded-full border-2 border-green-400 animate-ping opacity-20" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-slate-900 mb-3">
|
||||
Payment Successful
|
||||
</h1>
|
||||
|
||||
<div className="bg-green-50 rounded-xl px-4 py-3 mb-6">
|
||||
<p className="text-sm text-green-700 font-medium">
|
||||
Your payment has been received
|
||||
</p>
|
||||
<p className="text-xs text-green-600 mt-1">
|
||||
Your order will be updated shortly. A confirmation email will be sent once your payment is processed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sessionId && (
|
||||
<p className="text-xs text-slate-400 mb-6 font-mono">
|
||||
Reference: {sessionId.slice(0, 20)}...
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
href="/wholesale/portal?tab=orders"
|
||||
className="flex items-center justify-center gap-2 w-full rounded-xl bg-green-600 py-3.5 text-sm font-bold text-white hover:bg-green-700 active:bg-green-800 transition-colors shadow-lg shadow-green-900/20"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<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 2"/>
|
||||
</svg>
|
||||
View My Orders
|
||||
</Link>
|
||||
<Link
|
||||
href="/wholesale/portal?tab=products"
|
||||
className="flex items-center justify-center gap-2 w-full rounded-xl border border-slate-300 py-3 text-sm font-semibold text-slate-700 hover:bg-slate-50 active:bg-slate-100 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||
</svg>
|
||||
Continue Shopping
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-slate-100">
|
||||
<Link
|
||||
href="/wholesale/portal"
|
||||
className="text-xs text-slate-400 hover:text-slate-600 transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
Back to Portal
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { getWholesaleCustomerByUser, submitWholesaleOrder, getWholesaleProducts, getWholesaleCustomerOrders, getWholesaleCustomerPricing, getWholesaleCustomer, type WholesaleProduct, type WholesaleCustomerOrder, type WholesalePricingOverride } from "@/actions/wholesale-register";
|
||||
@@ -12,6 +12,214 @@ type CartItem = {
|
||||
unitPrice: number;
|
||||
};
|
||||
|
||||
// ── Loading Skeleton ─────────────────────────────────────────────────────────
|
||||
function ProductSkeleton() {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 animate-pulse">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-5 w-32 bg-slate-200 rounded" />
|
||||
<div className="h-3 w-48 bg-slate-100 rounded" />
|
||||
<div className="h-3 w-24 bg-slate-100 rounded" />
|
||||
<div className="h-3 w-40 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="h-10 w-20 bg-slate-200 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CartSkeleton() {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white shadow-sm ring-1 ring-slate-200 p-5 animate-pulse">
|
||||
<div className="h-6 w-40 bg-slate-200 rounded mb-4" />
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="flex gap-4">
|
||||
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||||
<div className="h-4 w-16 bg-slate-100 rounded ml-auto" />
|
||||
<div className="h-4 w-12 bg-slate-100 rounded" />
|
||||
<div className="h-4 w-16 bg-slate-100 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrdersSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].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 mb-3">
|
||||
<div className="space-y-2">
|
||||
<div className="h-5 w-32 bg-slate-200 rounded" />
|
||||
<div className="h-3 w-48 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="space-y-2 text-right">
|
||||
<div className="h-6 w-20 bg-slate-200 rounded" />
|
||||
<div className="h-4 w-16 bg-slate-100 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Quantity Stepper ──────────────────────────────────────────────────────────
|
||||
function QuantityStepper({
|
||||
maxQty,
|
||||
onAdd,
|
||||
}: {
|
||||
maxQty: number;
|
||||
onAdd: (qty: number) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [qty, setQty] = useState(1);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
|
||||
const presets = maxQty >= 10 ? [1, 5, 10] : maxQty >= 5 ? [1, 5] : [1];
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="rounded-xl bg-green-600 px-4 py-2 text-xs font-semibold text-white hover:bg-green-700 shrink-0 transition-colors shadow-sm"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 bottom-full mb-2 z-20 bg-white rounded-xl shadow-xl ring-1 ring-slate-200 p-4 w-56">
|
||||
<p className="text-xs font-semibold text-slate-500 mb-2">Qty for this item</p>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQty(q => Math.max(1, q - 1))}
|
||||
className="w-9 h-9 rounded-lg border border-slate-300 flex items-center justify-center text-slate-600 hover:bg-slate-50 disabled:opacity-30"
|
||||
disabled={qty <= 1}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={maxQty}
|
||||
value={qty}
|
||||
onChange={e => setQty(Math.min(maxQty, Math.max(1, Number(e.target.value) || 1)))}
|
||||
className="flex-1 rounded-lg border border-slate-300 px-3 py-2 text-center font-semibold text-slate-900 outline-none focus:border-green-500 min-w-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQty(q => Math.min(maxQty, q + 1))}
|
||||
className="w-9 h-9 rounded-lg border border-slate-300 flex items-center justify-center text-slate-600 hover:bg-slate-50 disabled:opacity-30"
|
||||
disabled={qty >= maxQty}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{presets.map(p => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setQty(Math.min(maxQty, p))}
|
||||
className={`rounded-lg px-2.5 py-1 text-xs font-semibold transition-colors ${
|
||||
qty === p ? "bg-green-600 text-white" : "bg-slate-100 text-slate-600 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
{maxQty >= 20 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQty(Math.min(maxQty, 20))}
|
||||
className={`rounded-lg px-2.5 py-1 text-xs font-semibold transition-colors ${
|
||||
qty === 20 ? "bg-green-600 text-white" : "bg-slate-100 text-slate-600 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
20
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mb-3">Max: {maxQty}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onAdd(qty); setOpen(false); setQty(1); }}
|
||||
className="w-full rounded-xl bg-green-600 py-2.5 text-sm font-bold text-white hover:bg-green-700"
|
||||
>
|
||||
Add {qty} to Cart
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Search & Filter Bar ───────────────────────────────────────────────────────
|
||||
function SearchBar({ value, onChange, placeholder }: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-xl border border-slate-300 pl-10 pr-4 py-3 text-sm outline-none focus:border-green-500 focus:ring-1 focus:ring-green-500 placeholder:text-slate-400"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty States ──────────────────────────────────────────────────────────────
|
||||
function EmptyState({ icon, title, description }: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white p-12 text-center shadow-sm ring-1 ring-slate-200">
|
||||
<div className="w-14 h-14 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-700 mb-2">{title}</h3>
|
||||
<p className="text-sm text-slate-500 max-w-xs mx-auto">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ────────────────────────────────────────────────────────────
|
||||
export default function WholesalePortalPage() {
|
||||
const router = useRouter();
|
||||
const [customer, setCustomer] = useState<{
|
||||
@@ -28,6 +236,7 @@ export default function WholesalePortalPage() {
|
||||
const [previewMode, setPreviewMode] = useState(false);
|
||||
const [tab, setTab] = useState<"products" | "cart" | "orders">("products");
|
||||
const [products, setProducts] = useState<WholesaleProduct[]>([]);
|
||||
const [allProducts, setAllProducts] = useState<WholesaleProduct[]>([]);
|
||||
const [cart, setCart] = useState<CartItem[]>([]);
|
||||
const [orders, setOrders] = useState<WholesaleCustomerOrder[]>([]);
|
||||
const [pricingOverrides, setPricingOverrides] = useState<Record<string, number>>({});
|
||||
@@ -36,6 +245,26 @@ export default function WholesalePortalPage() {
|
||||
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||
const [onlinePaymentEnabled, setOnlinePaymentEnabled] = useState(false);
|
||||
const [processingPayment, setProcessingPayment] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filterAvailability, setFilterAvailability] = useState<"all" | "available" | "limited">("all");
|
||||
|
||||
// Product filtering
|
||||
const filteredProducts = useMemo(() => {
|
||||
let filtered = allProducts;
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(p =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
(p.description ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
if (filterAvailability !== "all") {
|
||||
filtered = filtered.filter(p =>
|
||||
filterAvailability === "available" ? p.availability === "available" : p.availability === "limited"
|
||||
);
|
||||
}
|
||||
return filtered;
|
||||
}, [allProducts, searchQuery, filterAvailability]);
|
||||
|
||||
useEffect(() => {
|
||||
// ── Admin preview mode ──────────────────────────────────────────────────
|
||||
@@ -133,6 +362,7 @@ export default function WholesalePortalPage() {
|
||||
const { getWholesaleProducts } = await import("@/actions/wholesale");
|
||||
const prods = await getWholesaleProducts(brandId);
|
||||
setProducts(prods);
|
||||
setAllProducts(prods);
|
||||
}
|
||||
|
||||
async function loadOrders(customerId: string) {
|
||||
@@ -171,11 +401,8 @@ export default function WholesalePortalPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function addToCart(product: WholesaleProduct) {
|
||||
const qty = prompt(`Quantity for ${product.name} (${product.unit_type}):`);
|
||||
if (!qty) return;
|
||||
const q = Number(qty);
|
||||
if (isNaN(q) || q <= 0) return;
|
||||
function addToCart(product: WholesaleProduct, quantity: number) {
|
||||
if (quantity <= 0 || quantity > product.qty_available) return;
|
||||
|
||||
// Determine unit price: customer override first, then price tiers
|
||||
let unitPrice = 0;
|
||||
@@ -184,7 +411,7 @@ export default function WholesalePortalPage() {
|
||||
unitPrice = overridePrice;
|
||||
} else if (product.price_tiers && product.price_tiers.length > 0) {
|
||||
const tier = product.price_tiers.find((t: { min_qty: number; max_qty: number; price: number }) =>
|
||||
q >= t.min_qty && (t.max_qty === 0 || q <= t.max_qty)
|
||||
quantity >= t.min_qty && (t.max_qty === 0 || quantity <= t.max_qty)
|
||||
);
|
||||
unitPrice = tier ? tier.price : product.price_tiers[product.price_tiers.length - 1].price;
|
||||
}
|
||||
@@ -192,11 +419,23 @@ export default function WholesalePortalPage() {
|
||||
setCart(prev => {
|
||||
const existing = prev.find(i => i.product.id === product.id);
|
||||
if (existing) {
|
||||
return prev.map(i => i.product.id === product.id ? { ...i, quantity: i.quantity + q } : i);
|
||||
const newQty = existing.quantity + quantity;
|
||||
// Recalculate unit price if quantity changed tier
|
||||
let newUnitPrice = unitPrice;
|
||||
if (overridePrice === undefined && product.price_tiers && product.price_tiers.length > 0) {
|
||||
const tier = product.price_tiers.find((t: { min_qty: number; max_qty: number; price: number }) =>
|
||||
newQty >= t.min_qty && (t.max_qty === 0 || newQty <= t.max_qty)
|
||||
);
|
||||
newUnitPrice = tier ? tier.price : product.price_tiers[product.price_tiers.length - 1].price;
|
||||
}
|
||||
return prev.map(i => i.product.id === product.id ? { ...i, quantity: newQty, unitPrice: newUnitPrice } : i);
|
||||
}
|
||||
return [...prev, { product, quantity: q, unitPrice }];
|
||||
return [...prev, { product, quantity, unitPrice }];
|
||||
});
|
||||
setTab("cart");
|
||||
// Brief success feedback
|
||||
setMsg({ kind: "success", text: `${quantity} × ${product.name} added to cart` });
|
||||
setTimeout(() => setMsg(null), 3000);
|
||||
}
|
||||
|
||||
async function handlePlaceOrder() {
|
||||
@@ -283,8 +522,37 @@ export default function WholesalePortalPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100 flex items-center justify-center">
|
||||
<p className="text-slate-500">Loading...</p>
|
||||
<div className="min-h-screen bg-slate-100">
|
||||
{previewMode && (
|
||||
<div className="bg-yellow-400 text-yellow-900 text-center py-2 text-sm font-medium">
|
||||
Admin Preview — viewing portal as {customer?.company_name}
|
||||
</div>
|
||||
)}
|
||||
{/* Skeleton header */}
|
||||
<div className="bg-white border-b border-slate-200 px-6 py-4">
|
||||
<div className="mx-auto max-w-5xl flex items-center justify-between">
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-6 w-40 bg-slate-200 rounded" />
|
||||
<div className="h-4 w-56 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="h-10 w-24 bg-slate-200 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Skeleton tab bar */}
|
||||
<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 => (
|
||||
<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">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[1, 2, 3, 4, 5, 6].map(i => <ProductSkeleton key={i} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -341,55 +609,147 @@ export default function WholesalePortalPage() {
|
||||
{tab === "products" && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Product Catalog</h2>
|
||||
{products.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 py-8 text-center">No products available.</p>
|
||||
{/* Search + filter bar */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1">
|
||||
<SearchBar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search products..."
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filterAvailability}
|
||||
onChange={e => setFilterAvailability(e.target.value as typeof filterAvailability)}
|
||||
className="rounded-xl border border-slate-300 px-3 py-3 text-sm outline-none focus:border-green-500 min-h-[48px]"
|
||||
>
|
||||
<option value="all">All ({allProducts.length})</option>
|
||||
<option value="available">Available ({allProducts.filter(p => p.availability === "available").length})</option>
|
||||
<option value="limited">Limited Stock</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{filteredProducts.length === 0 && !loading ? (
|
||||
allProducts.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={
|
||||
<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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
||||
</svg>
|
||||
}
|
||||
title="No products available"
|
||||
description="This wholesale portal doesn't have any products listed yet. Check back soon or contact support."
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg className="w-7 h-7 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
}
|
||||
title="No products match your search"
|
||||
description={`No products found for "${searchQuery}". Try adjusting your search or filters.`}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{products.map(p => (
|
||||
{filteredProducts.map(p => (
|
||||
<div key={p.id} className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-slate-900">{p.name}</h3>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{p.description ?? ""}</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${p.availability === "available" ? "bg-green-100 text-green-700" : "bg-slate-100 text-slate-500"}`}>
|
||||
{p.availability}
|
||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-semibold ${
|
||||
p.availability === "available" ? "bg-green-100 text-green-700" :
|
||||
p.availability === "limited" ? "bg-amber-100 text-amber-700" :
|
||||
"bg-slate-100 text-slate-500"
|
||||
}`}>
|
||||
{p.availability === "available" ? "In Stock" :
|
||||
p.availability === "limited" ? `Limited (${p.qty_available})` :
|
||||
"Out of Stock"}
|
||||
</span>
|
||||
{" "}{p.qty_available} {p.unit_type} available
|
||||
</p>
|
||||
<span className="text-xs text-slate-400">{p.unit_type}</span>
|
||||
</div>
|
||||
{p.price_tiers && p.price_tiers.length > 0 && (
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
<div className="mt-2 space-y-0.5">
|
||||
{pricingOverrides[p.id] !== undefined ? (
|
||||
<span className="text-green-700 font-medium">Your price: ${pricingOverrides[p.id]!.toFixed(2)}</span>
|
||||
) : (
|
||||
p.price_tiers.map((t: { min_qty: number; max_qty: number; price: number }, i: number) => (
|
||||
<span key={i} className="mr-2">
|
||||
{t.min_qty}{t.max_qty ? `-${t.max_qty}` : "+"}: ${t.price}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-green-600 font-semibold bg-green-50 px-2 py-0.5 rounded">
|
||||
Your price: ${pricingOverrides[p.id]!.toFixed(2)} / {p.unit_type}
|
||||
</span>
|
||||
))
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{p.price_tiers.slice(0, 3).map((t: { min_qty: number; max_qty: number; price: number }, i: number) => (
|
||||
<span key={i} className="text-xs text-slate-500">
|
||||
<span className="font-medium text-slate-700">${t.price.toFixed(2)}</span>
|
||||
<span className="text-slate-400 ml-1">
|
||||
{t.max_qty === 0 ? `${t.min_qty}+` : `${t.min_qty}–${t.max_qty}`}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{p.availability === "available" && (
|
||||
<button onClick={() => addToCart(p)}
|
||||
className="rounded-xl bg-green-600 px-4 py-2 text-xs font-semibold text-white hover:bg-green-700 shrink-0">
|
||||
+ Add
|
||||
</button>
|
||||
{p.availability === "available" && p.qty_available > 0 ? (
|
||||
<QuantityStepper
|
||||
maxQty={p.qty_available}
|
||||
onAdd={(qty) => addToCart(p, qty)}
|
||||
/>
|
||||
) : p.availability === "limited" && p.qty_available > 0 ? (
|
||||
<QuantityStepper
|
||||
maxQty={p.qty_available}
|
||||
onAdd={(qty) => addToCart(p, qty)}
|
||||
/>
|
||||
) : (
|
||||
<span className="rounded-xl bg-slate-100 px-3 py-2 text-xs font-medium text-slate-400 shrink-0">
|
||||
Unavailable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{searchQuery && (
|
||||
<p className="text-sm text-slate-500 text-center">
|
||||
Showing {filteredProducts.length} of {allProducts.length} products
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "cart" && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Your Order ({cart.length} items)</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
Your Order
|
||||
{cart.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-slate-500">({cart.length} item{cart.length !== 1 ? "s" : ""})</span>
|
||||
)}
|
||||
</h2>
|
||||
{cart.length > 0 && (
|
||||
<button
|
||||
onClick={() => setCart([])}
|
||||
className="text-sm text-red-500 hover:text-red-700 hover:underline"
|
||||
>
|
||||
Clear cart
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{cart.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 py-8 text-center">Cart is empty.</p>
|
||||
<EmptyState
|
||||
icon={
|
||||
<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="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||
</svg>
|
||||
}
|
||||
title="Your cart is empty"
|
||||
description="Browse our product catalog and add items to get started with your wholesale order."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-2xl bg-white shadow-sm ring-1 ring-slate-200 overflow-hidden">
|
||||
@@ -397,7 +757,7 @@ export default function WholesalePortalPage() {
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-semibold text-left">Product</th>
|
||||
<th className="px-5 py-3 font-semibold text-right">Qty</th>
|
||||
<th className="px-5 py-3 font-semibold text-center">Qty</th>
|
||||
<th className="px-5 py-3 font-semibold text-right">Unit Price</th>
|
||||
<th className="px-5 py-3 font-semibold text-right">Total</th>
|
||||
<th className="px-5 py-3"></th>
|
||||
@@ -405,14 +765,56 @@ export default function WholesalePortalPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{cart.map(item => (
|
||||
<tr key={item.product.id}>
|
||||
<td className="px-5 py-3 font-medium text-slate-900">{item.product.name}</td>
|
||||
<td className="px-5 py-3 text-right">{item.quantity} {item.product.unit_type}</td>
|
||||
<td className="px-5 py-3 text-right">${item.unitPrice.toFixed(2)}</td>
|
||||
<td className="px-5 py-3 text-right font-semibold">${(item.quantity * item.unitPrice).toFixed(2)}</td>
|
||||
<td className="px-5 py-3">
|
||||
<button onClick={() => setCart(prev => prev.filter(i => i.product.id !== item.product.id))}
|
||||
className="text-xs text-red-500 hover:underline">Remove</button>
|
||||
<tr key={item.product.id} className="hover:bg-slate-50/50">
|
||||
<td className="px-5 py-4">
|
||||
<span className="font-medium text-slate-900">{item.product.name}</span>
|
||||
<span className="ml-1 text-xs text-slate-400">/ {item.product.unit_type}</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (item.quantity > 1) {
|
||||
setCart(prev => prev.map(i =>
|
||||
i.product.id === item.product.id
|
||||
? { ...i, quantity: i.quantity - 1 }
|
||||
: i
|
||||
));
|
||||
} else {
|
||||
setCart(prev => prev.filter(i => i.product.id !== item.product.id));
|
||||
}
|
||||
}}
|
||||
className="w-7 h-7 rounded-lg border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 disabled:opacity-30"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-10 text-center font-semibold text-slate-900">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (item.quantity < item.product.qty_available) {
|
||||
setCart(prev => prev.map(i =>
|
||||
i.product.id === item.product.id
|
||||
? { ...i, quantity: i.quantity + 1 }
|
||||
: i
|
||||
));
|
||||
}
|
||||
}}
|
||||
disabled={item.quantity >= item.product.qty_available}
|
||||
className="w-7 h-7 rounded-lg border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 disabled:opacity-30"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right text-slate-600">${item.unitPrice.toFixed(2)}</td>
|
||||
<td className="px-5 py-4 text-right font-semibold text-slate-900">${(item.quantity * item.unitPrice).toFixed(2)}</td>
|
||||
<td className="px-5 py-4">
|
||||
<button
|
||||
onClick={() => setCart(prev => prev.filter(i => i.product.id !== item.product.id))}
|
||||
className="text-xs text-red-500 hover:text-red-700 hover:underline"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -420,20 +822,44 @@ export default function WholesalePortalPage() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 items-end">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Pickup Date</label>
|
||||
<input type="date" value={pickupDate} onChange={e => setPickupDate(e.target.value)}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2 text-sm outline-none focus:border-green-600" />
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<p className="text-sm text-slate-500 mb-1">
|
||||
Total: <span className="font-bold text-slate-900">${cart.reduce((s, i) => s + i.quantity * i.unitPrice, 0).toFixed(2)}</span>
|
||||
</p>
|
||||
<button onClick={handlePlaceOrder} disabled={submitting || !pickupDate}
|
||||
className="rounded-xl bg-green-600 px-6 py-3 text-sm font-bold text-white hover:bg-green-700 disabled:opacity-50">
|
||||
{submitting ? "Placing Order..." : "Place Order"}
|
||||
</button>
|
||||
<div className="rounded-2xl bg-white shadow-sm ring-1 ring-slate-200 p-5">
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-1.5">
|
||||
Preferred Pickup Date
|
||||
<span className="text-slate-400 font-normal ml-1">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={pickupDate}
|
||||
onChange={e => setPickupDate(e.target.value)}
|
||||
min={new Date().toISOString().split("T")[0]}
|
||||
className="w-full rounded-xl border border-slate-300 px-4 py-3 text-sm outline-none focus:border-green-500 focus:ring-1 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between sm:flex-col sm:items-end gap-3">
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-slate-500">Order Total</p>
|
||||
<p className="text-2xl font-bold text-slate-900">
|
||||
${cart.reduce((s, i) => s + i.quantity * i.unitPrice, 0).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handlePlaceOrder}
|
||||
disabled={submitting || cart.length === 0}
|
||||
className="rounded-xl bg-green-600 px-8 py-3 text-base font-bold text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-lg shadow-green-900/20 min-w-[160px]"
|
||||
>
|
||||
{submitting ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<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>
|
||||
Placing Order...
|
||||
</span>
|
||||
) : "Place Order"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -443,63 +869,95 @@ export default function WholesalePortalPage() {
|
||||
|
||||
{tab === "orders" && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Order History</h2>
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
Order History
|
||||
{orders.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-slate-500">({orders.length} order{orders.length !== 1 ? "s" : ""})</span>
|
||||
)}
|
||||
</h2>
|
||||
{orders.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 py-8 text-center">No orders yet.</p>
|
||||
<EmptyState
|
||||
icon={
|
||||
<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>
|
||||
}
|
||||
title="No orders yet"
|
||||
description="Once you place your first wholesale order, it will appear here with full order history and tracking."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{orders.map(o => (
|
||||
<div key={o.id} className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div key={o.id} className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 hover:ring-slate-300 transition-shadow">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900">{o.invoice_number ?? o.id.slice(0, 8)}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-semibold text-slate-900">{o.invoice_number ?? "—"}</p>
|
||||
{o.invoice_number && (
|
||||
<span className="text-xs text-slate-400 font-mono">{o.id.slice(0, 8)}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
{formatDate(new Date(o.created_at))}
|
||||
{o.anticipated_pickup_date && ` · Pickup: ${o.anticipated_pickup_date}`}
|
||||
{o.anticipated_pickup_date && (
|
||||
<span className="ml-2 text-green-600 font-medium">→ Pickup: {o.anticipated_pickup_date}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold text-slate-900">${Number(o.subtotal).toFixed(2)}</p>
|
||||
<p className={`text-xs font-medium ${
|
||||
<p className="text-xl font-bold text-slate-900">${Number(o.subtotal).toFixed(2)}</p>
|
||||
<p className={`text-sm font-medium ${
|
||||
o.payment_status === "paid" ? "text-green-600" : "text-orange-500"
|
||||
}`}>
|
||||
{o.payment_status === "paid" ? "Paid" : `$${Number(o.balance_due).toFixed(2)} due`}
|
||||
{o.payment_status === "paid" ? "Paid in full" : `$${Number(o.balance_due).toFixed(2)} due`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||||
o.status === "fulfilled" ? "bg-green-100 text-green-700" :
|
||||
o.status === "pending" ? "bg-yellow-100 text-yellow-700" :
|
||||
o.status === "awaiting_deposit" ? "bg-purple-100 text-purple-700" :
|
||||
"bg-blue-100 text-blue-700"
|
||||
o.status === "confirmed" ? "bg-blue-100 text-blue-700" :
|
||||
"bg-slate-100 text-slate-700"
|
||||
}`}>
|
||||
{o.status.replace("_", " ")}
|
||||
{o.status.replace(/_/g, " ")}
|
||||
</span>
|
||||
{o.items && o.items.length > 0 && (
|
||||
<span className="text-xs text-slate-500">
|
||||
<span className="text-xs text-slate-500 flex items-center gap-1">
|
||||
<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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
||||
</svg>
|
||||
{o.items.length} item{o.items.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{o.invoice_number && o.invoice_token && (
|
||||
<a
|
||||
href={`/api/wholesale/invoice/${o.id}/pdf?token=${o.invoice_token}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl bg-slate-800 px-3 py-1.5 text-xs font-semibold text-white hover:bg-slate-700"
|
||||
>
|
||||
Download Invoice
|
||||
</a>
|
||||
)}
|
||||
{onlinePaymentEnabled && Number(o.balance_due) > 0 && o.payment_status !== "paid" && (
|
||||
<button
|
||||
onClick={() => handlePayNow(o)}
|
||||
disabled={processingPayment === o.id}
|
||||
className="ml-auto rounded-xl bg-green-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{processingPayment === o.id ? "Loading..." : "Pay Now"}
|
||||
</button>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{o.invoice_number && o.invoice_token && (
|
||||
<a
|
||||
href={`/api/wholesale/invoice/${o.id}/pdf?token=${o.invoice_token}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl bg-slate-800 px-3 py-1.5 text-xs font-semibold text-white hover:bg-slate-700 flex items-center gap-1"
|
||||
>
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
Invoice
|
||||
</a>
|
||||
)}
|
||||
{onlinePaymentEnabled && Number(o.balance_due) > 0 && o.payment_status !== "paid" && (
|
||||
<button
|
||||
onClick={() => handlePayNow(o)}
|
||||
disabled={processingPayment === o.id}
|
||||
className="rounded-xl bg-green-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-green-700 disabled:opacity-50 flex items-center gap-1"
|
||||
>
|
||||
<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="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
|
||||
</svg>
|
||||
{processingPayment === o.id ? "Loading..." : "Pay Now"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -4,15 +4,45 @@ import { useState, useEffect } from "react";
|
||||
import { registerWholesaleCustomer } from "@/actions/wholesale-register";
|
||||
import { useRouter } from "next/navigation";
|
||||
import WholesaleBenefits from "@/components/wholesale/WholesaleBenefits";
|
||||
import Link from "next/link";
|
||||
|
||||
const IRD_BRAND_ID = "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28";
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
function FormField({ label, id, error, hint, children }: {
|
||||
label: string;
|
||||
id: string;
|
||||
error?: string | null;
|
||||
hint?: string | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="block text-sm font-semibold text-zinc-400 mb-1.5">{label}</label>
|
||||
{children}
|
||||
{error && <p className="mt-1 text-xs text-red-400 flex items-center gap-1">
|
||||
<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 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>
|
||||
{error}
|
||||
</p>}
|
||||
{hint && !error && <p className="mt-1 text-xs text-zinc-500">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 [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);
|
||||
|
||||
@@ -32,10 +62,34 @@ export default function WholesaleRegisterPage() {
|
||||
checkEnabled(form.brandId);
|
||||
}, [form.brandId]);
|
||||
|
||||
function validateForm() {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!form.companyName.trim()) {
|
||||
errors.companyName = "Company name is required";
|
||||
}
|
||||
if (!form.email) {
|
||||
errors.email = "Email is required";
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
|
||||
errors.email = "Enter a valid email address";
|
||||
}
|
||||
if (form.phone && !/^[\d\s\-\+\(\)]+$/.test(form.phone)) {
|
||||
errors.phone = "Enter a valid phone number";
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const errors = validateForm();
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setFieldErrors(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setResult(null);
|
||||
setFieldErrors({});
|
||||
|
||||
const res = await registerWholesaleCustomer({
|
||||
brandId: form.brandId,
|
||||
companyName: form.companyName,
|
||||
@@ -52,14 +106,17 @@ export default function WholesaleRegisterPage() {
|
||||
: "Account created — you can now log in.",
|
||||
});
|
||||
} else {
|
||||
setResult({ success: false, message: res.error ?? "Registration failed." });
|
||||
setResult({ 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">
|
||||
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -70,7 +127,7 @@ export default function WholesaleRegisterPage() {
|
||||
<div className="bg-zinc-900 border-b border-zinc-800">
|
||||
<div className="mx-auto max-w-5xl px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-900/60 border border-emerald-700/50">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-900/60 border border-emerald-700/50">
|
||||
<svg className="h-5 w-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
|
||||
</svg>
|
||||
@@ -80,16 +137,21 @@ export default function WholesaleRegisterPage() {
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Fresh produce at wholesale prices</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/" className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors">← Back to site</a>
|
||||
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors flex items-center gap-1">
|
||||
<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="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
Back to site
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-5xl px-6 py-12">
|
||||
<div className="grid gap-10 lg:grid-cols-[1fr_420px] lg:items-start">
|
||||
<div className="grid gap-12 lg:grid-cols-[1fr_420px] lg:items-start">
|
||||
|
||||
{/* Benefits column */}
|
||||
<div className="lg:pt-4">
|
||||
<h2 className="text-3xl font-black text-zinc-100 tracking-tight mb-2">
|
||||
<h2 className="text-3xl font-black text-zinc-100 tracking-tight mb-3">
|
||||
Ready to get wholesale pricing?
|
||||
</h2>
|
||||
<p className="text-zinc-500 mb-8 leading-relaxed">
|
||||
@@ -101,103 +163,152 @@ 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 && (
|
||||
<div className="mb-4 rounded-xl border border-amber-900/50 bg-amber-950/50 px-4 py-3 text-sm text-amber-400">
|
||||
<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"/>
|
||||
</svg>
|
||||
The wholesale portal is currently disabled for this brand. You may still apply — an admin will activate your account.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-zinc-100">Apply for an Account</h1>
|
||||
<h1 className="text-2xl font-bold text-zinc-100">Create an account</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">We'll review and respond within 1–2 business days.</p>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className={`mb-4 rounded-xl border px-4 py-3 text-sm ${
|
||||
<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>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-zinc-400 mb-1.5">Company Name *</label>
|
||||
<input
|
||||
value={form.companyName}
|
||||
onChange={e => setForm(f => ({ ...f, companyName: e.target.value }))}
|
||||
required
|
||||
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="Farm or business name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-zinc-400 mb-1.5">Contact Name</label>
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-zinc-400 mb-1.5">Email *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
|
||||
required
|
||||
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="order@company.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-zinc-400 mb-1.5">Phone</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={form.phone}
|
||||
onChange={e => setForm(f => ({ ...f, phone: 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="(555) 555-5555"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-zinc-400 mb-1.5">Brand</label>
|
||||
<select
|
||||
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"
|
||||
{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"
|
||||
>
|
||||
<option value={TUXEDO_BRAND_ID}>Tuxedo Corn — Colorado Sweet Corn</option>
|
||||
<option value={IRD_BRAND_ID}>Indian River Direct — Florida Citrus</option>
|
||||
</select>
|
||||
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>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full rounded-xl bg-emerald-600 py-3 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-2"
|
||||
>
|
||||
{submitting ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<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...
|
||||
</span>
|
||||
) : "Submit Application"}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<FormField label="Company Name" id="companyName" error={fieldErrors.companyName} hint={null}>
|
||||
<input
|
||||
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>
|
||||
|
||||
<div className="mt-4 rounded-xl border border-zinc-800 bg-zinc-950 p-4">
|
||||
<p className="text-sm text-zinc-400">
|
||||
Already have an account?{" "}
|
||||
<a href="/wholesale/login" className="text-emerald-400 hover:underline font-medium hover:text-emerald-300">
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<FormField label="Contact Name" id="contactName" error={null} hint="Optional — your name">
|
||||
<input
|
||||
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
|
||||
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
|
||||
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
|
||||
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>
|
||||
)}
|
||||
|
||||
{!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?{" "}
|
||||
<Link href="/wholesale/login" className="text-emerald-400 hover:underline font-medium hover:text-emerald-300">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user