Initial commit - Route Commerce platform
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { type WholesaleOrder, recordWholesaleDeposit } from "@/actions/wholesale";
|
||||
|
||||
type Props = {
|
||||
order: WholesaleOrder;
|
||||
onClose: () => void;
|
||||
onFulfilled: () => void;
|
||||
};
|
||||
|
||||
export default function DepositModal({ order, onClose, onFulfilled }: Props) {
|
||||
const maxDeposit = Number(order.balance_due);
|
||||
const [amount, setAmount] = useState(
|
||||
maxDeposit > 0 ? maxDeposit.toFixed(2) : ""
|
||||
);
|
||||
const [method, setMethod] = useState("cash");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
async function handleConfirm() {
|
||||
const parsed = Number(amount);
|
||||
if (!amount || isNaN(parsed) || parsed <= 0) {
|
||||
setMsg({ kind: "error", text: "Please enter a valid amount." });
|
||||
return;
|
||||
}
|
||||
if (parsed > maxDeposit) {
|
||||
setMsg({ kind: "error", text: `Deposit cannot exceed the remaining balance of $${maxDeposit.toFixed(2)}.` });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
const result = await recordWholesaleDeposit(order.id, parsed, method);
|
||||
setSaving(false);
|
||||
if (result.success) {
|
||||
onFulfilled();
|
||||
onClose();
|
||||
} else {
|
||||
setMsg({ kind: "error", text: result.error ?? "Failed to record deposit." });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 px-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl p-6 w-96 shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Record Deposit</h3>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
Balance due: <span className="font-semibold text-slate-700">${Number(order.balance_due).toFixed(2)}</span>
|
||||
</p>
|
||||
{msg && (
|
||||
<div className={`mb-3 rounded-lg border px-3 py-2 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>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Amount ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
step="0.01"
|
||||
min="0"
|
||||
max={maxDeposit}
|
||||
className="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Method</label>
|
||||
<select
|
||||
value={method}
|
||||
onChange={e => setMethod(e.target.value)}
|
||||
className="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
>
|
||||
<option value="cash">Cash</option>
|
||||
<option value="check">Check</option>
|
||||
<option value="wire">Wire</option>
|
||||
<option value="card">Card</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={saving || !amount || Number(amount) > maxDeposit}
|
||||
className="rounded-xl bg-green-600 px-4 py-2 text-sm font-semibold text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Record Deposit"}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { type WholesaleOrder } from "@/actions/wholesale";
|
||||
|
||||
type Props = {
|
||||
order: WholesaleOrder;
|
||||
onClose: () => void;
|
||||
onFulfill: (orderId: string) => void;
|
||||
onRecordDeposit: (orderId: string) => void;
|
||||
fulfilling: string | null;
|
||||
};
|
||||
|
||||
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 OrderDetailsModal({ order, onClose, onFulfill, onRecordDeposit, fulfilling }: Props) {
|
||||
const hasPhone = Boolean(order.customer_phone);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[85vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-white px-6 py-4 border-b border-slate-200 flex items-center justify-between rounded-t-2xl">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-900">Order Details</h2>
|
||||
<p className="text-sm text-slate-500 font-mono">{order.invoice_number ?? order.id.slice(0, 8)}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 space-y-5">
|
||||
{/* Customer + pickup meta */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-0.5">Customer</p>
|
||||
<p className="font-semibold text-slate-900">{order.company_name}</p>
|
||||
<p className="text-sm text-slate-500">{order.contact_name ?? ""}</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
<a href={`mailto:${order.customer_email}`} className="hover:underline">{order.customer_email}</a>
|
||||
</p>
|
||||
{hasPhone && (
|
||||
<a href={`tel:${order.customer_phone}`} className="text-sm text-blue-600 hover:underline">
|
||||
{order.customer_phone}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-0.5">Pickup Date</p>
|
||||
<p className="font-medium text-slate-700">{order.anticipated_pickup_date ?? "—"}</p>
|
||||
<p className="text-xs text-slate-500 mt-2 mb-0.5">Status</p>
|
||||
<StatusBadge status={order.status} />
|
||||
<p className="text-xs text-slate-500 mt-2 mb-0.5">Payment</p>
|
||||
<span className={`text-sm font-medium ${order.payment_status === "paid" ? "text-green-600" : "text-orange-600"}`}>
|
||||
{order.payment_status === "paid" ? "Paid" : `$${Number(order.balance_due).toFixed(2)} balance due`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line items */}
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-2 uppercase tracking-wide font-semibold">Items</p>
|
||||
<div className="rounded-xl border border-slate-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5 text-left font-semibold">Product</th>
|
||||
<th className="px-4 py-2.5 text-right font-semibold">Qty</th>
|
||||
<th className="px-4 py-2.5 text-right font-semibold">Unit Price</th>
|
||||
<th className="px-4 py-2.5 text-right font-semibold">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{order.items && order.items.length > 0 ? order.items.map((item, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-4 py-2.5 font-medium text-slate-700">{item.product_name}</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-600">{item.quantity}</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-600">${Number(item.unit_price).toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5 text-right font-semibold text-slate-900">${Number(item.line_total).toFixed(2)}</td>
|
||||
</tr>
|
||||
)) : (
|
||||
<tr><td colSpan={4} className="px-4 py-4 text-center text-slate-400">No items</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
<tfoot className="bg-slate-50 border-t border-slate-200">
|
||||
<tr>
|
||||
<td colSpan={3} className="px-4 py-2.5 text-right font-semibold text-slate-700">Subtotal</td>
|
||||
<td className="px-4 py-2.5 text-right font-bold text-slate-900">${Number(order.subtotal).toFixed(2)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={3} className="px-4 py-2.5 text-right text-slate-600">Deposit Paid</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-700">${Number(order.deposit_paid).toFixed(2)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={3} className="px-4 py-2.5 text-right font-semibold text-slate-700">Balance Due</td>
|
||||
<td className="px-4 py-2.5 text-right font-bold text-orange-600">${Number(order.balance_due).toFixed(2)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Created</p>
|
||||
<p className="text-slate-700">{new Date(order.created_at).toLocaleString()}</p>
|
||||
</div>
|
||||
{order.fulfilled_at && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Fulfilled</p>
|
||||
<p className="text-slate-700">{new Date(order.fulfilled_at).toLocaleString()}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
{order.fulfillment_status !== "fulfilled" && (
|
||||
<button
|
||||
onClick={() => onFulfill(order.id)}
|
||||
disabled={fulfilling === order.id}
|
||||
className="rounded-xl bg-green-600 px-4 py-2 text-sm font-semibold text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{fulfilling === order.id ? "..." : "Mark Fulfilled"}
|
||||
</button>
|
||||
)}
|
||||
{Number(order.balance_due) > 0 && order.fulfillment_status !== "fulfilled" && (
|
||||
<button
|
||||
onClick={() => onRecordDeposit(order.id)}
|
||||
className="rounded-xl bg-purple-600 px-4 py-2 text-sm font-semibold text-white hover:bg-purple-700"
|
||||
>
|
||||
Record Deposit
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href={`/api/wholesale/invoice/${order.id}/pdf`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl bg-slate-800 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700"
|
||||
>
|
||||
Download Invoice
|
||||
</a>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-auto rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type React from "react";
|
||||
|
||||
type WholesaleBenefit = {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const CHECK_ICON = (
|
||||
<svg className="h-5 w-5 text-emerald-500" 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>
|
||||
);
|
||||
|
||||
const WHOLESALE_BENEFITS: WholesaleBenefit[] = [
|
||||
{
|
||||
icon: CHECK_ICON,
|
||||
title: "Exclusive Wholesale Pricing",
|
||||
description: "Access discounted rates and volume-based tiers that aren't available to retail customers.",
|
||||
},
|
||||
{
|
||||
icon: CHECK_ICON,
|
||||
title: "Easy Online Ordering",
|
||||
description: "Place orders anytime with the option to pay a deposit and settle the balance at pickup.",
|
||||
},
|
||||
{
|
||||
icon: CHECK_ICON,
|
||||
title: "Order History & Invoices",
|
||||
description: "Log in anytime to review past orders, download invoices, and track your spending.",
|
||||
},
|
||||
{
|
||||
icon: CHECK_ICON,
|
||||
title: "Priority Pickup Scheduling",
|
||||
description: "Reserve your spot at upcoming stops and ensure your order is ready when you arrive.",
|
||||
},
|
||||
{
|
||||
icon: CHECK_ICON,
|
||||
title: "Dedicated Bulk Support",
|
||||
description: "Get personalized help from our wholesale team for large or recurring orders.",
|
||||
},
|
||||
{
|
||||
icon: CHECK_ICON,
|
||||
title: "Early Access to Seasonal Products",
|
||||
description: "Be the first to know when new crops launch and reserve before they sell out.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function WholesaleBenefits() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-zinc-500">What You'll Get</p>
|
||||
<ul className="space-y-4">
|
||||
{WHOLESALE_BENEFITS.map((b) => (
|
||||
<li key={b.title} className="flex gap-3">
|
||||
<span className="shrink-0 mt-0.5">{b.icon}</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-zinc-300">{b.title}</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5 leading-relaxed">{b.description}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user