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:
co-authored by
Cursor
parent
27d5cd0186
commit
68365457a1
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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">
|
||||
از اینجا لباسها، مشتریان، بلاگ، سفارشها و پروفایل فروشگاه را مدیریت کنید.
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
+35
-8
@@ -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
@@ -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")
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user