Initial commit - Route Commerce platform
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
type WholesaleOrder,
|
||||
getWholesalePickupOrders,
|
||||
markWholesaleOrderFulfilled,
|
||||
} from "@/actions/wholesale";
|
||||
import DepositModal from "@/components/wholesale/DepositModal";
|
||||
import OrderDetailsModal from "@/components/wholesale/OrderDetailsModal";
|
||||
|
||||
type Queue = "past_due" | "today" | "upcoming";
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const map: Record<string, string> = {
|
||||
pending: "bg-yellow-100 text-yellow-700",
|
||||
awaiting_deposit: "bg-purple-100 text-purple-700",
|
||||
confirmed: "bg-blue-100 text-blue-700",
|
||||
fulfilled: "bg-green-100 text-green-700",
|
||||
cancelled: "bg-red-100 text-red-700",
|
||||
};
|
||||
return (
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${map[status] ?? "bg-slate-100 text-slate-600"}`}>
|
||||
{status.replace(/_/g, " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EmployeePortalPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [orders, setOrders] = useState<WholesaleOrder[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<Queue>("today");
|
||||
const [showViewOrder, setShowViewOrder] = useState<WholesaleOrder | null>(null);
|
||||
const [showDepForm, setShowDepForm] = useState<string | null>(null);
|
||||
const [fulfilling, setFulfilling] = useState<string | null>(null);
|
||||
const [openActions, setOpenActions] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||
const [brandName, setBrandName] = useState("");
|
||||
const [employeeName, setEmployeeName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
// Auth check via employee_session cookie — dedicated for store employees
|
||||
const cookies = document.cookie.split(";").reduce((acc, c) => {
|
||||
const [k, v] = c.trim().split("=");
|
||||
acc[k] = decodeURIComponent(v);
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
const session = cookies["employee_session"];
|
||||
if (!session) {
|
||||
router.push("/wholesale/login");
|
||||
return;
|
||||
}
|
||||
|
||||
let userId: string;
|
||||
let brandId: string;
|
||||
let empName: string;
|
||||
let brandNameVal = "Pickup Portal";
|
||||
try {
|
||||
const parsed = JSON.parse(session);
|
||||
userId = parsed.user_id;
|
||||
brandId = parsed.brand_id;
|
||||
empName = parsed.name || parsed.email || "Employee";
|
||||
brandNameVal = parsed.brand_name || "Pickup Portal";
|
||||
} catch {
|
||||
router.push("/wholesale/login");
|
||||
return;
|
||||
}
|
||||
|
||||
setBrandName(brandNameVal);
|
||||
setEmployeeName(empName);
|
||||
|
||||
getWholesalePickupOrders(brandId).then((data) => {
|
||||
setOrders(data);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [router]);
|
||||
|
||||
async function handleSignOut() {
|
||||
document.cookie = "employee_session=; path=/; max-age=0";
|
||||
document.cookie = "dev_session=; path=/; max-age=0";
|
||||
router.push("/wholesale/login");
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (!(e.target as Element).closest(".actions-cell")) {
|
||||
setOpenActions(null);
|
||||
}
|
||||
}
|
||||
document.addEventListener("click", handleClick);
|
||||
return () => document.removeEventListener("click", handleClick);
|
||||
}, []);
|
||||
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
const pastDue = orders.filter((o) => o.anticipated_pickup_date && o.anticipated_pickup_date < today);
|
||||
const todayOrders = orders.filter((o) => o.anticipated_pickup_date === today);
|
||||
const upcoming = orders.filter((o) => o.anticipated_pickup_date && o.anticipated_pickup_date > today);
|
||||
|
||||
const tabData: Record<Queue, { label: string; orders: WholesaleOrder[]; color: string; bg: string }> = {
|
||||
past_due: { label: "Past Due", orders: pastDue, color: "text-red-700", bg: "bg-red-50 border-red-200" },
|
||||
today: { label: "Today", orders: todayOrders, color: "text-yellow-700", bg: "bg-yellow-50 border-yellow-200" },
|
||||
upcoming: { label: "Upcoming", orders: upcoming, color: "text-blue-700", bg: "bg-blue-50 border-blue-200" },
|
||||
};
|
||||
|
||||
const currentTab = tabData[activeTab];
|
||||
|
||||
async function handleFulfill(orderId: string) {
|
||||
setFulfilling(orderId);
|
||||
const result = await markWholesaleOrderFulfilled(orderId);
|
||||
setFulfilling(null);
|
||||
if (result.success) {
|
||||
setMsg({ kind: "success", text: "Order marked as fulfilled." });
|
||||
// Reload orders using employee_session brand_id
|
||||
const cookies = document.cookie.split(";").reduce((acc, c) => {
|
||||
const [k, v] = c.trim().split("=");
|
||||
acc[k] = decodeURIComponent(v);
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
const session = cookies["employee_session"];
|
||||
if (session) {
|
||||
const { brand_id } = JSON.parse(session);
|
||||
const data = await getWholesalePickupOrders(brand_id);
|
||||
setOrders(data);
|
||||
}
|
||||
} else {
|
||||
setMsg({ kind: "error", text: result.error ?? "Failed to fulfill order." });
|
||||
}
|
||||
setTimeout(() => setMsg(null), 4000);
|
||||
}
|
||||
|
||||
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 (
|
||||
<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>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-slate-500">{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"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Queue tabs */}
|
||||
<div className="bg-white border-b border-slate-200 px-6">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<nav className="flex gap-1 -mb-px">
|
||||
{(["past_due", "today", "upcoming"] as Queue[]).map((tab) => {
|
||||
const data = tabData[tab];
|
||||
const isActive = activeTab === tab;
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 ${
|
||||
isActive
|
||||
? tab === "past_due" ? "border-red-500 text-red-700" :
|
||||
tab === "today" ? "border-yellow-500 text-yellow-700" :
|
||||
"border-blue-500 text-blue-700"
|
||||
: "border-transparent text-slate-500 hover:text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{data.label}
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-bold ${
|
||||
tab === "past_due" ? "bg-red-100 text-red-700" :
|
||||
tab === "today" ? "bg-yellow-100 text-yellow-700" :
|
||||
"bg-blue-100 text-blue-700"
|
||||
}`}>
|
||||
{data.orders.length}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-5xl px-6 py-6">
|
||||
{msg && (
|
||||
<div className={`mb-4 rounded-xl border px-4 py-3 text-sm ${
|
||||
msg.kind === "success" ? "border-green-200 bg-green-50 text-green-700" : "border-red-200 bg-red-50 text-red-700"
|
||||
}`}>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{currentTab.orders.map((order) => (
|
||||
<OrderCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
fulfilling={fulfilling}
|
||||
openActions={openActions}
|
||||
onToggleActions={() => {
|
||||
setOpenActions(openActions === order.id ? null : order.id);
|
||||
}}
|
||||
onFulfill={handleFulfill}
|
||||
onRecordDeposit={() => setShowDepForm(order.id)}
|
||||
onViewDetails={() => setShowViewOrder(order)}
|
||||
onGenerateManifest={() => {
|
||||
const w = window.open("", "_blank");
|
||||
if (w) {
|
||||
const cookies = document.cookie.split(";").reduce((acc, c) => {
|
||||
const [k, v] = c.trim().split("=");
|
||||
acc[k] = decodeURIComponent(v);
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
const session = cookies["employee_session"];
|
||||
const parsed = session ? JSON.parse(session) : {};
|
||||
const bId = parsed.brand_id ?? "";
|
||||
fetch("/api/wholesale/manifest", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ brandId: bId, orders: [order] }),
|
||||
}).then(r => r.text()).then(html => { w.document.write(html); w.document.close(); });
|
||||
}
|
||||
}}
|
||||
onSendPriceSheet={() => {
|
||||
fetch("/api/wholesale/price-sheet", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ customerIds: [order.customer_id], brandId: "" }),
|
||||
}).then(r => r.json()).then(d => {
|
||||
setMsg({ kind: "success", text: `Price sheet sent to ${order.company_name}.` });
|
||||
}).catch(() => setMsg({ kind: "error", text: "Failed to send price sheet." }));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showViewOrder && (
|
||||
<OrderDetailsModal
|
||||
order={showViewOrder}
|
||||
onClose={() => setShowViewOrder(null)}
|
||||
onFulfill={handleFulfill}
|
||||
onRecordDeposit={(id) => { setShowViewOrder(null); setShowDepForm(id); }}
|
||||
fulfilling={fulfilling}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDepForm && (() => {
|
||||
const order = orders.find(o => o.id === showDepForm);
|
||||
if (!order) return null;
|
||||
return (
|
||||
<DepositModal
|
||||
order={order}
|
||||
onClose={() => setShowDepForm(null)}
|
||||
onFulfilled={async () => {
|
||||
setMsg({ kind: "success", text: "Deposit recorded." });
|
||||
const cookies = document.cookie.split(";").reduce((acc, c) => {
|
||||
const [k, v] = c.trim().split("=");
|
||||
acc[k] = decodeURIComponent(v);
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
const session = cookies["employee_session"];
|
||||
if (session) {
|
||||
const { brand_id } = JSON.parse(session);
|
||||
const data = await getWholesalePickupOrders(brand_id);
|
||||
setOrders(data);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Order Card ────────────────────────────────────────────────────────────────
|
||||
|
||||
function OrderCard({
|
||||
order,
|
||||
fulfilling,
|
||||
openActions,
|
||||
onToggleActions,
|
||||
onFulfill,
|
||||
onRecordDeposit,
|
||||
onViewDetails,
|
||||
onGenerateManifest,
|
||||
onSendPriceSheet,
|
||||
}: {
|
||||
order: WholesaleOrder;
|
||||
fulfilling: string | null;
|
||||
openActions: string | null;
|
||||
onToggleActions: () => void;
|
||||
onFulfill: (id: string) => void;
|
||||
onRecordDeposit: () => void;
|
||||
onViewDetails: () => void;
|
||||
onGenerateManifest: () => void;
|
||||
onSendPriceSheet?: () => void;
|
||||
}) {
|
||||
const hasPhone = Boolean(order.customer_phone);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-white shadow-sm ring-1 ring-slate-200 overflow-hidden">
|
||||
{/* Card header */}
|
||||
<div className="px-5 py-4 flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-slate-900">{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 ?? ""} ·{" "}
|
||||
{hasPhone ? (
|
||||
<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>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
{order.invoice_number ?? order.id.slice(0, 8)} · Pickup: {order.anticipated_pickup_date ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Inline action buttons */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{order.fulfillment_status !== "fulfilled" && (
|
||||
<button
|
||||
onClick={() => !fulfilling && onFulfill(order.id)}
|
||||
disabled={fulfilling === order.id}
|
||||
title="Mark as fulfilled"
|
||||
className="inline-flex items-center gap-1.5 rounded-xl bg-green-600 px-3 py-2 text-xs font-bold text-white shadow-sm hover:bg-green-700 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
{fulfilling === order.id ? "..." : "Fulfill"}
|
||||
</button>
|
||||
)}
|
||||
{Number(order.balance_due) > 0 && order.fulfillment_status !== "fulfilled" && (
|
||||
<button
|
||||
onClick={onRecordDeposit}
|
||||
title="Record deposit"
|
||||
className="inline-flex items-center gap-1.5 rounded-xl bg-purple-600 px-3 py-2 text-xs font-bold text-white shadow-sm hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 6v12m-9-9h18" /></svg>
|
||||
Deposit
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href={`/api/wholesale/invoice/${order.id}/pdf`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Download Invoice (PDF)"
|
||||
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-slate-500 hover:text-slate-800 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12h9m-9-6h6m-3 12a9 9 0 110 12H15M6 2h9l4 4v14a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2z"/></svg>
|
||||
</a>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={onToggleActions}
|
||||
title="More actions"
|
||||
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
|
||||
</button>
|
||||
{openActions === order.id && (
|
||||
<div
|
||||
className="absolute bottom-full right-0 mb-1 z-30 w-56 rounded-xl bg-white shadow-xl ring-1 ring-slate-200 py-1 text-sm"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<button onClick={() => { onToggleActions(); onViewDetails(); }} className="w-full text-left px-4 py-3 text-slate-700 hover:bg-slate-50 flex items-center gap-3">
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
View Details
|
||||
</button>
|
||||
<button onClick={() => { onToggleActions(); onGenerateManifest(); }} className="w-full text-left px-4 py-3 text-slate-700 hover:bg-slate-50 flex items-center gap-3">
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12h9m-9-6h6m-3 12a9 9 0 01-18 0 9 9 0 0118 0z"/></svg>
|
||||
Generate Manifest
|
||||
</button>
|
||||
<div className="my-1 border-t border-slate-100" />
|
||||
<button
|
||||
onClick={() => { onToggleActions(); onSendPriceSheet?.(); }}
|
||||
className="w-full text-left px-4 py-3 text-slate-700 hover:bg-slate-50 flex items-center gap-3"
|
||||
>
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
||||
Send Price Sheet
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line items summary */}
|
||||
{order.items && order.items.length > 0 && (
|
||||
<div className="px-5 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">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium text-xs">Product</th>
|
||||
<th className="px-4 py-2 text-right font-medium text-xs">Qty</th>
|
||||
<th className="px-4 py-2 text-right font-medium text-xs">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { wholesaleLoginAction } from "@/actions/wholesale-auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import WholesaleBenefits from "@/components/wholesale/WholesaleBenefits";
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
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 [selectedBrand, setSelectedBrand] = useState(BRANDS[1]);
|
||||
|
||||
useEffect(() => {
|
||||
const found = BRANDS.find(b => b.id === form.brandId);
|
||||
if (found) setSelectedBrand(found);
|
||||
}, [form.brandId]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const err = params.get("error");
|
||||
if (err === "portal_disabled") {
|
||||
setError("The wholesale portal is currently disabled. Contact us for assistance.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
const fd = new FormData(e.currentTarget as HTMLFormElement);
|
||||
fd.set("brand_id", form.brandId);
|
||||
const result = await wholesaleLoginAction(fd);
|
||||
setSubmitting(false);
|
||||
if (result.success) {
|
||||
router.push("/wholesale/portal");
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(result.error ?? "Login failed.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950">
|
||||
{/* Header bar */}
|
||||
<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>
|
||||
<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>
|
||||
</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">
|
||||
|
||||
{/* Benefits column */}
|
||||
<div className="lg:pt-4">
|
||||
<h2 className="text-3xl font-black text-zinc-100 tracking-tight mb-2">
|
||||
Grow your business with wholesale pricing
|
||||
</h2>
|
||||
<p className="text-zinc-500 mb-8 leading-relaxed">
|
||||
Join our wholesale program and get access to exclusive pricing, easier ordering, and priority fulfillment.
|
||||
</p>
|
||||
<WholesaleBenefits />
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
</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">
|
||||
{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>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
<input
|
||||
type="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"
|
||||
placeholder="buyer@company.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-zinc-400 mb-1.5">Password</label>
|
||||
<input
|
||||
type="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"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</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>
|
||||
Signing in...
|
||||
</span>
|
||||
) : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 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
|
||||
href="/wholesale/register"
|
||||
className="mt-1 inline-block text-sm text-emerald-400 hover:text-emerald-300 hover:underline"
|
||||
>
|
||||
Apply for a wholesale account →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-5xl px-6 py-8 text-center">
|
||||
<p className="text-xs text-zinc-600">
|
||||
Powered by Route Commerce
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
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" />
|
||||
</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>
|
||||
<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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
Continue Shopping
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
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>
|
||||
}>
|
||||
<PaymentSuccessContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentSuccessContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const sessionId = searchParams.get("session_id");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notified, setNotified] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } 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";
|
||||
import { getWholesaleSettings, enqueueWholesaleNotification } from "@/actions/wholesale";
|
||||
|
||||
type CartItem = {
|
||||
product: WholesaleProduct;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
};
|
||||
|
||||
export default function WholesalePortalPage() {
|
||||
const router = useRouter();
|
||||
const [customer, setCustomer] = useState<{
|
||||
id: string;
|
||||
user_id: string;
|
||||
company_name: string;
|
||||
contact_name: string;
|
||||
email: string;
|
||||
account_status: string;
|
||||
brand_id: string;
|
||||
} | null>(null);
|
||||
const [brandName, setBrandName] = useState("Wholesale Portal");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [previewMode, setPreviewMode] = useState(false);
|
||||
const [tab, setTab] = useState<"products" | "cart" | "orders">("products");
|
||||
const [products, setProducts] = useState<WholesaleProduct[]>([]);
|
||||
const [cart, setCart] = useState<CartItem[]>([]);
|
||||
const [orders, setOrders] = useState<WholesaleCustomerOrder[]>([]);
|
||||
const [pricingOverrides, setPricingOverrides] = useState<Record<string, number>>({});
|
||||
const [pickupDate, setPickupDate] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||
const [onlinePaymentEnabled, setOnlinePaymentEnabled] = useState(false);
|
||||
const [processingPayment, setProcessingPayment] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// ── Admin preview mode ──────────────────────────────────────────────────
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const isPreview = params.get("_admin_preview") === "1";
|
||||
const previewCustomerId = params.get("preview_customer_id");
|
||||
|
||||
if (isPreview && previewCustomerId) {
|
||||
// Load customer directly by ID (admin preview mode — no login required)
|
||||
getWholesaleCustomer(previewCustomerId).then(async cust => {
|
||||
if (!cust) {
|
||||
// In preview mode with an invalid customer ID, show empty state
|
||||
setCustomer(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const { supabase } = await import("@/lib/supabase");
|
||||
const { data: ws } = await supabase
|
||||
.from("wholesale_settings")
|
||||
.select("wholesale_enabled, online_payment_enabled")
|
||||
.eq("brand_id", cust.brand_id)
|
||||
.single();
|
||||
setOnlinePaymentEnabled(ws?.online_payment_enabled ?? false);
|
||||
setCustomer(cust);
|
||||
setPreviewMode(true);
|
||||
setLoading(false);
|
||||
loadBrandName(cust.brand_id);
|
||||
loadProducts(cust.brand_id);
|
||||
loadOrders(cust.id);
|
||||
loadPricingOverrides(cust.id);
|
||||
});
|
||||
return; // ← prevent the synchronous session check below from running while preview loads
|
||||
}
|
||||
|
||||
// ── Normal session-based login ────────────────────────────────────────
|
||||
const cookies = document.cookie.split(";").reduce((acc, c) => {
|
||||
const [k, v] = c.trim().split("=");
|
||||
acc[k] = decodeURIComponent(v);
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
const session = cookies["wholesale_session"];
|
||||
if (!session) {
|
||||
router.push("/wholesale/login");
|
||||
return;
|
||||
}
|
||||
|
||||
let userId: string;
|
||||
try {
|
||||
const parsed = JSON.parse(session);
|
||||
userId = parsed.user_id;
|
||||
} catch {
|
||||
router.push("/wholesale/login");
|
||||
return;
|
||||
}
|
||||
|
||||
getWholesaleCustomerByUser("00000000-0000-0000-0000-000000000000", userId).then(async cust => {
|
||||
if (!cust || cust.account_status !== "active") {
|
||||
router.push("/wholesale/login?error=account_not_active");
|
||||
return;
|
||||
}
|
||||
|
||||
const { supabase } = await import("@/lib/supabase");
|
||||
const { data: ws } = await supabase
|
||||
.from("wholesale_settings")
|
||||
.select("wholesale_enabled, online_payment_enabled")
|
||||
.eq("brand_id", cust.brand_id)
|
||||
.single();
|
||||
if (ws && ws.wholesale_enabled === false) {
|
||||
router.push("/wholesale/login?error=portal_disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
setOnlinePaymentEnabled(ws?.online_payment_enabled ?? false);
|
||||
setCustomer(cust);
|
||||
setLoading(false);
|
||||
loadBrandName(cust.brand_id);
|
||||
loadProducts(cust.brand_id);
|
||||
loadOrders(cust.id);
|
||||
loadPricingOverrides(cust.id);
|
||||
});
|
||||
}, [router]);
|
||||
|
||||
async function loadBrandName(brandId: string) {
|
||||
const { supabase } = await import("@/lib/supabase");
|
||||
const { data: brand } = await supabase
|
||||
.from("brands")
|
||||
.select("name")
|
||||
.eq("id", brandId)
|
||||
.single();
|
||||
if (brand) setBrandName(brand.name);
|
||||
}
|
||||
|
||||
async function loadProducts(brandId: string) {
|
||||
const { getWholesaleProducts } = await import("@/actions/wholesale");
|
||||
const prods = await getWholesaleProducts(brandId);
|
||||
setProducts(prods);
|
||||
}
|
||||
|
||||
async function loadOrders(customerId: string) {
|
||||
const orderList = await getWholesaleCustomerOrders(customerId);
|
||||
setOrders(orderList);
|
||||
}
|
||||
|
||||
async function loadPricingOverrides(customerId: string) {
|
||||
const pricing = await getWholesaleCustomerPricing(customerId);
|
||||
const map: Record<string, number> = {};
|
||||
for (const p of pricing) {
|
||||
map[p.product_id] = p.custom_unit_price;
|
||||
}
|
||||
setPricingOverrides(map);
|
||||
}
|
||||
|
||||
async function handlePayNow(order: WholesaleCustomerOrder) {
|
||||
if (!customer) return;
|
||||
setProcessingPayment(order.id);
|
||||
try {
|
||||
const res = await fetch("/api/wholesale/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ orderId: order.id, customerId: customer.id }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.checkoutUrl) {
|
||||
window.location.href = data.checkoutUrl;
|
||||
} else {
|
||||
setMsg({ kind: "error", text: data.error ?? "Failed to start payment." });
|
||||
}
|
||||
} catch {
|
||||
setMsg({ kind: "error", text: "Payment request failed." });
|
||||
} finally {
|
||||
setProcessingPayment(null);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Determine unit price: customer override first, then price tiers
|
||||
let unitPrice = 0;
|
||||
const overridePrice = pricingOverrides[product.id];
|
||||
if (overridePrice !== undefined) {
|
||||
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)
|
||||
);
|
||||
unitPrice = tier ? tier.price : product.price_tiers[product.price_tiers.length - 1].price;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return [...prev, { product, quantity: q, unitPrice }];
|
||||
});
|
||||
setTab("cart");
|
||||
}
|
||||
|
||||
async function handlePlaceOrder() {
|
||||
if (!customer || cart.length === 0) return;
|
||||
setSubmitting(true);
|
||||
setMsg(null);
|
||||
|
||||
const checkoutSessionId = crypto.randomUUID();
|
||||
const items = cart.map(c => ({
|
||||
product_id: c.product.id,
|
||||
quantity: c.quantity,
|
||||
unit_price: c.unitPrice,
|
||||
}));
|
||||
|
||||
const result = await submitWholesaleOrder({
|
||||
brandId: customer.brand_id,
|
||||
customerId: customer.id,
|
||||
anticipatedPickupDate: pickupDate || undefined,
|
||||
items,
|
||||
checkoutSessionId,
|
||||
});
|
||||
|
||||
setSubmitting(false);
|
||||
if (result.success) {
|
||||
setMsg({ kind: "success", text: `Order placed! Invoice: ${result.invoiceNumber} — Status: ${result.status}` });
|
||||
setCart([]);
|
||||
setPickupDate("");
|
||||
if (customer) loadOrders(customer.id);
|
||||
|
||||
// Enqueue order confirmation notification
|
||||
if (result.orderId && customer) {
|
||||
const total = cart.reduce((s, i) => s + i.quantity * i.unitPrice, 0);
|
||||
enqueueWholesaleNotification({
|
||||
brandId: customer.brand_id,
|
||||
customerId: customer.id,
|
||||
orderId: result.orderId,
|
||||
type: "order_confirmation",
|
||||
emailTo: customer.email,
|
||||
subject: `Order ${result.invoiceNumber} Confirmed`,
|
||||
bodyHtml: `
|
||||
<h2>Thank you for your order, ${customer.company_name}!</h2>
|
||||
<p>Your wholesale order <strong>${result.invoiceNumber}</strong> has been received.</p>
|
||||
<p><strong>Total:</strong> $${total.toFixed(2)}</p>
|
||||
${result.status === "awaiting_deposit" ? `<p><strong>Deposit Required:</strong> Your order is awaiting a deposit payment.</p>` : ""}
|
||||
<p>We'll notify you when your order is ready for pickup.</p>
|
||||
`,
|
||||
bodyText: `Order ${result.invoiceNumber} confirmed. Total: $${total.toFixed(2)}. Status: ${result.status}.`,
|
||||
});
|
||||
|
||||
// Also notify the admin
|
||||
const settings = await getWholesaleSettings(customer.brand_id);
|
||||
const adminEmail = settings?.notification_email ?? settings?.from_email ?? settings?.invoice_business_email ?? null;
|
||||
if (adminEmail) {
|
||||
enqueueWholesaleNotification({
|
||||
brandId: customer.brand_id,
|
||||
customerId: customer.id,
|
||||
orderId: result.orderId,
|
||||
type: "order_confirmation",
|
||||
emailTo: adminEmail,
|
||||
subject: `[Admin] New Wholesale Order — ${result.invoiceNumber}`,
|
||||
bodyHtml: `
|
||||
<h2>New Wholesale Order</h2>
|
||||
<p>A new wholesale order has been placed by <strong>${customer.company_name}</strong>.</p>
|
||||
<p><strong>Invoice:</strong> ${result.invoiceNumber}</p>
|
||||
<p><strong>Total:</strong> $${total.toFixed(2)}</p>
|
||||
<p><strong>Status:</strong> ${result.status}</p>
|
||||
${result.status === "awaiting_deposit" ? `<p><strong>Action Required:</strong> Awaiting deposit payment.</p>` : ""}
|
||||
`,
|
||||
bodyText: `New wholesale order ${result.invoiceNumber} from ${customer.company_name}. Total: $${total.toFixed(2)}. Status: ${result.status}.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger notification send (fire-and-forget)
|
||||
fetch(`/api/wholesale/notifications/send`, { method: "POST" }).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
let errorText = result.error ?? "Failed to place order.";
|
||||
if (result.creditLimit !== undefined) {
|
||||
errorText += ` (Credit limit: $${result.creditLimit} | Outstanding: $${result.outstandingBalance?.toFixed(2)} | Order total: $${result.orderTotal?.toFixed(2)})`;
|
||||
}
|
||||
setMsg({ kind: "error", text: errorText });
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100 flex items-center justify-center">
|
||||
<p className="text-slate-500">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
{/* 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-xl font-bold text-slate-900">{brandName}</h1>
|
||||
<p className="text-sm text-slate-500">Wholesale Portal — {customer?.company_name}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={() => { document.cookie = "wholesale_session=; path=/; max-age=0"; router.push("/wholesale/login"); }}
|
||||
className="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab nav */}
|
||||
<div className="bg-white border-b border-slate-200 px-6">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<nav className="flex gap-1 -mb-px">
|
||||
{(["products", "cart", "orders"] as const).map(t => (
|
||||
<button key={t} onClick={() => setTab(t)}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${tab === t ? "border-green-600 text-green-700" : "border-transparent text-slate-500 hover:text-slate-700"}`}>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
{t === "cart" && cart.length > 0 && (
|
||||
<span className="ml-2 bg-green-600 text-white text-xs rounded-full px-1.5 py-0.5">{cart.length}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-5xl px-6 py-6">
|
||||
{msg && (
|
||||
<div className={`mb-4 rounded-xl border px-4 py-3 text-sm ${
|
||||
msg.kind === "success" ? "border-green-200 bg-green-50 text-green-700" : "border-red-200 bg-red-50 text-red-700"
|
||||
}`}>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{products.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>
|
||||
<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}
|
||||
</span>
|
||||
{" "}{p.qty_available} {p.unit_type} available
|
||||
</p>
|
||||
{p.price_tiers && p.price_tiers.length > 0 && (
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
{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}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "cart" && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Your Order ({cart.length} items)</h2>
|
||||
{cart.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 py-8 text-center">Cart is empty.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-2xl bg-white shadow-sm ring-1 ring-slate-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<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-right">Unit Price</th>
|
||||
<th className="px-5 py-3 font-semibold text-right">Total</th>
|
||||
<th className="px-5 py-3"></th>
|
||||
</tr>
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "orders" && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Order History</h2>
|
||||
{orders.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 py-8 text-center">No orders yet.</p>
|
||||
) : (
|
||||
<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>
|
||||
<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">
|
||||
{formatDate(new Date(o.created_at))}
|
||||
{o.anticipated_pickup_date && ` · Pickup: ${o.anticipated_pickup_date}`}
|
||||
</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 ${
|
||||
o.payment_status === "paid" ? "text-green-600" : "text-orange-500"
|
||||
}`}>
|
||||
{o.payment_status === "paid" ? "Paid" : `$${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 ${
|
||||
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.replace("_", " ")}
|
||||
</span>
|
||||
{o.items && o.items.length > 0 && (
|
||||
<span className="text-xs text-slate-500">
|
||||
{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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-5xl px-6 py-8 text-center">
|
||||
<p className="text-xs text-slate-400">
|
||||
Powered by{" "}
|
||||
<a href="https://cielohermosa.com" target="_blank" rel="noopener noreferrer" className="hover:text-slate-600">
|
||||
Route Commerce
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { registerWholesaleCustomer } from "@/actions/wholesale-register";
|
||||
import { useRouter } from "next/navigation";
|
||||
import WholesaleBenefits from "@/components/wholesale/WholesaleBenefits";
|
||||
|
||||
const IRD_BRAND_ID = "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28";
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
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 [submitting, setSubmitting] = useState(false);
|
||||
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function checkEnabled(brandId: string) {
|
||||
const { supabase } = await import("@/lib/supabase");
|
||||
const { data: ws } = await supabase
|
||||
.from("wholesale_settings")
|
||||
.select("wholesale_enabled")
|
||||
.eq("brand_id", brandId)
|
||||
.single();
|
||||
if (ws && ws.wholesale_enabled === false) {
|
||||
setPortalDisabled(true);
|
||||
}
|
||||
setCheckingEnabled(false);
|
||||
}
|
||||
checkEnabled(form.brandId);
|
||||
}, [form.brandId]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setResult(null);
|
||||
const res = await registerWholesaleCustomer({
|
||||
brandId: form.brandId,
|
||||
companyName: form.companyName,
|
||||
contactName: form.contactName || undefined,
|
||||
email: form.email,
|
||||
phone: form.phone || undefined,
|
||||
});
|
||||
setSubmitting(false);
|
||||
if (res.success) {
|
||||
setResult({
|
||||
success: true,
|
||||
message: res.requiresApproval
|
||||
? "Application submitted — we'll review your account and notify you by email within 1–2 business days."
|
||||
: "Account created — you can now log in.",
|
||||
});
|
||||
} else {
|
||||
setResult({ success: false, message: res.error ?? "Registration failed." });
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950">
|
||||
{/* Header bar */}
|
||||
<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>
|
||||
<div>
|
||||
<p className="font-bold text-base text-zinc-100 leading-none">Apply for Wholesale</p>
|
||||
<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>
|
||||
</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">
|
||||
|
||||
{/* Benefits column */}
|
||||
<div className="lg:pt-4">
|
||||
<h2 className="text-3xl font-black text-zinc-100 tracking-tight mb-2">
|
||||
Ready to get wholesale pricing?
|
||||
</h2>
|
||||
<p className="text-zinc-500 mb-8 leading-relaxed">
|
||||
Apply in just a few minutes. We review applications within 1–2 business days.
|
||||
</p>
|
||||
<WholesaleBenefits />
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
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>
|
||||
<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 ${
|
||||
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.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"
|
||||
>
|
||||
<option value={TUXEDO_BRAND_ID}>Tuxedo Corn — Colorado Sweet Corn</option>
|
||||
<option value={IRD_BRAND_ID}>Indian River Direct — Florida Citrus</option>
|
||||
</select>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-5xl px-6 py-8 text-center">
|
||||
<p className="text-xs text-zinc-600">
|
||||
Powered by Route Commerce
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user