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>