From 68365457a1ae878f428b9083fc59b2aac407c9d4 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sat, 26 Sep 2026 11:10:37 +0330 Subject: [PATCH] Fix auth login flow, Iran Yekan/Doran fonts, and account tiles. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevent stale /auth/me from wiping a fresh session, unblock login submit, allow localhost HMR, and drop the tile "مشاهده" CTA. Co-authored-by: Cursor --- next.config.ts | 2 + src/app/account/orders/page.tsx | 131 +++++ src/app/account/page.tsx | 145 +++++ src/app/account/profile/page.tsx | 510 ++++++++++++++++++ src/app/globals.css | 6 +- src/app/layout.tsx | 48 +- src/app/page.tsx | 4 +- src/components/RequireAuth.tsx | 71 +++ src/components/RequireStoreAuth.tsx | 43 -- src/components/auth/AuthForm.tsx | 173 +++--- src/components/clothes/ClothesShell.tsx | 4 +- src/components/dashboard/DashTileGrid.tsx | 17 +- src/components/dashboard/DashboardHeader.tsx | 107 +++- src/fonts/iranyekan/IRANYekanBlackFaNum.ttf | Bin 0 -> 59004 bytes src/fonts/iranyekan/IRANYekanBoldFaNum.ttf | Bin 0 -> 59504 bytes .../iranyekan/IRANYekanExtraBoldFaNum.ttf | Bin 0 -> 59884 bytes src/fonts/iranyekan/IRANYekanLightFaNum.ttf | Bin 0 -> 61476 bytes src/fonts/iranyekan/IRANYekanMediumFaNum.ttf | Bin 0 -> 60392 bytes src/fonts/iranyekan/IRANYekanRegularFaNum.ttf | Bin 0 -> 60264 bytes src/fonts/iranyekan/IRANYekanThinFaNum.ttf | Bin 0 -> 63780 bytes src/lib/api.ts | 43 +- src/lib/auth.tsx | 92 ++-- src/lib/customer-orders-api.ts | 29 + src/lib/profile-api.ts | 72 +++ 24 files changed, 1294 insertions(+), 203 deletions(-) create mode 100644 src/app/account/orders/page.tsx create mode 100644 src/app/account/page.tsx create mode 100644 src/app/account/profile/page.tsx create mode 100644 src/components/RequireAuth.tsx delete mode 100644 src/components/RequireStoreAuth.tsx create mode 100755 src/fonts/iranyekan/IRANYekanBlackFaNum.ttf create mode 100755 src/fonts/iranyekan/IRANYekanBoldFaNum.ttf create mode 100755 src/fonts/iranyekan/IRANYekanExtraBoldFaNum.ttf create mode 100755 src/fonts/iranyekan/IRANYekanLightFaNum.ttf create mode 100755 src/fonts/iranyekan/IRANYekanMediumFaNum.ttf create mode 100755 src/fonts/iranyekan/IRANYekanRegularFaNum.ttf create mode 100755 src/fonts/iranyekan/IRANYekanThinFaNum.ttf create mode 100644 src/lib/customer-orders-api.ts create mode 100644 src/lib/profile-api.ts diff --git a/next.config.ts b/next.config.ts index f69e93c..02fed60 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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", diff --git a/src/app/account/orders/page.tsx b/src/app/account/orders/page.tsx new file mode 100644 index 0000000..ce5bf73 --- /dev/null +++ b/src/app/account/orders/page.tsx @@ -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 = { + 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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + {error ? ( +

+ {error} +

+ ) : null} + + {loading ? ( +

در حال بارگذاری…

+ ) : orders.length === 0 ? ( +
+

هنوز سفارشی ثبت نشده است.

+

+ بعد از خرید از فروشگاه مانا، سفارش‌ها اینجا نمایش داده می‌شوند. +

+
+ ) : ( +
+ {orders.map((order, index) => ( +
+
+
+

+ سفارش{" "} + + #{order.id.slice(-8)} + +

+ + {STATUS_FA[order.status] ?? order.status} + +
+

+ {formatDate(order.createdAt)} ·{" "} + {new Intl.NumberFormat("fa-IR").format(order.itemCount)} قلم +

+ {order.items.length > 0 ? ( +

+ {order.items + .map((item) => + item.variantLabel + ? `${item.nameFa} (${item.variantLabel})` + : item.nameFa, + ) + .join("، ")} +

+ ) : null} +
+

+ {formatMoney(order.totalAmount)} +

+
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx new file mode 100644 index 0000000..0bd0c21 --- /dev/null +++ b/src/app/account/page.tsx @@ -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: , + }, + { + href: "/account/profile", + title: "پروفایل من", + titleEn: "My Profile", + description: "اطلاعات حساب و آدرس‌ها", + icon: , + }, + ...(showAdmin + ? [ + { + href: "/", + title: "پنل مدیریت", + titleEn: "Admin", + description: "مدیریت فروشگاه و محصولات", + icon: , + } satisfies DashTileItem, + ] + : []), + ]; + + const bannerDelay = 80 + tiles.length * 70; + + return ( +
+ + +
+
+

+ + Account + + + • + + + حساب کاربری + +

+

+ سفارش‌ها و اطلاعات حساب خود را از اینجا مدیریت کنید + {showAdmin ? "؛ در صورت دسترسی می‌توانید به پنل مدیریت بروید" : ""} + . +

+
+ + + + +
+
+ ); +} + +function iconProps() { + return { + width: 56, + height: 56, + viewBox: "0 0 24 24", + fill: "none", + "aria-hidden": true as const, + }; +} + +function OrdersIcon() { + return ( + + + + + ); +} + +function ProfileIcon() { + return ( + + + + + ); +} + +function AdminIcon() { + return ( + + + + ); +} diff --git a/src/app/account/profile/page.tsx b/src/app/account/profile/page.tsx new file mode 100644 index 0000000..99e51f3 --- /dev/null +++ b/src/app/account/profile/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(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([]); + const [removedIds, setRemovedIds] = useState([]); + + 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) { + const file = event.target.files?.[0]; + if (!file) return; + setAvatar(URL.createObjectURL(file)); + } + + function updateAddressField( + id: string, + field: keyof Omit, + 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 ( + + {loading ? ( +

در حال بارگذاری…

+ ) : ( +
void handleSubmit(e)} + className="grid grid-cols-1 gap-3 lg:grid-cols-[260px_1fr]" + > + + +
+ {error ? ( +

+ {error} +

+ ) : null} + {success ? ( +

+ {success} +

+ ) : null} + +
+
+ + setFirstName(e.target.value)} + placeholder="نام" + required + /> +
+
+ + setLastName(e.target.value)} + placeholder="نام خانوادگی" + required + /> +
+
+ + + قابل تغییر نیست +
+
+ + setEmail(e.target.value)} + placeholder="name@example.com" + /> +
+
+ + setInstagram(e.target.value)} + placeholder="@username" + /> +
+
+ +