Add shared customer auth, cart handoff, and account dropdown.
Mirror Meshkee customer sessions onto the storefront, continue checkout on the customer dashboard, and show profile/orders/admin links under the header name. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
c2552af079
commit
c53fb99330
@@ -0,0 +1,311 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { formatPrice } from "@/data/sample";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { useCart } from "@/lib/cart";
|
||||
import { customerLoginUrl } from "@/lib/customer-auth";
|
||||
|
||||
type Address = {
|
||||
id: string;
|
||||
label?: string;
|
||||
province: string;
|
||||
city: string;
|
||||
address: string;
|
||||
postalCode?: string;
|
||||
landline?: string;
|
||||
};
|
||||
|
||||
type PaymentMethods = {
|
||||
cash?: boolean;
|
||||
transfer?: boolean;
|
||||
pos?: boolean;
|
||||
ePayment?: {
|
||||
enabled?: boolean;
|
||||
defaultGateway?: string;
|
||||
gateways?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export function CheckoutPage() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, ready: authReady, getAccessToken } = useAuth();
|
||||
const { items, total, ready: cartReady } = useCart();
|
||||
|
||||
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||
const [methods, setMethods] = useState<PaymentMethods | null>(null);
|
||||
const [addressId, setAddressId] = useState("");
|
||||
const [paymentType, setPaymentType] = useState<"cash" | "transfer" | "e_payment_gate">("cash");
|
||||
const [gatewayType, setGatewayType] = useState("zarinpal");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [shipping, setShipping] = useState({
|
||||
province: "",
|
||||
city: "",
|
||||
address: "",
|
||||
postalCode: "",
|
||||
landline: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!authReady) return;
|
||||
if (!isAuthenticated) {
|
||||
window.location.href = customerLoginUrl("/checkout");
|
||||
}
|
||||
}, [authReady, isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getAccessToken();
|
||||
if (!token) return;
|
||||
|
||||
Promise.all([
|
||||
fetch("/api/addresses", { headers: { Authorization: `Bearer ${token}` } }).then((r) =>
|
||||
r.ok ? r.json() : { items: [] },
|
||||
),
|
||||
fetch("/api/payments/methods", { headers: { Authorization: `Bearer ${token}` } }).then((r) =>
|
||||
r.ok ? r.json() : null,
|
||||
),
|
||||
]).then(([addressRes, methodsRes]) => {
|
||||
const nextAddresses = (addressRes.items ?? []) as Address[];
|
||||
setAddresses(nextAddresses);
|
||||
if (nextAddresses[0]) setAddressId(nextAddresses[0].id);
|
||||
|
||||
const nextMethods = (methodsRes ?? methodsRes?.methods ?? methodsRes) as PaymentMethods;
|
||||
setMethods(nextMethods);
|
||||
if (nextMethods?.ePayment?.enabled) {
|
||||
setPaymentType("e_payment_gate");
|
||||
setGatewayType(nextMethods.ePayment.defaultGateway ?? nextMethods.ePayment.gateways?.[0] ?? "zarinpal");
|
||||
} else if (nextMethods?.cash) {
|
||||
setPaymentType("cash");
|
||||
} else if (nextMethods?.transfer) {
|
||||
setPaymentType("transfer");
|
||||
}
|
||||
});
|
||||
}, [getAccessToken, isAuthenticated]);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const token = getAccessToken();
|
||||
if (!token) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
payment: { type: paymentType },
|
||||
};
|
||||
|
||||
if (addressId) {
|
||||
payload.addressId = addressId;
|
||||
} else {
|
||||
payload.shippingAddress = shipping;
|
||||
}
|
||||
|
||||
if (paymentType === "e_payment_gate") {
|
||||
payload.payment = { type: paymentType, gatewayType };
|
||||
payload.returnUrl = `${window.location.origin}/checkout/complete`;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/cart/checkout", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "ثبت سفارش ناموفق بود");
|
||||
|
||||
const redirect = data.payment?.redirect;
|
||||
if (redirect?.url) {
|
||||
if (redirect.method === "POST" && redirect.fields) {
|
||||
const form = document.createElement("form");
|
||||
form.method = "POST";
|
||||
form.action = redirect.url;
|
||||
for (const [key, value] of Object.entries(redirect.fields as Record<string, string>)) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = key;
|
||||
input.value = value;
|
||||
form.appendChild(input);
|
||||
}
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
return;
|
||||
}
|
||||
window.location.href = redirect.url;
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/checkout/complete?orderId=${data.order?.id ?? ""}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "خطا در ثبت سفارش");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!authReady || !cartReady || !isAuthenticated) {
|
||||
return <div className="py-16 text-center text-sm text-muted">در حال بارگذاری...</div>;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-surface p-10 text-center">
|
||||
<p className="mb-4 text-muted">سبد خرید شما خالی است</p>
|
||||
<Link href="/products" className="text-sm font-medium text-red hover:text-red-dark">
|
||||
بازگشت به فروشگاه
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="grid gap-8 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<section className="rounded-2xl border border-border bg-surface p-5">
|
||||
<h2 className="mb-4 text-lg font-bold">آدرس تحویل</h2>
|
||||
{addresses.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{addresses.map((address) => (
|
||||
<label
|
||||
key={address.id}
|
||||
className={`flex cursor-pointer gap-3 rounded-xl border p-3 ${
|
||||
addressId === address.id ? "border-red bg-red-light/30" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="address"
|
||||
checked={addressId === address.id}
|
||||
onChange={() => setAddressId(address.id)}
|
||||
/>
|
||||
<span className="text-sm leading-relaxed">
|
||||
{address.label ? `${address.label} — ` : ""}
|
||||
{address.province}، {address.city}، {address.address}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<input
|
||||
required
|
||||
placeholder="استان"
|
||||
value={shipping.province}
|
||||
onChange={(e) => setShipping((s) => ({ ...s, province: e.target.value }))}
|
||||
className="rounded-xl border border-border px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
required
|
||||
placeholder="شهر"
|
||||
value={shipping.city}
|
||||
onChange={(e) => setShipping((s) => ({ ...s, city: e.target.value }))}
|
||||
className="rounded-xl border border-border px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
required
|
||||
placeholder="آدرس"
|
||||
value={shipping.address}
|
||||
onChange={(e) => setShipping((s) => ({ ...s, address: e.target.value }))}
|
||||
className="rounded-xl border border-border px-3 py-2 text-sm sm:col-span-2"
|
||||
/>
|
||||
<input
|
||||
placeholder="کد پستی"
|
||||
value={shipping.postalCode}
|
||||
onChange={(e) => setShipping((s) => ({ ...s, postalCode: e.target.value }))}
|
||||
className="rounded-xl border border-border px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
placeholder="تلفن ثابت"
|
||||
value={shipping.landline}
|
||||
onChange={(e) => setShipping((s) => ({ ...s, landline: e.target.value }))}
|
||||
className="rounded-xl border border-border px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-border bg-surface p-5">
|
||||
<h2 className="mb-4 text-lg font-bold">روش پرداخت</h2>
|
||||
<div className="space-y-2">
|
||||
{methods?.cash !== false && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="payment"
|
||||
checked={paymentType === "cash"}
|
||||
onChange={() => setPaymentType("cash")}
|
||||
/>
|
||||
پرداخت در محل / نقدی
|
||||
</label>
|
||||
)}
|
||||
{methods?.transfer && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="payment"
|
||||
checked={paymentType === "transfer"}
|
||||
onChange={() => setPaymentType("transfer")}
|
||||
/>
|
||||
کارت به کارت / انتقال
|
||||
</label>
|
||||
)}
|
||||
{methods?.ePayment?.enabled && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="payment"
|
||||
checked={paymentType === "e_payment_gate"}
|
||||
onChange={() => setPaymentType("e_payment_gate")}
|
||||
/>
|
||||
پرداخت آنلاین
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{paymentType === "e_payment_gate" && methods?.ePayment?.gateways?.length ? (
|
||||
<select
|
||||
value={gatewayType}
|
||||
onChange={(e) => setGatewayType(e.target.value)}
|
||||
className="mt-3 w-full rounded-xl border border-border px-3 py-2 text-sm"
|
||||
>
|
||||
{methods.ePayment.gateways.map((gateway) => (
|
||||
<option key={gateway} value={gateway}>
|
||||
{gateway}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className="h-fit rounded-2xl border border-border bg-surface p-5">
|
||||
<h2 className="mb-4 text-lg font-bold">خلاصه سفارش</h2>
|
||||
<div className="mb-4 space-y-2 text-sm">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="flex justify-between gap-3">
|
||||
<span className="line-clamp-1 text-muted">{item.name}</span>
|
||||
<span>× {new Intl.NumberFormat("fa-IR").format(item.quantity)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mb-4 flex items-center justify-between border-t border-border pt-4">
|
||||
<span className="text-muted">جمع کل</span>
|
||||
<span className="text-lg font-bold text-red-dark">{formatPrice(total)} تومان</span>
|
||||
</div>
|
||||
{error && <p className="mb-3 text-sm text-red">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-xl bg-red py-3 text-sm font-medium text-white transition-colors hover:bg-red-dark disabled:cursor-not-allowed disabled:bg-red/60"
|
||||
>
|
||||
{loading ? "در حال ثبت سفارش..." : "ثبت سفارش"}
|
||||
</button>
|
||||
</aside>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user