Fix auth login flow, Iran Yekan/Doran fonts, and account tiles.

Prevent stale /auth/me from wiping a fresh session, unblock login submit, allow localhost HMR, and drop the tile "مشاهده" CTA.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-26 11:10:37 +03:30
co-authored by Cursor
parent 27d5cd0186
commit 68365457a1
24 changed files with 1294 additions and 203 deletions
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import ClothesShell from "@/components/clothes/ClothesShell";
import { ApiError } from "@/lib/api";
import {
CustomerOrder,
fetchMyOrders,
} from "@/lib/customer-orders-api";
const STATUS_FA: Record<string, string> = {
pending: "در انتظار",
confirmed: "تأیید شده",
preparing: "در حال آماده‌سازی",
shipped: "ارسال شده",
delivered: "تحویل شده",
cancelled: "لغو شده",
};
function formatMoney(amount: number) {
return new Intl.NumberFormat("fa-IR").format(amount) + " تومان";
}
function formatDate(value: string) {
try {
return new Intl.DateTimeFormat("fa-IR", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(value));
} catch {
return value;
}
}
export default function CustomerOrdersPage() {
const [orders, setOrders] = useState<CustomerOrder[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
setOrders(await fetchMyOrders());
} catch (err) {
setError(
err instanceof ApiError
? err.message
: "بارگذاری سفارش‌ها ناموفق بود.",
);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
return (
<ClothesShell
eyebrow="Orders"
title="سفارش‌های من"
description="وضعیت و جزئیات خریدهای خود را اینجا ببینید."
breadcrumbs={[
{ label: "حساب کاربری", href: "/account" },
{ label: "سفارش‌ها" },
]}
>
{error ? (
<p className="mb-3 rounded-lg border border-line bg-surface px-4 py-3 text-sm text-red-600">
{error}
</p>
) : null}
{loading ? (
<p className="text-sm text-muted">در حال بارگذاری…</p>
) : orders.length === 0 ? (
<div className="rounded-lg border border-line bg-surface px-5 py-10 text-center dash-enter">
<p className="text-sm text-ink">هنوز سفارشی ثبت نشده است.</p>
<p className="mt-2 text-xs leading-6 text-muted">
بعد از خرید از فروشگاه مانا، سفارش‌ها اینجا نمایش داده می‌شوند.
</p>
</div>
) : (
<div className="space-y-2">
{orders.map((order, index) => (
<article
key={order.id}
className="customer-card dash-enter"
style={{ animationDelay: `${index * 40}ms` }}
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<p className="text-sm text-ink">
سفارش{" "}
<span dir="ltr" className="font-montserrat text-muted">
#{order.id.slice(-8)}
</span>
</p>
<span className="rounded-full border border-line px-2.5 py-0.5 text-[0.7rem] text-muted">
{STATUS_FA[order.status] ?? order.status}
</span>
</div>
<p className="mt-1 text-xs text-muted">
{formatDate(order.createdAt)} ·{" "}
{new Intl.NumberFormat("fa-IR").format(order.itemCount)} قلم
</p>
{order.items.length > 0 ? (
<p className="mt-2 truncate text-xs text-muted">
{order.items
.map((item) =>
item.variantLabel
? `${item.nameFa} (${item.variantLabel})`
: item.nameFa,
)
.join("، ")}
</p>
) : null}
</div>
<p className="shrink-0 text-sm text-ink">
{formatMoney(order.totalAmount)}
</p>
</article>
))}
</div>
)}
</ClothesShell>
);
}
+145
View File
@@ -0,0 +1,145 @@
"use client";
import DashboardBanner from "@/components/dashboard/DashboardBanner";
import DashboardHeader from "@/components/dashboard/DashboardHeader";
import DashTileGrid, {
DashTileItem,
} from "@/components/dashboard/DashTileGrid";
import { useAuth } from "@/lib/auth";
function canAccessAdmin(role: string | undefined) {
return role === "STORE_ADMIN" || role === "SUPER_ADMIN";
}
export default function CustomerHomePage() {
const { user } = useAuth();
const showAdmin = canAccessAdmin(user?.role);
const tiles: DashTileItem[] = [
{
href: "/account/orders",
title: "سفارش‌های من",
titleEn: "My Orders",
description: "پیگیری وضعیت و جزئیات خرید",
icon: <OrdersIcon />,
},
{
href: "/account/profile",
title: "پروفایل من",
titleEn: "My Profile",
description: "اطلاعات حساب و آدرس‌ها",
icon: <ProfileIcon />,
},
...(showAdmin
? [
{
href: "/",
title: "پنل مدیریت",
titleEn: "Admin",
description: "مدیریت فروشگاه و محصولات",
icon: <AdminIcon />,
} satisfies DashTileItem,
]
: []),
];
const bannerDelay = 80 + tiles.length * 70;
return (
<div className="mana-canvas relative flex min-h-dvh flex-col">
<DashboardHeader />
<main className="relative z-10 mx-auto flex w-full max-w-6xl flex-1 flex-col px-3 pt-10 pb-6 sm:px-5 sm:pt-12 sm:pb-8 lg:px-6">
<div className="mb-3 dash-enter" style={{ animationDelay: "0ms" }}>
<h1
dir="ltr"
className="flex flex-wrap items-baseline justify-end gap-x-2.5 gap-y-1 text-2xl font-medium text-ink sm:text-4xl"
>
<span className="font-display text-base tracking-[0.06em] text-muted sm:text-xl">
Account
</span>
<span aria-hidden className="text-sm text-muted/60 sm:text-base">
•
</span>
<span dir="rtl" className="font-doran">
حساب کاربری
</span>
</h1>
<p className="mt-3 max-w-xl text-sm leading-7 text-muted sm:text-base">
سفارش‌ها و اطلاعات حساب خود را از اینجا مدیریت کنید
{showAdmin ? "؛ در صورت دسترسی می‌توانید به پنل مدیریت بروید" : ""}
.
</p>
</div>
<DashTileGrid tiles={tiles} />
<DashboardBanner
src="/images/image4.jpg"
alt="نمای کالکشن مانا"
caption="Quiet elegance."
hoverTitle="خریدهای من"
hoverSubtitle="My Orders"
priority
style={{ animationDelay: `${bannerDelay}ms` }}
/>
</main>
</div>
);
}
function iconProps() {
return {
width: 56,
height: 56,
viewBox: "0 0 24 24",
fill: "none",
"aria-hidden": true as const,
};
}
function OrdersIcon() {
return (
<svg {...iconProps()}>
<path
d="M6 7h12l-.8 11.2a1 1 0 0 1-1 .8H7.8a1 1 0 0 1-1-.8L6 7Z"
stroke="currentColor"
strokeWidth="1.15"
strokeLinejoin="round"
/>
<path
d="M9 7a3 3 0 0 1 6 0"
stroke="currentColor"
strokeWidth="1.15"
strokeLinecap="round"
/>
</svg>
);
}
function ProfileIcon() {
return (
<svg {...iconProps()}>
<circle cx="12" cy="8" r="3" stroke="currentColor" strokeWidth="1.15" />
<path
d="M5 19c1.5-3.2 3.8-4.8 7-4.8s5.5 1.6 7 4.8"
stroke="currentColor"
strokeWidth="1.15"
strokeLinecap="round"
/>
</svg>
);
}
function AdminIcon() {
return (
<svg {...iconProps()}>
<path
d="M4.5 10.5 12 4.5l7.5 6V19a1 1 0 0 1-1 1h-4.5v-5h-5v5H5.5a1 1 0 0 1-1-1v-8.5Z"
stroke="currentColor"
strokeWidth="1.15"
strokeLinejoin="round"
/>
</svg>
);
}
+510
View File
@@ -0,0 +1,510 @@
"use client";
import { ChangeEvent, FormEvent, useEffect, useRef, useState } from "react";
import ClothesShell from "@/components/clothes/ClothesShell";
import ImageCropper from "@/components/ui/ImageCropper";
import { ApiError } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import {
createAddress,
deleteAddress,
fetchProfile,
updateAddress,
updateProfile,
type ProfileAddress,
} from "@/lib/profile-api";
type AddressDraft = {
id: string;
label: string;
province: string;
city: string;
fullAddress: string;
landline: string;
isNew?: boolean;
};
const DEFAULT_AVATAR = "/images/ryanwar-hanif-98XENwtPMCc-unsplash.jpg";
const provinces = [
"تهران",
"اصفهان",
"فارس",
"خراسان رضوی",
"آذربایجان شرقی",
"آذربایجان غربی",
"گیلان",
"مازندران",
"خوزستان",
"کرمان",
"یزد",
"قم",
"البرز",
"همدان",
"کردستان",
"کرمانشاه",
"هرمزگان",
"بوشهر",
"سیستان و بلوچستان",
"گلستان",
"قزوین",
"زنجان",
"اردبیل",
"لرستان",
"مرکزی",
"سمنان",
"چهارمحال و بختیاری",
"کهگیلویه و بویراحمد",
"ایلام",
"خراسان شمالی",
"خراسان جنوبی",
];
function emptyAddress(): AddressDraft {
return {
id: `local-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
label: "",
province: "",
city: "",
fullAddress: "",
landline: "",
isNew: true,
};
}
function toDraft(address: ProfileAddress): AddressDraft {
return {
id: address.id,
label: address.label ?? "",
province: address.province,
city: address.city,
fullAddress: address.fullAddress,
landline: address.landline ?? "",
};
}
export default function CustomerProfilePage() {
const { refreshMe } = useAuth();
const fileRef = useRef<HTMLInputElement>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [avatar, setAvatar] = useState(DEFAULT_AVATAR);
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [phone, setPhone] = useState("");
const [email, setEmail] = useState("");
const [instagram, setInstagram] = useState("");
const [about, setAbout] = useState("");
const [addresses, setAddresses] = useState<AddressDraft[]>([]);
const [removedIds, setRemovedIds] = useState<string[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const profile = await fetchProfile();
if (cancelled) return;
setFirstName(profile.firstName);
setLastName(profile.lastName);
setPhone(profile.cellNumber);
setEmail(profile.email ?? "");
setInstagram(profile.instagram ?? "");
setAbout(profile.about ?? "");
setAvatar(profile.avatarUrl || DEFAULT_AVATAR);
setAddresses(
profile.addresses.length
? profile.addresses.map(toDraft)
: [emptyAddress()],
);
} catch (err) {
if (!cancelled) {
setError(
err instanceof ApiError
? err.message
: "بارگذاری پروفایل ناموفق بود.",
);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
function handleAvatarChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setAvatar(URL.createObjectURL(file));
}
function updateAddressField(
id: string,
field: keyof Omit<AddressDraft, "id" | "isNew">,
value: string,
) {
setAddresses((prev) =>
prev.map((item) =>
item.id === id ? { ...item, [field]: value } : item,
),
);
}
function addAddress() {
setAddresses((prev) => [...prev, emptyAddress()]);
}
function removeAddress(id: string) {
setAddresses((prev) => {
const target = prev.find((item) => item.id === id);
if (!target) return prev;
if (!target.isNew) {
setRemovedIds((ids) => [...ids, id]);
}
const next = prev.filter((item) => item.id !== id);
return next.length ? next : [emptyAddress()];
});
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setSaving(true);
setError(null);
setSuccess(null);
try {
await updateProfile({
firstName: firstName.trim(),
lastName: lastName.trim(),
email: email.trim() || null,
instagram: instagram.trim() || null,
about: about.trim() || null,
});
for (const id of removedIds) {
await deleteAddress(id);
}
for (const address of addresses) {
const body = {
label: address.label.trim(),
province: address.province.trim(),
city: address.city.trim(),
fullAddress: address.fullAddress.trim(),
landline: address.landline.trim() || null,
};
if (!body.province || !body.city || !body.fullAddress) {
continue;
}
if (address.isNew) {
await createAddress(body);
} else {
await updateAddress(address.id, body);
}
}
const profile = await fetchProfile();
setAddresses(
profile.addresses.length
? profile.addresses.map(toDraft)
: [emptyAddress()],
);
setRemovedIds([]);
await refreshMe();
setSuccess("پروفایل با موفقیت ذخیره شد.");
} catch (err) {
setError(
err instanceof ApiError ? err.message : "ذخیره پروفایل ناموفق بود.",
);
} finally {
setSaving(false);
}
}
return (
<ClothesShell
eyebrow="Profile"
title="پروفایل"
description="اطلاعات شخصی، تصویر آواتار و آدرس‌های خود را مدیریت کنید."
breadcrumbs={[
{ label: "حساب کاربری", href: "/account" },
{ label: "پروفایل" },
]}
>
{loading ? (
<p className="text-sm text-muted">در حال بارگذاری…</p>
) : (
<form
onSubmit={(e) => void handleSubmit(e)}
className="grid grid-cols-1 gap-3 lg:grid-cols-[260px_1fr]"
>
<aside className="dash-enter rounded-lg border border-line bg-surface p-4">
<p className="mb-2 text-sm text-ink">آواتار</p>
<p className="mb-3 text-xs leading-6 text-muted">نسبت ۱:۱</p>
<ImageCropper
src={avatar}
aspect="1:1"
alt="آواتار پروفایل"
className="profile-avatar-cropper"
/>
<input
ref={fileRef}
type="file"
accept="image/*"
className="sr-only"
onChange={handleAvatarChange}
/>
<button
type="button"
className="mt-3 w-full border border-line bg-white px-3 py-2 text-sm text-ink transition-colors hover:border-ink"
onClick={() => fileRef.current?.click()}
>
انتخاب تصویر
</button>
<p className="mt-2 text-[0.65rem] leading-5 text-muted">
آپلود تصویر به‌زودی فعال می‌شود؛ فعلاً پیش‌نمایش محلی است.
</p>
</aside>
<div className="space-y-3 dash-enter" style={{ animationDelay: "70ms" }}>
{error ? (
<p className="rounded-lg border border-line bg-surface px-4 py-3 text-sm text-red-600">
{error}
</p>
) : null}
{success ? (
<p className="rounded-lg border border-line bg-surface px-4 py-3 text-sm text-ink">
{success}
</p>
) : null}
<section className="grid grid-cols-1 gap-3 rounded-lg border border-line bg-surface p-4 sm:grid-cols-12">
<div className="clothes-field sm:col-span-6">
<label htmlFor="profile-first">نام</label>
<input
id="profile-first"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="نام"
required
/>
</div>
<div className="clothes-field sm:col-span-6">
<label htmlFor="profile-last">نام خانوادگی</label>
<input
id="profile-last"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="نام خانوادگی"
required
/>
</div>
<div className="clothes-field sm:col-span-6">
<label htmlFor="profile-phone">شماره موبایل</label>
<input
id="profile-phone"
dir="ltr"
className="!text-end font-montserrat profile-input-locked"
value={phone}
readOnly
aria-readonly="true"
title="شماره موبایل قابل تغییر نیست"
/>
<span className="text-[0.65rem] text-muted">قابل تغییر نیست</span>
</div>
<div className="clothes-field sm:col-span-6">
<label htmlFor="profile-email">ایمیل</label>
<input
id="profile-email"
type="email"
dir="ltr"
className="!text-end font-montserrat"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
/>
</div>
<div className="clothes-field sm:col-span-12">
<label htmlFor="profile-instagram">اینستاگرام</label>
<input
id="profile-instagram"
dir="ltr"
className="!text-end font-montserrat"
value={instagram}
onChange={(e) => setInstagram(e.target.value)}
placeholder="@username"
/>
</div>
<div className="clothes-field sm:col-span-12">
<label htmlFor="profile-about">درباره من</label>
<textarea
id="profile-about"
value={about}
onChange={(e) => setAbout(e.target.value)}
placeholder="چند خط درباره خودتان..."
className="!min-h-[6rem]"
/>
</div>
</section>
<section className="rounded-lg border border-line bg-surface p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm text-ink">آدرس‌ها</p>
<p className="text-xs text-muted">
می‌توانید چند آدرس اضافه کنید.
</p>
</div>
<button
type="button"
onClick={addAddress}
className="border border-ink bg-ink px-3 py-1.5 text-xs text-white transition-colors hover:bg-white hover:text-ink"
>
+ آدرس جدید
</button>
</div>
<div className="space-y-3">
{addresses.map((address, index) => (
<div key={address.id} className="address-block">
<div className="mb-3 flex items-center justify-between gap-2">
<p className="text-sm text-ink">
آدرس {new Intl.NumberFormat("fa-IR").format(index + 1)}
</p>
<button
type="button"
className="text-xs text-muted transition-colors hover:text-ink disabled:opacity-30"
disabled={addresses.length <= 1}
onClick={() => removeAddress(address.id)}
>
حذف
</button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12">
<div className="clothes-field sm:col-span-4">
<label htmlFor={`addr-label-${address.id}`}>برچسب</label>
<input
id={`addr-label-${address.id}`}
value={address.label}
onChange={(e) =>
updateAddressField(
address.id,
"label",
e.target.value,
)
}
placeholder="منزل، محل کار..."
/>
</div>
<div className="clothes-field sm:col-span-4">
<label htmlFor={`addr-province-${address.id}`}>
استان
</label>
<select
id={`addr-province-${address.id}`}
value={address.province}
onChange={(e) =>
updateAddressField(
address.id,
"province",
e.target.value,
)
}
>
<option value="" disabled>
انتخاب استان
</option>
{provinces.map((province) => (
<option key={province} value={province}>
{province}
</option>
))}
</select>
</div>
<div className="clothes-field sm:col-span-4">
<label htmlFor={`addr-city-${address.id}`}>شهر</label>
<input
id={`addr-city-${address.id}`}
value={address.city}
onChange={(e) =>
updateAddressField(
address.id,
"city",
e.target.value,
)
}
placeholder="نام شهر"
/>
</div>
<div className="clothes-field sm:col-span-8">
<label htmlFor={`addr-full-${address.id}`}>
آدرس کامل
</label>
<textarea
id={`addr-full-${address.id}`}
value={address.fullAddress}
onChange={(e) =>
updateAddressField(
address.id,
"fullAddress",
e.target.value,
)
}
placeholder="خیابان، کوچه، پلاک، واحد..."
className="!min-h-[4.5rem]"
/>
</div>
<div className="clothes-field sm:col-span-4">
<label htmlFor={`addr-landline-${address.id}`}>
تلفن ثابت
</label>
<input
id={`addr-landline-${address.id}`}
dir="ltr"
className="!text-end font-montserrat"
inputMode="tel"
value={address.landline}
onChange={(e) =>
updateAddressField(
address.id,
"landline",
e.target.value,
)
}
placeholder="021..."
/>
</div>
</div>
</div>
))}
</div>
</section>
<div className="flex justify-end gap-2">
<button
type="button"
className="border border-line px-5 py-2.5 text-sm text-muted hover:text-ink"
onClick={() => window.location.reload()}
disabled={saving}
>
انصراف
</button>
<button
type="submit"
className="auth-button !mt-0 !max-w-none !w-auto px-6"
disabled={saving}
>
<span>{saving ? "در حال ذخیره…" : "ذخیره پروفایل"}</span>
</button>
</div>
</div>
</form>
)}
</ClothesShell>
);
}
+5 -1
View File
@@ -17,13 +17,17 @@
--color-muted: var(--muted);
--color-line: var(--line);
--color-ink: var(--ink);
--font-sans: var(--font-doran);
--font-sans: var(--font-iranyekan);
--font-display: var(--font-cailyne);
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-iranyekan), Tahoma, sans-serif;
}
.font-doran {
font-family: var(--font-doran), Tahoma, sans-serif;
}
+45 -3
View File
@@ -2,9 +2,51 @@ import type { Metadata } from "next";
import { Montserrat } from "next/font/google";
import localFont from "next/font/local";
import Providers from "@/components/Providers";
import RequireStoreAuth from "@/components/RequireStoreAuth";
import RequireAuth from "@/components/RequireAuth";
import "./globals.css";
const iranYekan = localFont({
src: [
{
path: "../fonts/iranyekan/IRANYekanThinFaNum.ttf",
weight: "100",
style: "normal",
},
{
path: "../fonts/iranyekan/IRANYekanLightFaNum.ttf",
weight: "300",
style: "normal",
},
{
path: "../fonts/iranyekan/IRANYekanRegularFaNum.ttf",
weight: "400",
style: "normal",
},
{
path: "../fonts/iranyekan/IRANYekanMediumFaNum.ttf",
weight: "500",
style: "normal",
},
{
path: "../fonts/iranyekan/IRANYekanBoldFaNum.ttf",
weight: "700",
style: "normal",
},
{
path: "../fonts/iranyekan/IRANYekanExtraBoldFaNum.ttf",
weight: "800",
style: "normal",
},
{
path: "../fonts/iranyekan/IRANYekanBlackFaNum.ttf",
weight: "900",
style: "normal",
},
],
variable: "--font-iranyekan",
display: "swap",
});
const doran = localFont({
src: [
{
@@ -65,11 +107,11 @@ export default function RootLayout({
<html
lang="fa"
dir="rtl"
className={`${doran.variable} ${cailyne.variable} ${montserrat.variable} h-full antialiased`}
className={`${iranYekan.variable} ${doran.variable} ${cailyne.variable} ${montserrat.variable} h-full antialiased`}
>
<body className="min-h-full font-sans text-foreground">
<Providers>
<RequireStoreAuth>{children}</RequireStoreAuth>
<RequireAuth>{children}</RequireAuth>
</Providers>
</body>
</html>
+3 -1
View File
@@ -62,7 +62,9 @@ export default function HomePage() {
<span aria-hidden className="text-sm text-muted/60 sm:text-base">
•
</span>
<span dir="rtl">داشبورد مدیریت</span>
<span dir="rtl" className="font-doran">
داشبورد مدیریت
</span>
</h1>
<p className="mt-3 max-w-xl text-sm leading-7 text-muted sm:text-base">
از اینجا لباس‌ها، مشتریان، بلاگ، سفارش‌ها و پروفایل فروشگاه را مدیریت کنید.