From e5592ca9977a4de11d520a7be883eefd729757bd Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 30 Sep 2026 08:57:38 +0330 Subject: [PATCH] Turn customers into users management with membership and row actions. Co-authored-by: Cursor --- src/app/account/page.tsx | 2 - src/app/customers/page.tsx | 360 ++++++++++++++++--- src/app/globals.css | 49 ++- src/app/page.tsx | 8 +- src/components/dashboard/DashboardBanner.tsx | 20 +- src/components/dashboard/DashboardHeader.tsx | 2 +- src/components/ui/IconButton.tsx | 10 + src/data/customers.ts | 75 +--- src/lib/customers-api.ts | 47 ++- 9 files changed, 426 insertions(+), 147 deletions(-) diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index e02f832..eb824be 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -85,8 +85,6 @@ export default function CustomerHomePage() { src="/images/image4.jpg" alt="نمای کالکشن مانا" caption="Quiet elegance." - hoverTitle="خریدهای من" - hoverSubtitle="My Orders" priority style={{ animationDelay: `${bannerDelay}ms` }} /> diff --git a/src/app/customers/page.tsx b/src/app/customers/page.tsx index 4cc0250..9072e29 100644 --- a/src/app/customers/page.tsx +++ b/src/app/customers/page.tsx @@ -1,37 +1,72 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { FormEvent, useCallback, useEffect, useState } from "react"; import ClothesShell from "@/components/clothes/ClothesShell"; +import { useConfirm } from "@/components/ui/ConfirmDialog"; import IconButton, { ClearFilterIcon, + EditIcon, FilterIcon, + RoleIcon, + SubscriptionIcon, + TrashIcon, } from "@/components/ui/IconButton"; +import { FormModal } from "@/components/ui/Modal"; import Pagination, { usePagination } from "@/components/ui/Pagination"; import { Customer, - formatJoinedDate, + formatCount, + roleLabel, + subscriptionLabel, + SubscriptionStatus, + UserRole, } from "@/data/customers"; import { ApiError } from "@/lib/api"; -import { fetchCustomers } from "@/lib/customers-api"; +import { useAuth } from "@/lib/auth"; +import { + deleteCustomer, + fetchCustomers, + updateCustomer, + updateCustomerMembership, + updateCustomerRole, +} from "@/lib/customers-api"; const PAGE_SIZE = 5; +const ROLES: UserRole[] = ["CUSTOMER", "STORE_ADMIN", "SUPER_ADMIN"]; +const MEMBERSHIPS: SubscriptionStatus[] = ["regular", "premium"]; + type Filters = { name: string; phone: string; }; +type Editor = + | { kind: "edit"; customer: Customer } + | { kind: "role"; customer: Customer } + | { kind: "membership"; customer: Customer } + | null; + const emptyFilters: Filters = { name: "", phone: "", }; export default function CustomersPage() { + const confirmDialog = useConfirm(); + const { user } = useAuth(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [draft, setDraft] = useState(emptyFilters); const [applied, setApplied] = useState(emptyFilters); + const [editor, setEditor] = useState(null); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [phone, setPhone] = useState(""); + const [role, setRole] = useState("CUSTOMER"); + const [membership, setMembership] = useState("regular"); const reload = useCallback(async () => { setLoading(true); @@ -40,9 +75,7 @@ export default function CustomersPage() { setItems(await fetchCustomers()); } catch (err) { setError( - err instanceof ApiError - ? err.message - : "بارگذاری مشتریان ناموفق بود.", + err instanceof ApiError ? err.message : "بارگذاری کاربران ناموفق بود.", ); } finally { setLoading(false); @@ -64,14 +97,111 @@ export default function CustomersPage() { const { page, setPage, pageItems, totalPages, totalItems, pageSize } = usePagination(list, PAGE_SIZE, JSON.stringify(applied)); + function closeEditor() { + setEditor(null); + } + + function openEdit(customer: Customer) { + setError(null); + setFirstName(customer.firstName); + setLastName(customer.lastName); + setPhone(customer.phone); + setEditor({ kind: "edit", customer }); + } + + function openRole(customer: Customer) { + setError(null); + setRole(customer.role); + setEditor({ kind: "role", customer }); + } + + function openMembership(customer: Customer) { + setError(null); + setMembership(customer.subscription); + setEditor({ kind: "membership", customer }); + } + + function replaceCustomer(next: Customer) { + setItems((prev) => prev.map((item) => (item.id === next.id ? next : item))); + } + + async function handleEdit(event: FormEvent) { + event.preventDefault(); + if (!editor || editor.kind !== "edit") return; + if (!firstName.trim() || !lastName.trim() || !phone.trim()) return; + setSaving(true); + setError(null); + try { + replaceCustomer( + await updateCustomer(editor.customer.id, { + firstName: firstName.trim(), + lastName: lastName.trim(), + cellNumber: phone.trim(), + }), + ); + closeEditor(); + } catch (err) { + setError(err instanceof ApiError ? err.message : "ویرایش کاربر ناموفق بود."); + } finally { + setSaving(false); + } + } + + async function handleRole(event: FormEvent) { + event.preventDefault(); + if (!editor || editor.kind !== "role") return; + setSaving(true); + setError(null); + try { + replaceCustomer(await updateCustomerRole(editor.customer.id, role)); + closeEditor(); + } catch (err) { + setError( + err instanceof ApiError ? err.message : "تغییر نقش ناموفق بود.", + ); + } finally { + setSaving(false); + } + } + + async function handleMembership(event: FormEvent) { + event.preventDefault(); + if (!editor || editor.kind !== "membership") return; + setSaving(true); + setError(null); + try { + replaceCustomer( + await updateCustomerMembership(editor.customer.id, membership), + ); + closeEditor(); + } catch (err) { + setError( + err instanceof ApiError ? err.message : "تغییر عضویت ناموفق بود.", + ); + } finally { + setSaving(false); + } + } + + async function handleDelete(customer: Customer) { + if (!(await confirmDialog(`«${customer.nameFa}» حذف شود؟`))) return; + setError(null); + try { + await deleteCustomer(customer.id); + setItems((prev) => prev.filter((item) => item.id !== customer.id)); + } catch (err) { + setError(err instanceof ApiError ? err.message : "حذف کاربر ناموفق بود."); + } + } + return ( {error ? ( @@ -133,40 +263,95 @@ export default function CustomersPage() { ) : ( <>
- {pageItems.map((customer, i) => ( -
-
-

- {customer.nameFa} -

-

- {customer.phone} -

-
+ {pageItems.map((customer, i) => { + const isSelf = user?.id === customer.id; + const protectedAdmin = + customer.role === "SUPER_ADMIN" && user?.role !== "SUPER_ADMIN"; + const premium = customer.subscription === "premium"; + return ( +
+
+

+ {customer.nameFa} +

+

+ {customer.phone} +

+

+ {roleLabel(customer.role)} +

+
-
- عضویت - -
-
- ))} +
+
+ عضویت + + {subscriptionLabel(customer.subscription)} + +
+
+ سفارش‌ها + + {formatCount(customer.orderCount)} + +
+
+ +
+ openEdit(customer)} + > + + + openRole(customer)} + > + + + openMembership(customer)} + > + + + void handleDelete(customer)} + > + + +
+
+ ); + })} {list.length === 0 ? (

- مشتری‌ای با این فیلترها پیدا نشد. + کاربری با این فیلترها پیدا نشد.

) : null}
@@ -182,6 +367,101 @@ export default function CustomersPage() { ) : null} )} + + +
+ + setFirstName(e.target.value)} + required + /> +
+
+ + setLastName(e.target.value)} + required + /> +
+
+ + setPhone(e.target.value)} + required + /> +
+ {error ?

{error}

: null} +
+ + +
+ + +
+ {error ?

{error}

: null} +
+ + +
+ + +
+ {error ?

{error}

: null} +
); } diff --git a/src/app/globals.css b/src/app/globals.css index ac22d93..d4f5e88 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -272,25 +272,8 @@ body { color: #fff; } -/* Dashboard banner — white wipe overlay */ .dash-banner { - cursor: pointer; -} - -.dash-banner-wipe { - position: absolute; - inset: 0; - z-index: 2; - display: flex; - align-items: center; - justify-content: center; - background: #fff; - transform: translateY(101%); - transition: transform 0.32s cubic-bezier(0.7, 0, 0.84, 0.2); -} - -.dash-banner:hover .dash-banner-wipe { - transform: translateY(0); + cursor: default; } /* Dashboard tiles */ @@ -646,6 +629,11 @@ body { border-color 0.2s ease; } +.icon-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + .icon-btn[data-tooltip]::before, .icon-btn[data-tooltip]::after { position: absolute; @@ -764,6 +752,7 @@ body { .customer-card { display: flex; align-items: center; + flex-wrap: wrap; gap: 1rem; border: 1px solid var(--line); background: #fff; @@ -776,6 +765,30 @@ body { border-color: #cfcfcf; } +.customer-stats { + display: flex; + align-items: center; + gap: 1.25rem; +} + +.customer-stat { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.15rem; + min-width: 4.25rem; +} + +.customer-stat-label { + font-size: 0.65rem; + color: color-mix(in srgb, var(--muted) 75%, white); +} + +.customer-stat-value { + font-size: 0.85rem; + color: color-mix(in srgb, var(--ink) 55%, white); +} + .customer-joined { display: flex; flex-direction: column; diff --git a/src/app/page.tsx b/src/app/page.tsx index 25eafc9..eef5a86 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -15,9 +15,9 @@ const tiles: DashTileItem[] = [ }, { href: "/customers", - title: "مشتریان من", - titleEn: "My Customers", - description: "فهرست و جزئیات مشتریان", + title: "کاربران", + titleEn: "Users", + description: "فهرست، نقش و عضویت کاربران", icon: , }, { @@ -74,7 +74,7 @@ export default function HomePage() {

- از اینجا لباس‌ها، مشتریان، بلاگ، سفارش‌ها، وب‌سایت و پروفایل فروشگاه را + از اینجا لباس‌ها، کاربران، بلاگ، سفارش‌ها، وب‌سایت و پروفایل فروشگاه را مدیریت کنید.

diff --git a/src/components/dashboard/DashboardBanner.tsx b/src/components/dashboard/DashboardBanner.tsx index fce6538..f569045 100644 --- a/src/components/dashboard/DashboardBanner.tsx +++ b/src/components/dashboard/DashboardBanner.tsx @@ -5,8 +5,6 @@ type DashboardBannerProps = { src: string; alt: string; caption?: string; - hoverTitle?: string; - hoverSubtitle?: string; className?: string; style?: CSSProperties; priority?: boolean; @@ -16,15 +14,13 @@ export default function DashboardBanner({ src, alt, caption = "Quiet elegance.", - hoverTitle = "مدیریت فروش ویژه ی من", - hoverSubtitle = "Special Sale", className = "", style, priority = false, }: DashboardBannerProps) { return (
{caption}

) : null} - -
-
-

{hoverTitle}

-

- {hoverSubtitle} -

-
-
); } diff --git a/src/components/dashboard/DashboardHeader.tsx b/src/components/dashboard/DashboardHeader.tsx index cf7f454..580ccb3 100644 --- a/src/components/dashboard/DashboardHeader.tsx +++ b/src/components/dashboard/DashboardHeader.tsx @@ -11,7 +11,7 @@ type NavLink = { href: string; label: string }; const ADMIN_LINKS: NavLink[] = [ { href: "/clothes", label: "لباس‌های من" }, - { href: "/customers", label: "مشتریان من" }, + { href: "/customers", label: "کاربران" }, { href: "/blog", label: "بلاگ من" }, { href: "/blog/categories", label: "دسته‌بندی بلاگ" }, { href: "/orders", label: "سفارش‌های من" }, diff --git a/src/components/ui/IconButton.tsx b/src/components/ui/IconButton.tsx index bbb5470..a2c4830 100644 --- a/src/components/ui/IconButton.tsx +++ b/src/components/ui/IconButton.tsx @@ -48,6 +48,16 @@ export default function IconButton({ ); } +export function RoleIcon() { + return ( + + + + + + ); +} + export function EditIcon() { return ( diff --git a/src/data/customers.ts b/src/data/customers.ts index 3013149..f39be46 100644 --- a/src/data/customers.ts +++ b/src/data/customers.ts @@ -1,71 +1,20 @@ export type SubscriptionStatus = "regular" | "premium"; +export type UserRole = "CUSTOMER" | "STORE_ADMIN" | "SUPER_ADMIN"; + export type Customer = { id: string; + firstName: string; + lastName: string; nameFa: string; phone: string; joinedAt: string; subscription: SubscriptionStatus; + role: UserRole; + orderCount: number; }; -export const customers: Customer[] = [ - { - id: "cu1", - nameFa: "سارا محمدی", - phone: "09121234567", - joinedAt: "2025-11-12", - subscription: "premium", - }, - { - id: "cu2", - nameFa: "نیلوفر احمدی", - phone: "09351239876", - joinedAt: "2026-01-04", - subscription: "regular", - }, - { - id: "cu3", - nameFa: "مریم کریمی", - phone: "09197654321", - joinedAt: "2025-08-21", - subscription: "premium", - }, - { - id: "cu4", - nameFa: "الهام رضایی", - phone: "09014567890", - joinedAt: "2026-02-18", - subscription: "regular", - }, - { - id: "cu5", - nameFa: "هانیه موسوی", - phone: "09128887766", - joinedAt: "2025-12-02", - subscription: "regular", - }, - { - id: "cu6", - nameFa: "یاسمن نوری", - phone: "09361112233", - joinedAt: "2026-03-09", - subscription: "premium", - }, - { - id: "cu7", - nameFa: "زهرا حسینی", - phone: "09201230000", - joinedAt: "2025-06-30", - subscription: "regular", - }, - { - id: "cu8", - nameFa: "فرناز اکبری", - phone: "09193456789", - joinedAt: "2026-04-01", - subscription: "premium", - }, -]; +export const customers: Customer[] = []; export function formatJoinedDate(isoDate: string) { const date = new Date(`${isoDate}T12:00:00`); @@ -76,6 +25,16 @@ export function formatJoinedDate(isoDate: string) { }).format(date); } +export function formatCount(value: number) { + return new Intl.NumberFormat("fa-IR").format(value); +} + export function subscriptionLabel(status: SubscriptionStatus) { return status === "premium" ? "پرمیوم" : "عادی"; } + +export function roleLabel(role: UserRole) { + if (role === "SUPER_ADMIN") return "مدیر کل"; + if (role === "STORE_ADMIN") return "مدیر فروشگاه"; + return "مشتری"; +} diff --git a/src/lib/customers-api.ts b/src/lib/customers-api.ts index dbc9bbd..f7b7c53 100644 --- a/src/lib/customers-api.ts +++ b/src/lib/customers-api.ts @@ -1,5 +1,5 @@ import { api } from "@/lib/api"; -import type { Customer } from "@/data/customers"; +import type { Customer, SubscriptionStatus, UserRole } from "@/data/customers"; export type ApiCustomer = { id: string; @@ -8,17 +8,19 @@ export type ApiCustomer = { name?: string; cellNumber: string; email?: string | null; - instagram?: string | null; - about?: string | null; - avatarUrl?: string | null; + role?: UserRole; + membership?: SubscriptionStatus; + orderCount?: number; disabled?: boolean; createdAt: string; }; export function mapCustomer(row: ApiCustomer): Customer { + const firstName = row.firstName?.trim() ?? ""; + const lastName = row.lastName?.trim() ?? ""; const name = row.name?.trim() || - `${row.firstName ?? ""} ${row.lastName ?? ""}`.trim() || + `${firstName} ${lastName}`.trim() || "بدون نام"; const joined = row.createdAt ? new Date(row.createdAt).toISOString().slice(0, 10) @@ -26,10 +28,14 @@ export function mapCustomer(row: ApiCustomer): Customer { return { id: row.id, + firstName, + lastName, nameFa: name, phone: row.cellNumber, joinedAt: joined, - subscription: "regular", + subscription: row.membership === "premium" ? "premium" : "regular", + role: row.role ?? "CUSTOMER", + orderCount: row.orderCount ?? 0, }; } @@ -37,3 +43,32 @@ export async function fetchCustomers() { const rows = await api("/customers"); return rows.map(mapCustomer); } + +export async function updateCustomer( + id: string, + body: { firstName: string; lastName: string; cellNumber: string }, +) { + return mapCustomer(await api(`/customers/${id}`, { method: "PATCH", body })); +} + +export async function updateCustomerRole(id: string, role: UserRole) { + return mapCustomer( + await api(`/customers/${id}/role`, { method: "PATCH", body: { role } }), + ); +} + +export async function updateCustomerMembership( + id: string, + membership: SubscriptionStatus, +) { + return mapCustomer( + await api(`/customers/${id}/membership`, { + method: "PATCH", + body: { membership }, + }), + ); +} + +export async function deleteCustomer(id: string) { + await api(`/customers/${id}`, { method: "DELETE" }); +}