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
@@ -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">
|
||||
از اینجا لباسها، مشتریان، بلاگ، سفارشها و پروفایل فروشگاه را مدیریت کنید.
|
||||
|
||||
Reference in New Issue
Block a user