Turn customers into users management with membership and row actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-30 08:57:38 +03:30
co-authored by Cursor
parent e687e4502e
commit e5592ca997
9 changed files with 426 additions and 147 deletions
-2
View File
@@ -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` }}
/>
+320 -40
View File
@@ -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<Customer[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState<Filters>(emptyFilters);
const [applied, setApplied] = useState<Filters>(emptyFilters);
const [editor, setEditor] = useState<Editor>(null);
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [phone, setPhone] = useState("");
const [role, setRole] = useState<UserRole>("CUSTOMER");
const [membership, setMembership] = useState<SubscriptionStatus>("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<HTMLFormElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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 (
<ClothesShell
eyebrow="Customers"
title="مشتریان"
description="مشتریان ثبت‌نام‌شده در مانا کالکشن."
eyebrow="Users"
title="کاربران"
description="کاربران ثبت‌نام‌شده در مانا کالکشن."
breadcrumbs={[
{ label: "داشبورد", href: "/" },
{ label: "مشتریان" },
{ label: "کاربران" },
]}
>
{error ? (
@@ -133,40 +263,95 @@ export default function CustomersPage() {
) : (
<>
<div className="space-y-1">
{pageItems.map((customer, i) => (
<article
key={customer.id}
className="customer-card dash-enter"
style={{ animationDelay: `${70 + i * 45}ms` }}
>
<div className="min-w-0 flex-1">
<h2 className="truncate text-base text-ink/65 sm:text-lg">
{customer.nameFa}
</h2>
<p
dir="ltr"
className="font-montserrat mt-0.5 text-end text-[0.7rem] font-medium tracking-wide text-muted/70 sm:text-xs"
>
{customer.phone}
</p>
</div>
{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 (
<article
key={customer.id}
className="customer-card dash-enter"
style={{ animationDelay: `${70 + i * 45}ms` }}
>
<div className="min-w-0 flex-1">
<h2 className="truncate text-base text-ink/65 sm:text-lg">
{customer.nameFa}
</h2>
<p
dir="ltr"
className="font-montserrat mt-0.5 text-end text-[0.7rem] font-medium tracking-wide text-muted/70 sm:text-xs"
>
{customer.phone}
</p>
<p className="mt-1 text-[0.7rem] text-muted">
{roleLabel(customer.role)}
</p>
</div>
<div className="customer-joined shrink-0">
<span className="customer-joined-label">عضویت</span>
<time
dateTime={customer.joinedAt}
dir="ltr"
className="customer-joined-date"
>
{formatJoinedDate(customer.joinedAt)}
</time>
</div>
</article>
))}
<div className="customer-stats shrink-0">
<div className="customer-stat">
<span className="customer-stat-label">عضویت</span>
<span
className={
premium
? "customer-stat-value text-ink"
: "customer-stat-value"
}
>
{subscriptionLabel(customer.subscription)}
</span>
</div>
<div className="customer-stat">
<span className="customer-stat-label">سفارش‌ها</span>
<span className="customer-stat-value">
{formatCount(customer.orderCount)}
</span>
</div>
</div>
<div className="tree-row-actions shrink-0">
<IconButton
label="ویرایش"
variant="line"
disabled={protectedAdmin}
onClick={() => openEdit(customer)}
>
<EditIcon />
</IconButton>
<IconButton
label="تغییر نقش"
variant="line"
disabled={isSelf || protectedAdmin}
onClick={() => openRole(customer)}
>
<RoleIcon />
</IconButton>
<IconButton
label="عضویت"
variant="line"
className={premium ? "customer-sub-premium" : undefined}
disabled={protectedAdmin}
onClick={() => openMembership(customer)}
>
<SubscriptionIcon premium={premium} />
</IconButton>
<IconButton
label="حذف"
variant="line"
disabled={isSelf || protectedAdmin}
onClick={() => void handleDelete(customer)}
>
<TrashIcon />
</IconButton>
</div>
</article>
);
})}
{list.length === 0 ? (
<p className="rounded-lg border border-line bg-surface px-4 py-8 text-center text-sm text-muted">
مشتری‌ای با این فیلترها پیدا نشد.
کاربری با این فیلترها پیدا نشد.
</p>
) : null}
</div>
@@ -182,6 +367,101 @@ export default function CustomersPage() {
) : null}
</>
)}
<FormModal
open={editor?.kind === "edit"}
title="ویرایش کاربر"
subtitle={editor?.kind === "edit" ? editor.customer.phone : undefined}
onClose={closeEditor}
onSubmit={handleEdit}
submitLabel={saving ? "..." : "ذخیره"}
>
<div className="clothes-field">
<label htmlFor="user-first-name">نام</label>
<input
id="user-first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
/>
</div>
<div className="clothes-field">
<label htmlFor="user-last-name">نام خانوادگی</label>
<input
id="user-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
required
/>
</div>
<div className="clothes-field">
<label htmlFor="user-phone">شماره موبایل</label>
<input
id="user-phone"
dir="ltr"
className="!text-end"
inputMode="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required
/>
</div>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
</FormModal>
<FormModal
open={editor?.kind === "role"}
title="تغییر نقش"
subtitle={editor?.customer.nameFa}
onClose={closeEditor}
onSubmit={handleRole}
submitLabel={saving ? "..." : "ذخیره"}
>
<div className="clothes-field">
<label htmlFor="user-role">نقش</label>
<select
id="user-role"
value={role}
onChange={(e) => setRole(e.target.value as UserRole)}
>
{ROLES.filter(
(item) => user?.role === "SUPER_ADMIN" || item !== "SUPER_ADMIN",
).map((item) => (
<option key={item} value={item}>
{roleLabel(item)}
</option>
))}
</select>
</div>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
</FormModal>
<FormModal
open={editor?.kind === "membership"}
title="عضویت"
subtitle={editor?.customer.nameFa}
onClose={closeEditor}
onSubmit={handleMembership}
submitLabel={saving ? "..." : "ذخیره"}
>
<div className="clothes-field">
<label htmlFor="user-membership">عضویت فعلی</label>
<select
id="user-membership"
value={membership}
onChange={(e) =>
setMembership(e.target.value as SubscriptionStatus)
}
>
{MEMBERSHIPS.map((item) => (
<option key={item} value={item}>
{subscriptionLabel(item)}
</option>
))}
</select>
</div>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
</FormModal>
</ClothesShell>
);
}
+31 -18
View File
@@ -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;
+4 -4
View File
@@ -15,9 +15,9 @@ const tiles: DashTileItem[] = [
},
{
href: "/customers",
title: "مشتریان من",
titleEn: "My Customers",
description: "فهرست و جزئیات مشتریان",
title: "کاربران",
titleEn: "Users",
description: "فهرست، نقش و عضویت کاربران",
icon: <CustomersIcon />,
},
{
@@ -74,7 +74,7 @@ export default function HomePage() {
</span>
</h1>
<p className="mt-3 max-w-xl text-sm leading-7 text-muted sm:text-base">
از اینجا لباس‌ها، مشتریان، بلاگ، سفارش‌ها، وب‌سایت و پروفایل فروشگاه را
از اینجا لباس‌ها، کاربران، بلاگ، سفارش‌ها، وب‌سایت و پروفایل فروشگاه را
مدیریت کنید.
</p>
</div>
+2 -18
View File
@@ -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 (
<div
className={`dash-banner group relative mt-2 h-[150px] w-full overflow-hidden rounded-lg border border-line dash-enter ${className}`}
className={`dash-banner relative mt-2 h-[150px] w-full overflow-hidden rounded-lg border border-line dash-enter ${className}`}
style={style}
>
<Image
@@ -39,23 +35,11 @@ export default function DashboardBanner({
{caption ? (
<p
dir="ltr"
className="absolute bottom-5 start-5 z-[1] font-display text-lg text-white/95 transition-opacity duration-300 group-hover:opacity-0 sm:text-xl"
className="absolute bottom-5 start-5 z-[1] font-display text-lg text-white/95 sm:text-xl"
>
{caption}
</p>
) : null}
<div className="dash-banner-wipe" aria-hidden>
<div className="flex flex-col items-center gap-1.5 px-6 text-center">
<p className="text-base text-ink sm:text-lg">{hoverTitle}</p>
<p
dir="ltr"
className="font-display text-sm tracking-[0.08em] text-muted sm:text-base"
>
{hoverSubtitle}
</p>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -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: "سفارش‌های من" },
+10
View File
@@ -48,6 +48,16 @@ export default function IconButton({
);
}
export function RoleIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden>
<circle {...stroke} cx="9" cy="8" r="2.4" />
<path {...stroke} d="M4.5 17.5c.7-2.2 2.3-3.3 4.5-3.3s3.8 1.1 4.5 3.3" />
<path {...stroke} d="M15.5 9.5H20M17.75 7.25 20 9.5l-2.25 2.25" />
</svg>
);
}
export function EditIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden>
+17 -58
View File
@@ -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 "مشتری";
}
+41 -6
View File
@@ -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<ApiCustomer[]>("/customers");
return rows.map(mapCustomer);
}
export async function updateCustomer(
id: string,
body: { firstName: string; lastName: string; cellNumber: string },
) {
return mapCustomer(await api<ApiCustomer>(`/customers/${id}`, { method: "PATCH", body }));
}
export async function updateCustomerRole(id: string, role: UserRole) {
return mapCustomer(
await api<ApiCustomer>(`/customers/${id}/role`, { method: "PATCH", body: { role } }),
);
}
export async function updateCustomerMembership(
id: string,
membership: SubscriptionStatus,
) {
return mapCustomer(
await api<ApiCustomer>(`/customers/${id}/membership`, {
method: "PATCH",
body: { membership },
}),
);
}
export async function deleteCustomer(id: string) {
await api(`/customers/${id}`, { method: "DELETE" });
}