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:
Alireza Hassani
2026-08-25 16:41:00 +03:30
co-authored by Cursor
parent c2552af079
commit c53fb99330
30 changed files with 2492 additions and 32 deletions
+311
View File
@@ -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>
);
}