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
+2
View File
@@ -2,6 +2,8 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
allowedDevOrigins: [
"127.0.0.1",
"localhost",
"mana-collection.com",
"*.mana-collection.com",
"store.mana-collection.com",
+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">
از اینجا لباس‌ها، مشتریان، بلاگ، سفارش‌ها و پروفایل فروشگاه را مدیریت کنید.
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
const PUBLIC_PATHS = ["/login"];
const CUSTOMER_HOME = "/account";
function isPublicPath(pathname: string) {
return PUBLIC_PATHS.some(
(p) => pathname === p || pathname.startsWith(`${p}/`),
);
}
function isCustomerPath(pathname: string) {
return (
pathname === CUSTOMER_HOME || pathname.startsWith(`${CUSTOMER_HOME}/`)
);
}
function canAccessAdmin(role: string | undefined) {
return role === "STORE_ADMIN" || role === "SUPER_ADMIN";
}
export default function RequireAuth({
children,
}: {
children: React.ReactNode;
}) {
const { user, loading } = useAuth();
const pathname = usePathname();
const router = useRouter();
const isPublic = isPublicPath(pathname);
const onCustomerArea = isCustomerPath(pathname);
const onAdminArea = !isPublic && !onCustomerArea;
useEffect(() => {
if (loading) return;
// Already signed in — leave the login page.
if (isPublic) {
if (user) router.replace(CUSTOMER_HOME);
return;
}
if (!user) {
router.replace("/login");
return;
}
// Everyone starts in customer area; block customers from admin routes.
if (onAdminArea && !canAccessAdmin(user.role)) {
router.replace(CUSTOMER_HOME);
}
}, [user, loading, isPublic, onAdminArea, router]);
// Login must never get stuck behind a hung /auth/me request.
if (isPublic) {
if (user && !loading) return null;
return <>{children}</>;
}
if (loading && !user) {
return (
<div className="flex min-h-dvh items-center justify-center text-sm text-muted">
در حال بارگذاری…
</div>
);
}
if (!user) return null;
if (onAdminArea && !canAccessAdmin(user.role)) return null;
return <>{children}</>;
}
-43
View File
@@ -1,43 +0,0 @@
"use client";
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
const PUBLIC_PATHS = ["/login"];
export default function RequireStoreAuth({
children,
}: {
children: React.ReactNode;
}) {
const { user, loading } = useAuth();
const pathname = usePathname();
const router = useRouter();
const isPublic = PUBLIC_PATHS.some(
(p) => pathname === p || pathname.startsWith(`${p}/`),
);
useEffect(() => {
if (loading || isPublic) return;
if (!user) {
router.replace("/login");
return;
}
if (user.role === "CUSTOMER") {
router.replace("/login?as=customer");
}
}, [user, loading, isPublic, router]);
if (isPublic) return <>{children}</>;
if (loading) {
return (
<div className="flex min-h-dvh items-center justify-center text-sm text-muted">
در حال بارگذاری…
</div>
);
}
if (!user || user.role === "CUSTOMER") return null;
return <>{children}</>;
}
+87 -86
View File
@@ -1,12 +1,11 @@
"use client";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import {
FormEvent,
InputHTMLAttributes,
ReactNode,
Suspense,
useState,
} from "react";
import ManaLogo from "@/components/ui/ManaLogo";
@@ -18,7 +17,6 @@ type LoginMethod = "password" | "otp";
function AuthFormInner() {
const router = useRouter();
const searchParams = useSearchParams();
const {
login,
sendLoginOtp,
@@ -35,24 +33,28 @@ function AuthFormInner() {
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const isLogin = mode === "login";
const customerHint = searchParams.get("as") === "customer";
function afterAuth(role: string) {
if (role === "CUSTOMER") {
setError(
"این پنل مخصوص فروشندگان است. مشتریان از وب‌سایت مانا کالکشن وارد شوند.",
);
return;
}
router.replace("/");
function afterAuth(_role: string) {
router.replace("/account");
}
async function handleLoginPassword(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
async function handleLoginPassword(event?: FormEvent<HTMLFormElement>) {
event?.preventDefault();
event?.stopPropagation();
setError(null);
setBusy(true);
const form = new FormData(event.currentTarget);
const cellNumber = String(form.get("cellNumber") ?? "").trim();
const formEl =
event?.currentTarget ??
(document.getElementById("auth-login-form") as HTMLFormElement | null);
if (!formEl) {
setError("فرم ورود پیدا نشد");
setBusy(false);
return;
}
const form = new FormData(formEl);
const cellNumber = String(form.get("cellNumber") ?? "")
.trim()
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)));
const password = String(form.get("password") ?? "");
try {
const user = await login(cellNumber, password);
@@ -64,12 +66,23 @@ function AuthFormInner() {
}
}
async function handleLoginOtp(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
async function handleLoginOtp(event?: FormEvent<HTMLFormElement>) {
event?.preventDefault();
event?.stopPropagation();
setError(null);
setBusy(true);
const form = new FormData(event.currentTarget);
const cellNumber = String(form.get("cellNumber") ?? "").trim();
const formEl =
event?.currentTarget ??
(document.getElementById("auth-login-form") as HTMLFormElement | null);
if (!formEl) {
setError("فرم ورود پیدا نشد");
setBusy(false);
return;
}
const form = new FormData(formEl);
const cellNumber = String(form.get("cellNumber") ?? "")
.trim()
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)));
const code = String(form.get("code") ?? "").trim();
try {
if (!otpSent) {
@@ -87,14 +100,25 @@ function AuthFormInner() {
}
}
async function handleRegister(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
async function handleRegister(event?: FormEvent<HTMLFormElement>) {
event?.preventDefault();
event?.stopPropagation();
setError(null);
setBusy(true);
const form = new FormData(event.currentTarget);
const formEl =
event?.currentTarget ??
(document.getElementById("auth-register-form") as HTMLFormElement | null);
if (!formEl) {
setError("فرم ثبت‌نام پیدا نشد");
setBusy(false);
return;
}
const form = new FormData(formEl);
const firstName = String(form.get("firstName") ?? "").trim();
const lastName = String(form.get("lastName") ?? "").trim();
const cellNumber = String(form.get("cellNumber") ?? "").trim();
const cellNumber = String(form.get("cellNumber") ?? "")
.trim()
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)));
const password = String(form.get("password") ?? "");
const confirm = String(form.get("confirm") ?? "");
const code = String(form.get("code") ?? "").trim();
@@ -176,29 +200,16 @@ function AuthFormInner() {
</button>
</div>
{customerHint ? (
<p className="mb-4 text-xs leading-6 text-muted">
حساب شما مشتری است. برای مدیریت فروشگاه باید با حساب فروشنده وارد
شوید.
</p>
) : null}
{error ? (
<p className="mb-4 text-xs leading-6 text-red-600">{error}</p>
) : null}
<div className="grid min-h-0 flex-1">
<div
className={`col-start-1 row-start-1 flex items-center transition-opacity duration-300 ${
isLogin
? "relative z-10 opacity-100"
: "pointer-events-none invisible z-0 opacity-0"
}`}
aria-hidden={!isLogin}
>
<div className="flex min-h-0 flex-1 items-center">
{isLogin ? (
<AuthPanel
title="ورود به وبسایت"
subtitle="با شماره موبایل و رمز عبور یا کد یکبارمصرف وارد پنل فروشگاه شوید."
formId="auth-login-form"
title="ورود به حساب"
subtitle="با شماره موبایل و رمز عبور یا کد یکبارمصرف وارد شوید."
onSubmit={
loginMethod === "password"
? handleLoginPassword
@@ -215,7 +226,7 @@ function AuthFormInner() {
placeholder="09123456789"
dir="ltr"
pattern="09[0-9]{9}"
required={isLogin}
required
defaultValue={pendingCell || undefined}
/>
{loginMethod === "password" ? (
@@ -227,7 +238,7 @@ function AuthFormInner() {
autoComplete="off"
placeholder="••••••••"
dir="ltr"
required={isLogin}
required
minLength={4}
/>
) : otpSent ? (
@@ -246,10 +257,14 @@ function AuthFormInner() {
/>
) : null}
<button
type="submit"
type="button"
className="auth-button"
disabled={busy}
tabIndex={isLogin ? 0 : -1}
onClick={() => {
void (loginMethod === "password"
? handleLoginPassword()
: handleLoginOtp());
}}
>
<span>
{busy
@@ -298,17 +313,9 @@ function AuthFormInner() {
</button>
</div>
</AuthPanel>
</div>
<div
className={`col-start-1 row-start-1 flex items-center transition-opacity duration-300 ${
!isLogin
? "relative z-10 opacity-100"
: "pointer-events-none invisible z-0 opacity-0"
}`}
aria-hidden={isLogin}
>
) : (
<AuthPanel
formId="auth-register-form"
title="عضویت در مانا کالکشن"
subtitle="با تکمیل اطلاعات زیر، ما یک حساب کاربری برای شما ایجاد میکنیم."
onSubmit={handleRegister}
@@ -322,7 +329,7 @@ function AuthFormInner() {
name="firstName"
type="text"
autoComplete="off"
required={!isLogin}
required
/>
<Field
id="register-last"
@@ -330,7 +337,7 @@ function AuthFormInner() {
name="lastName"
type="text"
autoComplete="off"
required={!isLogin}
required
/>
</div>
<Field
@@ -343,7 +350,7 @@ function AuthFormInner() {
placeholder="09123456789"
dir="ltr"
pattern="09[0-9]{9}"
required={!isLogin}
required
/>
<Field
id="register-password"
@@ -353,7 +360,7 @@ function AuthFormInner() {
autoComplete="new-password"
placeholder="••••••••"
dir="ltr"
required={!isLogin}
required
minLength={4}
/>
<Field
@@ -364,7 +371,7 @@ function AuthFormInner() {
autoComplete="new-password"
placeholder="••••••••"
dir="ltr"
required={!isLogin}
required
minLength={4}
/>
</>
@@ -395,10 +402,12 @@ function AuthFormInner() {
</>
)}
<button
type="submit"
type="button"
className="auth-button"
disabled={busy}
tabIndex={!isLogin ? 0 : -1}
onClick={() => {
void handleRegister();
}}
>
<span>
{busy
@@ -409,7 +418,7 @@ function AuthFormInner() {
</span>
</button>
</AuthPanel>
</div>
)}
</div>
<p className="mt-6 shrink-0 text-xs leading-6 text-muted">
@@ -450,36 +459,36 @@ function AuthFormInner() {
}
export default function AuthForm() {
return (
<Suspense
fallback={
<div className="flex min-h-dvh items-center justify-center text-sm text-muted">
در حال بارگذاری…
</div>
}
>
<AuthFormInner />
</Suspense>
);
return <AuthFormInner />;
}
function AuthPanel({
formId,
title,
subtitle,
onSubmit,
children,
}: {
formId: string;
title: string;
subtitle: string;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onSubmit: (event?: FormEvent<HTMLFormElement>) => void;
children: ReactNode;
}) {
return (
<div className="w-full">
<h1 className="mb-2 text-xl font-medium text-ink sm:text-2xl">{title}</h1>
<h1 className="font-doran mb-2 text-xl font-medium text-ink sm:text-2xl">
{title}
</h1>
<p className="mb-8 max-w-sm text-sm leading-7 text-muted">{subtitle}</p>
<form
onSubmit={onSubmit}
id={formId}
noValidate
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
onSubmit(event);
}}
className="flex w-full flex-col gap-4"
autoComplete="off"
>
@@ -493,14 +502,11 @@ function Field({
id,
label,
autoComplete = "off",
onFocus,
...props
}: {
id: string;
label: string;
} & InputHTMLAttributes<HTMLInputElement>) {
const [unlockAutofill, setUnlockAutofill] = useState(false);
return (
<div className="w-full">
<label htmlFor={id} className="auth-label">
@@ -511,11 +517,6 @@ function Field({
className="auth-input"
{...props}
autoComplete={autoComplete}
readOnly={!unlockAutofill}
onFocus={(event) => {
if (!unlockAutofill) setUnlockAutofill(true);
onFocus?.(event);
}}
/>
</div>
);
+3 -1
View File
@@ -51,7 +51,9 @@ export default function ClothesShell({
<span aria-hidden className="text-sm text-muted/60 sm:text-base">
•
</span>
<span dir="rtl">{title}</span>
<span dir="rtl" className="font-doran">
{title}
</span>
</h1>
{description ? (
<p className="mt-3 max-w-xl text-sm leading-7 text-muted">
+10 -7
View File
@@ -20,8 +20,17 @@ export default function DashTileGrid({
startDelayMs = 80,
stepMs = 70,
}: DashTileGridProps) {
const gridCols =
tiles.length <= 2
? "grid-cols-1 sm:grid-cols-2 lg:max-w-2xl"
: tiles.length === 3
? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
: tiles.length === 4
? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4"
: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-5";
return (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-5">
<div className={`grid gap-2 ${gridCols}`}>
{tiles.map((tile, i) => (
<Link
key={tile.href + tile.titleEn}
@@ -42,12 +51,6 @@ export default function DashTileGrid({
{tile.description}
</p>
</div>
<span className="mt-5 text-center text-xs tracking-wide text-muted">
مشاهده
<span aria-hidden className="mx-1">
←
</span>
</span>
</Link>
))}
</div>
+86 -21
View File
@@ -1,12 +1,36 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import ChangePasswordModal from "@/components/dashboard/ChangePasswordModal";
import ManaLogo from "@/components/ui/ManaLogo";
import { useAuth } from "@/lib/auth";
type NavLink = { href: string; label: string };
const ADMIN_LINKS: NavLink[] = [
{ href: "/clothes", label: "لباس‌های من" },
{ href: "/customers", label: "مشتریان من" },
{ href: "/blog", label: "بلاگ من" },
{ href: "/blog/categories", label: "دسته‌بندی بلاگ" },
{ href: "/orders", label: "سفارش‌های من" },
{ href: "/profile", label: "پروفایل من" },
];
const CUSTOMER_LINKS: NavLink[] = [
{ href: "/account/orders", label: "سفارش‌های من" },
{ href: "/account/profile", label: "پروفایل من" },
];
function canAccessAdmin(role: string | undefined) {
return role === "STORE_ADMIN" || role === "SUPER_ADMIN";
}
function isCustomerPath(pathname: string) {
return pathname === "/account" || pathname.startsWith("/account/");
}
type DashboardHeaderProps = {
userName?: string;
};
@@ -16,6 +40,20 @@ export default function DashboardHeader({
}: DashboardHeaderProps) {
const { user, logout } = useAuth();
const router = useRouter();
const pathname = usePathname();
const onCustomerArea = isCustomerPath(pathname);
const isAdmin = canAccessAdmin(user?.role);
const homeHref = onCustomerArea ? "/account" : "/";
const profileHref = onCustomerArea ? "/account/profile" : "/profile";
const navLinks = onCustomerArea
? [
...CUSTOMER_LINKS,
...(isAdmin ? [{ href: "/", label: "پنل مدیریت" }] : []),
]
: [
...ADMIN_LINKS,
{ href: "/account", label: "حساب کاربری" },
];
const displayName =
[user?.firstName, user?.lastName].filter(Boolean).join(" ").trim() ||
user?.name?.trim() ||
@@ -60,7 +98,7 @@ export default function DashboardHeader({
<MenuIcon open={menuOpen} />
</button>
<ManaLogo href="/" height={31} variant="black" />
<ManaLogo href={homeHref} height={31} variant="black" />
</div>
<div ref={userRef} className="relative">
@@ -85,7 +123,7 @@ export default function DashboardHeader({
{userOpen && (
<div role="menu" className="dash-menu-panel">
<Link
href="/profile"
href={profileHref}
role="menuitem"
className="dash-menu-item"
onClick={() => setUserOpen(false)}
@@ -93,6 +131,28 @@ export default function DashboardHeader({
<UserIcon />
پروفایل
</Link>
{isAdmin && onCustomerArea ? (
<Link
href="/"
role="menuitem"
className="dash-menu-item"
onClick={() => setUserOpen(false)}
>
<AdminMiniIcon />
پنل مدیریت
</Link>
) : null}
{!onCustomerArea ? (
<Link
href="/account"
role="menuitem"
className="dash-menu-item"
onClick={() => setUserOpen(false)}
>
<UserIcon />
حساب کاربری
</Link>
) : null}
<button
type="button"
role="menuitem"
@@ -127,24 +187,16 @@ export default function DashboardHeader({
className="relative z-20 border-b border-line bg-surface"
>
<div className="mx-auto flex w-full max-w-6xl flex-wrap gap-x-8 gap-y-3 px-4 py-4 text-sm sm:px-6 lg:px-8">
<Link href="/clothes" className="text-ink transition-opacity hover:opacity-60">
لباس‌های من
</Link>
<Link href="/customers" className="text-ink transition-opacity hover:opacity-60">
مشتریان من
</Link>
<Link href="/blog" className="text-ink transition-opacity hover:opacity-60">
بلاگ من
</Link>
<Link href="/blog/categories" className="text-ink transition-opacity hover:opacity-60">
دسته‌بندی بلاگ
</Link>
<Link href="/orders" className="text-ink transition-opacity hover:opacity-60">
سفارش‌های من
</Link>
<Link href="/profile" className="text-ink transition-opacity hover:opacity-60">
پروفایل من
</Link>
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="text-ink transition-opacity hover:opacity-60"
onClick={() => setMenuOpen(false)}
>
{link.label}
</Link>
))}
</div>
</nav>
)}
@@ -185,6 +237,19 @@ function UserIcon() {
);
}
function AdminMiniIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
<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.4"
strokeLinejoin="round"
/>
</svg>
);
}
function KeyIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+35 -8
View File
@@ -2,6 +2,8 @@ const API_URL =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") ||
"http://api.mana-collection.com:3002/api/v1";
const FETCH_TIMEOUT_MS = 10_000;
export class ApiError extends Error {
status: number;
body: unknown;
@@ -67,6 +69,18 @@ async function parseError(res: Response) {
return new ApiError(res.status, message, body);
}
function fetchWithTimeout(
input: string,
init: RequestInit = {},
timeoutMs = FETCH_TIMEOUT_MS,
) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
return fetch(input, { ...init, signal: controller.signal }).finally(() => {
clearTimeout(timer);
});
}
let refreshPromise: Promise<string | null> | null = null;
async function refreshAccessToken(): Promise<string | null> {
@@ -75,7 +89,7 @@ async function refreshAccessToken(): Promise<string | null> {
if (!refreshPromise) {
refreshPromise = (async () => {
try {
const res = await fetch(`${API_URL}/auth/refresh`, {
const res = await fetchWithTimeout(`${API_URL}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
@@ -117,19 +131,32 @@ export async function api<T = unknown>(
if (token) headers.Authorization = `Bearer ${token}`;
const doFetch = () =>
fetch(`${API_URL}${path.startsWith("/") ? path : `/${path}`}`, {
method: options.method ?? (options.body !== undefined ? "POST" : "GET"),
headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
});
fetchWithTimeout(
`${API_URL}${path.startsWith("/") ? path : `/${path}`}`,
{
method: options.method ?? (options.body !== undefined ? "POST" : "GET"),
headers,
body:
options.body !== undefined ? JSON.stringify(options.body) : undefined,
},
);
let res = await doFetch();
let res: Response;
try {
res = await doFetch();
} catch {
throw new ApiError(0, "ارتباط با سرور برقرار نشد");
}
if (res.status === 401 && useAuth && !options.token) {
const next = await refreshAccessToken();
if (next) {
headers.Authorization = `Bearer ${next}`;
res = await doFetch();
try {
res = await doFetch();
} catch {
throw new ApiError(0, "ارتباط با سرور برقرار نشد");
}
}
}
+60 -32
View File
@@ -6,10 +6,13 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { flushSync } from "react-dom";
import {
ApiError,
api,
clearTokens,
readStoredUser,
@@ -63,50 +66,75 @@ type AuthContextValue = {
const AuthContext = createContext<AuthContextValue | null>(null);
function persistSession(data: TokenResponse) {
setTokens(data.accessToken, data.refreshToken);
storeUser(data.user);
return data.user;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthSessionUser | null>(null);
const [loading, setLoading] = useState(true);
// Bumped on every successful login/register so in-flight /auth/me calls
// from a previous (stale) token cannot wipe the new session.
const sessionGen = useRef(0);
const persistSession = useCallback((data: TokenResponse) => {
sessionGen.current += 1;
setTokens(data.accessToken, data.refreshToken);
storeUser(data.user);
flushSync(() => setUser(data.user));
return data.user;
}, []);
const refreshMe = useCallback(async () => {
const gen = sessionGen.current;
try {
const me = await api<AuthSessionUser>("/auth/me");
if (gen !== sessionGen.current) return;
storeUser(me);
setUser(me);
} catch {
clearTokens();
setUser(null);
} catch (err) {
if (gen !== sessionGen.current) return;
// Keep the cached session on network blips; only hard-logout on 401.
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
clearTokens();
setUser(null);
}
}
}, []);
useEffect(() => {
const cached = readStoredUser<AuthSessionUser>();
if (cached) setUser(cached);
const token =
typeof window !== "undefined"
? localStorage.getItem("mana_access_token")
: null;
if (!token) {
setLoading(false);
return;
}
void refreshMe().finally(() => setLoading(false));
if (cached) {
setLoading(false);
void refreshMe();
return;
}
const safety = window.setTimeout(() => setLoading(false), 12_000);
void refreshMe().finally(() => {
window.clearTimeout(safety);
setLoading(false);
});
}, [refreshMe]);
const login = useCallback(async (cellNumber: string, password: string) => {
const data = await api<TokenResponse>("/auth/login", {
auth: false,
body: { cellNumber, password },
});
const next = persistSession(data);
setUser(next);
return next;
}, []);
const login = useCallback(
async (cellNumber: string, password: string) => {
const data = await api<TokenResponse>("/auth/login", {
auth: false,
body: { cellNumber, password },
});
return persistSession(data);
},
[persistSession],
);
const sendLoginOtp = useCallback(async (cellNumber: string) => {
await api("/auth/login/send-code", {
@@ -115,15 +143,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
});
}, []);
const loginWithOtp = useCallback(async (cellNumber: string, code: string) => {
const data = await api<TokenResponse>("/auth/login/verify", {
auth: false,
body: { cellNumber, code },
});
const next = persistSession(data);
setUser(next);
return next;
}, []);
const loginWithOtp = useCallback(
async (cellNumber: string, code: string) => {
const data = await api<TokenResponse>("/auth/login/verify", {
auth: false,
body: { cellNumber, code },
});
return persistSession(data);
},
[persistSession],
);
const registerSendCode = useCallback(
async (input: {
@@ -143,14 +172,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
auth: false,
body: { cellNumber, code },
});
const next = persistSession(data);
setUser(next);
return next;
return persistSession(data);
},
[],
[persistSession],
);
const logout = useCallback(async () => {
sessionGen.current += 1;
const refreshToken =
typeof window !== "undefined"
? localStorage.getItem("mana_refresh_token")
+29
View File
@@ -0,0 +1,29 @@
import { api } from "@/lib/api";
export type CustomerOrderItem = {
id: string;
nameFa: string;
variantLabel: string | null;
quantity: number;
unitPrice: number;
storeId: string;
};
export type CustomerOrder = {
id: string;
status: string;
itemCount: number;
totalAmount: number;
note: string;
createdAt: string;
items: CustomerOrderItem[];
fulfillments: { id: string; storeId: string; status: string }[];
};
export function fetchMyOrders() {
return api<CustomerOrder[]>("/orders/mine");
}
export function fetchMyOrder(id: string) {
return api<CustomerOrder>(`/orders/mine/${id}`);
}
+72
View File
@@ -0,0 +1,72 @@
import { api } from "@/lib/api";
export type ProfileAddress = {
id: string;
label: string;
province: string;
city: string;
fullAddress: string;
landline: string | null;
};
export type Profile = {
id: string;
firstName: string;
lastName: string;
cellNumber: string;
email: string | null;
instagram: string | null;
about: string | null;
avatarUrl: string | null;
role: string;
name: string;
addresses: ProfileAddress[];
};
export type UpdateProfileInput = {
firstName?: string;
lastName?: string;
email?: string | null;
instagram?: string | null;
about?: string | null;
avatarUrl?: string | null;
};
export type AddressInput = {
label?: string;
province: string;
city: string;
fullAddress: string;
landline?: string | null;
};
export function fetchProfile() {
return api<Profile>("/profile");
}
export function updateProfile(body: UpdateProfileInput) {
return api<Omit<Profile, "addresses">>("/profile", {
method: "PATCH",
body,
});
}
export function createAddress(body: AddressInput) {
return api<ProfileAddress>("/profile/addresses", {
method: "POST",
body,
});
}
export function updateAddress(id: string, body: Partial<AddressInput>) {
return api<ProfileAddress>(`/profile/addresses/${id}`, {
method: "PATCH",
body,
});
}
export function deleteAddress(id: string) {
return api<{ ok: true }>(`/profile/addresses/${id}`, {
method: "DELETE",
});
}