Add customer/manager access UI and tighten customers filters.

Business and super-admin can set Customer vs Manager roles (Admin only for super-admin), and the customers page drops clutter while filtering by all/customers/managers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-10 21:31:28 +03:30
co-authored by Cursor
parent 404c008ced
commit f622e6d605
28 changed files with 1280 additions and 241 deletions
+16 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus } from 'lucide-react'
import { FileText, Plus } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { BlogCard } from '../components/BlogCard'
import { BlogCommentsModal } from '../components/BlogCommentsModal'
@@ -180,7 +180,21 @@ export function BlogListPage() {
{isLoading ? (
<p className={styles.empty}>{t('blog.list.loading')}</p>
) : blogs.length === 0 ? (
<p className={styles.empty}>{t('blog.list.empty')}</p>
<div className={styles.emptyState}>
<div className={styles.emptyIcon} aria-hidden="true">
<FileText size={28} strokeWidth={1.75} />
</div>
<h3 className={styles.emptyTitle}>{t('blog.list.empty')}</h3>
<p className={styles.emptyHint}>{t('blog.list.emptyHint')}</p>
<button
type="button"
className={styles.emptyCta}
onClick={() => navigate('/blog/new')}
>
<Plus size={18} strokeWidth={2.25} />
{t('blog.list.emptyCta')}
</button>
</div>
) : (
<>
<div className={pageStyles.grid}>
@@ -8,6 +8,82 @@
border-radius: var(--radius);
}
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 56px 28px;
text-align: center;
background: var(--glass-bg);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
}
.emptyIcon {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 6px;
border-radius: 16px;
color: var(--primary);
background: linear-gradient(
145deg,
rgba(255, 255, 255, 0.72) 0%,
rgba(var(--primary-rgb) / 0.12) 100%
);
border: 1px solid rgba(255, 255, 255, 0.45);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
.emptyTitle {
margin: 0;
font-size: 17px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.4;
}
.emptyHint {
margin: 0 0 8px;
max-width: 360px;
font-size: 13px;
line-height: 1.65;
color: var(--text-secondary);
}
.emptyCta {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 10px 20px;
font-size: 14px;
font-weight: 600;
color: #fff;
text-align: center;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
border-radius: 999px;
box-shadow: 0 8px 20px rgba(var(--primary-rgb) / 0.32);
transition: transform 0.2s, box-shadow 0.2s;
}
button.emptyCta {
text-align: center;
}
.emptyCta:hover {
transform: translateY(-1px);
box-shadow: 0 12px 26px rgba(var(--primary-rgb) / 0.4);
}
.error {
margin-bottom: 16px;
padding: 12px 14px;
@@ -126,12 +126,14 @@
}
.badge[data-status='draft'] {
color: #fff;
color: #0f172a;
background: linear-gradient(
145deg,
rgba(251, 191, 36, 0.55) 0%,
rgba(245, 158, 11, 0.32) 100%
rgba(255, 255, 255, 0.78) 0%,
rgba(255, 255, 255, 0.42) 100%
);
border-color: rgba(255, 255, 255, 0.55);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
.badge[data-status='published'] {
@@ -1,3 +1,11 @@
.pageHeaderCompact {
margin-bottom: 12px;
}
.pageHeaderCompact :global(h2) {
margin-bottom: 0;
}
.tablePanel {
margin-top: 12px;
padding: 0;
@@ -61,7 +69,7 @@
}
.colActions {
width: 206px;
width: 240px;
}
.th,
@@ -132,10 +140,9 @@
align-items: center;
justify-content: flex-end;
gap: 0;
direction: ltr;
}
/* FA: عملیات column is on the physical left — pin controls to that edge. */
/* FA: عملیات column is on the physical left. */
.tableFa .thActions,
.tableFa .tdActions {
text-align: left;
@@ -143,12 +150,8 @@
padding-right: 10px;
}
.tableFa .rowActions {
justify-content: flex-start;
}
.toggleInActions {
margin-right: 10px;
margin-inline-end: 10px;
flex-shrink: 0;
display: flex;
align-items: center;
+117 -7
View File
@@ -1,28 +1,33 @@
import { useEffect, useMemo, useState } from 'react'
import { MessageSquare, Pencil, Plus, RotateCcw, Search, Ticket, Trash2 } from 'lucide-react'
import { MessageSquare, Pencil, Plus, RotateCcw, Search, Shield, Ticket, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { AddCustomerModal } from '../components/AddCustomerModal'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ChangeUserAccessModal } from '../components/ChangeUserAccessModal'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { EditCustomerModal } from '../components/EditCustomerModal'
import { Pagination } from '../components/Pagination'
import { ToggleSwitch } from '../components/ToggleSwitch'
import { Tooltip } from '../components/Tooltip'
import { useAuth } from '../context/AuthContext'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import { formatCellForDisplay } from '../lib/cellNumber'
import {
listCustomers,
removeCustomer,
updateCustomerEnabled,
type BusinessCustomerListItem,
type CustomerAccessFilter,
type CustomersListResponse,
} from '../services/customerService'
import { formatIrtPrice } from '../utils/irtPrice'
import { textLocaleAttrs } from '../utils/textLocale'
import filterStyles from '../components/ListFiltersPanel.module.css'
import pageStyles from '../components/PageContent.module.css'
import accessStyles from '../components/ChangeUserAccessModal.module.css'
import styles from './CustomersPage.module.css'
const PAGE_SIZE = 24
@@ -47,26 +52,66 @@ function formatTransactionTotal(total: number | null | undefined) {
return formatIrtPrice(total)
}
function accessBadgeKey(user: BusinessCustomerListItem): string | null {
if (user.isBusinessOwner) return 'customers.access.badge.owner'
if (user.teamRole === 'admin') return 'customers.access.badge.admin'
if (user.teamRole === 'editor') return 'customers.access.badge.editor'
if (user.teamRole === 'viewer') return 'customers.access.badge.viewer'
if (user.businessMemberId) return 'customers.access.badge.manager'
return null
}
export function CustomersPage() {
const t = useT()
const { locale } = useLocale()
const { user: authUser } = useAuth()
const { showToast } = useToast()
const [data, setData] = useState<CustomersListResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [appliedFilters, setAppliedFilters] = useState<{ name?: string; cellNumber?: string }>({})
const [appliedFilters, setAppliedFilters] = useState<{
name?: string
cellNumber?: string
access: CustomerAccessFilter
}>({ access: 'all' })
const [page, setPage] = useState(1)
const [draftName, setDraftName] = useState('')
const [draftCell, setDraftCell] = useState('')
const [draftAccess, setDraftAccess] = useState<CustomerAccessFilter>('all')
const [togglingId, setTogglingId] = useState<string | null>(null)
const [editTarget, setEditTarget] = useState<BusinessCustomerListItem | null>(null)
const [accessTarget, setAccessTarget] = useState<BusinessCustomerListItem | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [removeTarget, setRemoveTarget] = useState<BusinessCustomerListItem | null>(null)
const [removing, setRemoving] = useState(false)
const membership = useMemo(() => {
const activeId = getActiveBusinessId()
if (!authUser?.businesses.length) return undefined
return (
authUser.businesses.find((b) => String(b.id) === String(activeId)) ??
authUser.businesses[0]
)
}, [authUser])
const isSuperAdmin = Boolean(
authUser?.isSuperAdmin || authUser?.roles.includes('super_admin'),
)
const canManageAccess =
isSuperAdmin ||
Boolean(membership?.isOwner) ||
Boolean(membership?.permissions.includes('business.team.update'))
function accessChangeDisabledReason(customer: BusinessCustomerListItem): string | null {
if (customer.isBusinessOwner) return t('customers.access.ownerLocked')
if (customer.teamRole === 'admin' && !isSuperAdmin) {
return t('customers.access.adminLocked')
}
return null
}
useEffect(() => {
const controller = new AbortController()
@@ -97,7 +142,7 @@ export function CustomersPage() {
return () => {
controller.abort()
}
}, [page, appliedFilters.name, appliedFilters.cellNumber, t])
}, [page, appliedFilters.name, appliedFilters.cellNumber, appliedFilters.access, t])
const totalPages = useMemo(() => {
const total = data?.total ?? 0
@@ -117,6 +162,7 @@ export function CustomersPage() {
function applyFilters() {
setPage(1)
setAppliedFilters({
access: draftAccess,
...(draftName.trim() ? { name: draftName.trim() } : {}),
...(draftCell.trim() ? { cellNumber: draftCell.trim() } : {}),
})
@@ -125,8 +171,19 @@ export function CustomersPage() {
function clearFilters() {
setDraftName('')
setDraftCell('')
setDraftAccess('all')
setPage(1)
setAppliedFilters({})
setAppliedFilters({ access: 'all' })
}
function handleAccessFilterChange(value: CustomerAccessFilter) {
setDraftAccess(value)
setPage(1)
setAppliedFilters({
access: value,
...(draftName.trim() ? { name: draftName.trim() } : {}),
...(draftCell.trim() ? { cellNumber: draftCell.trim() } : {}),
})
}
async function handleToggleEnabled(customer: BusinessCustomerListItem, isEnabled: boolean) {
@@ -201,6 +258,22 @@ export function CustomersPage() {
showToast(t('customers.toast.updated', { name: displayName(updated) }), 'success')
}
function handleAccessSaved(updated: BusinessCustomerListItem) {
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) => (item.id === updated.id ? { ...item, ...updated } : item)),
}
})
const roleLabel = updated.isBusinessOwner
? t('customers.access.badge.owner')
: updated.teamRole
? t(`customers.access.role.${updated.teamRole}`)
: t('customers.access.customer')
showToast(t('customers.toast.accessUpdated', { role: roleLabel }), 'success')
}
function handleCustomerCreated(customer: BusinessCustomerListItem) {
setPage(1)
setData((prev) => {
@@ -234,17 +307,28 @@ export function CustomersPage() {
]}
/>
<div className={pageStyles.pageHeader}>
<div className={`${pageStyles.pageHeader} ${styles.pageHeaderCompact}`}>
<div>
<h2 className={pageStyles.pageTitle}>{t('customers.title')}</h2>
<p className={pageStyles.pageSubtitle}>{t('customers.subtitle')}</p>
</div>
</div>
<div className={filterStyles.filtersPanel}>
<div className={filterStyles.filtersTitle}>{t('customers.filters')}</div>
<form className={filterStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
<div className={filterStyles.filtersInputs}>
<div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}>
<select
id="filter-customer-access"
value={draftAccess}
onChange={(e) => handleAccessFilterChange(e.target.value as CustomerAccessFilter)}
aria-label={t('customers.filter.access')}
disabled={loading}
>
<option value="all">{t('customers.filter.access.all')}</option>
<option value="customers">{t('customers.filter.access.customers')}</option>
<option value="managers">{t('customers.filter.access.managers')}</option>
</select>
</div>
<div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}>
<input
id="filter-customer-name"
@@ -355,6 +439,8 @@ export function CustomersPage() {
data?.items?.map((customer) => {
const name = displayName(customer)
const nameLocale = textLocaleAttrs(name)
const badgeKey = accessBadgeKey(customer)
const accessLocked = accessChangeDisabledReason(customer)
return (
<tr
key={customer.id}
@@ -374,6 +460,9 @@ export function CustomersPage() {
>
{name}
</div>
{badgeKey ? (
<span className={accessStyles.roleBadge}>{t(badgeKey)}</span>
) : null}
{!customer.isEnabled && (
<div className={styles.statusDisabled}>{t('customers.disabled')}</div>
)}
@@ -417,6 +506,19 @@ export function CustomersPage() {
/>
</span>
</Tooltip>
{canManageAccess ? (
<Tooltip label={accessLocked ?? t('customers.access.change')}>
<button
type="button"
className={styles.actionBtn}
onClick={() => setAccessTarget(customer)}
aria-label={accessLocked ?? t('customers.access.change')}
disabled={Boolean(accessLocked) || removing}
>
<Shield size={15} />
</button>
</Tooltip>
) : null}
<Tooltip label={t('customers.edit')}>
<button
type="button"
@@ -499,6 +601,14 @@ export function CustomersPage() {
onSaved={handleCustomerSaved}
/>
<ChangeUserAccessModal
open={accessTarget !== null}
user={accessTarget}
canAssignAdmin={isSuperAdmin}
onClose={() => setAccessTarget(null)}
onSaved={handleAccessSaved}
/>
<ConfirmDeleteModal
open={removeTarget !== null}
title={t('customers.deleteTitle')}
+42 -24
View File
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useLocale } from '@meshkee/dashboard-ui'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ImageLightbox } from '../components/ImageLightbox'
import { ProductDetailsTabs } from '../components/ProductDetailsTabs'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
getProduct,
@@ -14,6 +16,9 @@ import pageStyles from '../components/PageContent.module.css'
import styles from './ProductDetailsPage.module.css'
export function ProductDetailsPage() {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const { id } = useParams()
const [product, setProduct] = useState<Product | null>(null)
const [isLoading, setIsLoading] = useState(true)
@@ -40,7 +45,7 @@ export function ProductDetailsPage() {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load product.')
setError(t('products.details.errorLoad'))
}
setProduct(null)
} finally {
@@ -50,7 +55,7 @@ export function ProductDetailsPage() {
void loadProduct()
return () => controller.abort()
}, [id])
}, [id, t])
const galleryImages = useMemo(() => product?.images ?? [], [product])
const currentImage = galleryImages[activeIndex] ?? galleryImages[0] ?? ''
@@ -63,7 +68,7 @@ export function ProductDetailsPage() {
if (isLoading) {
return (
<main className={pageStyles.content}>
<p className={styles.status}>Loading product...</p>
<p className={styles.status}>{t('products.details.loading')}</p>
</main>
)
}
@@ -71,16 +76,27 @@ export function ProductDetailsPage() {
if (error || !product) {
return (
<main className={pageStyles.content}>
<p className={styles.error}>{error || 'Product not found.'}</p>
<p className={styles.error}>{error || t('products.details.notFound')}</p>
<Link to="/products/list" className={styles.backLink}>
Back to My Products
{t('products.details.back')}
</Link>
</main>
)
}
const nameEnLocale = textLocaleAttrs(product.nameEn)
const nameFaLocale = textLocaleAttrs(product.nameFa)
const primaryName = isFa
? product.nameFa?.trim() || product.nameEn
: product.nameEn?.trim() || product.nameFa
const secondaryName = isFa
? product.nameEn?.trim() && product.nameEn !== primaryName
? product.nameEn
: ''
: product.nameFa?.trim() && product.nameFa !== primaryName
? product.nameFa
: ''
const primaryLocale = textLocaleAttrs(primaryName)
const secondaryLocale = textLocaleAttrs(secondaryName)
const summaryLocale = textLocaleAttrs(product.summary)
const descriptionLocale = textLocaleAttrs(
product.description?.replace(/<[^>]+>/g, ' ') ?? '',
@@ -94,7 +110,7 @@ export function ProductDetailsPage() {
{ label: 'Dashboard', href: '/' },
{ label: 'Products', href: '/products' },
{ label: 'My Products', href: '/products/list' },
{ label: product.nameEn },
{ label: primaryName },
]}
/>
@@ -104,15 +120,17 @@ export function ProductDetailsPage() {
type="button"
className={styles.mainImage}
onClick={() => currentImage && openLightbox(activeIndex)}
aria-label="Open image gallery"
aria-label={t('products.details.openGallery')}
disabled={!currentImage}
>
{currentImage ? (
<img src={currentImage} alt={product.nameEn} />
<img src={currentImage} alt={primaryName} />
) : (
<div className={styles.imagePlaceholder}>No image</div>
<div className={styles.imagePlaceholder}>{t('products.details.noImage')}</div>
)}
{product.status === 'draft' && (
<span className={styles.draftBadge}>{t('products.card.draft')}</span>
)}
{product.status === 'draft' && <span className={styles.draftBadge}>Draft</span>}
</button>
{galleryImages.length > 1 && (
@@ -126,7 +144,7 @@ export function ProductDetailsPage() {
setActiveIndex(index)
openLightbox(index)
}}
aria-label={`View image ${index + 1}`}
aria-label={t('products.details.viewImage', { index: index + 1 })}
>
<img src={src} alt="" />
</button>
@@ -147,21 +165,21 @@ export function ProductDetailsPage() {
)}
<h1
className={[styles.nameEn, nameEnLocale.className].filter(Boolean).join(' ')}
lang={nameEnLocale.lang}
dir={nameEnLocale.dir}
className={[styles.nameEn, primaryLocale.className].filter(Boolean).join(' ')}
lang={primaryLocale.lang}
dir={primaryLocale.dir}
>
{product.nameEn}
{primaryName}
</h1>
{product.nameFa && (
{secondaryName ? (
<p
className={[styles.nameFa, nameFaLocale.className].filter(Boolean).join(' ')}
lang={nameFaLocale.lang}
dir={nameFaLocale.dir}
className={[styles.nameFa, secondaryLocale.className].filter(Boolean).join(' ')}
lang={secondaryLocale.lang}
dir={secondaryLocale.dir}
>
{product.nameFa}
{secondaryName}
</p>
)}
) : null}
{product.summary && (
<p
@@ -210,7 +228,7 @@ export function ProductDetailsPage() {
open={lightboxOpen}
images={galleryImages}
initialIndex={lightboxIndex}
alt={product.nameEn}
alt={primaryName}
onClose={() => setLightboxOpen(false)}
/>
</main>