mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-12 06:40:57 +04:30
Polish business FA layout and wire special-group keys plus SSL sync.
RTL carousels/FABs, special key+title fields, slider gallery fixes, and dashboard SSL sync agent for super-admin. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
66004a0fba
commit
672091d1f5
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { MessageSquare, Pencil, Plus, RotateCcw, Search, Ticket, Trash2 } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { AddCustomerModal } from '../components/AddCustomerModal'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
@@ -8,6 +9,7 @@ import { Pagination } from '../components/Pagination'
|
||||
import { ToggleSwitch } from '../components/ToggleSwitch'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||
import {
|
||||
@@ -24,12 +26,13 @@ import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './CustomersPage.module.css'
|
||||
|
||||
const PAGE_SIZE = 24
|
||||
const COLUMN_COUNT = 7
|
||||
const COLUMN_COUNT = 6
|
||||
|
||||
function formatDate(value: string) {
|
||||
function formatDate(value: string, locale: 'en' | 'fa') {
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return value
|
||||
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })
|
||||
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
|
||||
return d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' })
|
||||
}
|
||||
|
||||
function displayName(customer: BusinessCustomerListItem) {
|
||||
@@ -37,13 +40,6 @@ function displayName(customer: BusinessCustomerListItem) {
|
||||
return name || '—'
|
||||
}
|
||||
|
||||
function formatOrderCount(count: number | null | undefined) {
|
||||
if (count == null || count === 0) {
|
||||
return <span className={styles.subText}>No order yet</span>
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function formatTransactionTotal(total: number | null | undefined) {
|
||||
if (total == null || total === 0) {
|
||||
return <span className={styles.subText}>—</span>
|
||||
@@ -52,6 +48,8 @@ function formatTransactionTotal(total: number | null | undefined) {
|
||||
}
|
||||
|
||||
export function CustomersPage() {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const { showToast } = useToast()
|
||||
const [data, setData] = useState<CustomersListResponse | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -88,7 +86,7 @@ export function CustomersPage() {
|
||||
setData(result)
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || controller.signal.aborted) return
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load customers.')
|
||||
setError(err instanceof ApiError ? err.message : t('customers.error.load'))
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
@@ -99,7 +97,7 @@ export function CustomersPage() {
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
}, [page, appliedFilters.name, appliedFilters.cellNumber])
|
||||
}, [page, appliedFilters.name, appliedFilters.cellNumber, t])
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
const total = data?.total ?? 0
|
||||
@@ -147,12 +145,12 @@ export function CustomersPage() {
|
||||
})
|
||||
showToast(
|
||||
isEnabled
|
||||
? `"${displayName(customer)}" has been enabled.`
|
||||
: `"${displayName(customer)}" has been disabled.`,
|
||||
? t('customers.toast.enabled', { name: displayName(customer) })
|
||||
: t('customers.toast.disabled', { name: displayName(customer) }),
|
||||
'success',
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to update customer.')
|
||||
setError(err instanceof ApiError ? err.message : t('customers.error.update'))
|
||||
} finally {
|
||||
setTogglingId(null)
|
||||
}
|
||||
@@ -172,21 +170,24 @@ export function CustomersPage() {
|
||||
items: prev.items.filter((item) => item.id !== removeTarget.id),
|
||||
}
|
||||
})
|
||||
showToast(`"${displayName(removeTarget)}" has been removed.`, 'success')
|
||||
showToast(t('customers.toast.removed', { name: displayName(removeTarget) }), 'success')
|
||||
setRemoveTarget(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to remove customer.')
|
||||
setError(err instanceof ApiError ? err.message : t('customers.error.remove'))
|
||||
} finally {
|
||||
setRemoving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSendSms(customer: BusinessCustomerListItem) {
|
||||
showToast(`SMS to ${formatCellForDisplay(customer.cellNumber)} is not available yet.`, 'info')
|
||||
showToast(
|
||||
t('customers.toast.smsSoon', { phone: formatCellForDisplay(customer.cellNumber) }),
|
||||
'info',
|
||||
)
|
||||
}
|
||||
|
||||
function handleTickets(customer: BusinessCustomerListItem) {
|
||||
showToast(`Tickets for "${displayName(customer)}" are not available yet.`, 'info')
|
||||
showToast(t('customers.toast.ticketsSoon', { name: displayName(customer) }), 'info')
|
||||
}
|
||||
|
||||
function handleCustomerSaved(updated: BusinessCustomerListItem) {
|
||||
@@ -197,7 +198,7 @@ export function CustomersPage() {
|
||||
items: prev.items.map((item) => (item.id === updated.id ? updated : item)),
|
||||
}
|
||||
})
|
||||
showToast(`"${displayName(updated)}" has been updated.`, 'success')
|
||||
showToast(t('customers.toast.updated', { name: displayName(updated) }), 'success')
|
||||
}
|
||||
|
||||
function handleCustomerCreated(customer: BusinessCustomerListItem) {
|
||||
@@ -221,7 +222,7 @@ export function CustomersPage() {
|
||||
),
|
||||
}
|
||||
})
|
||||
showToast(`"${displayName(customer)}" has been added.`, 'success')
|
||||
showToast(t('customers.toast.added', { name: displayName(customer) }), 'success')
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -235,33 +236,31 @@ export function CustomersPage() {
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>Customers</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
View customers who have registered or ordered from your business.
|
||||
</p>
|
||||
<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}>Filters</div>
|
||||
<div className={filterStyles.filtersTitle}>{t('customers.filters')}</div>
|
||||
<div className={filterStyles.filtersGrid}>
|
||||
<div className={filterStyles.filtersInputs}>
|
||||
<div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}>
|
||||
<label htmlFor="filter-customer-name">Name</label>
|
||||
<input
|
||||
id="filter-customer-name"
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
placeholder="Search by name"
|
||||
placeholder={t('customers.filter.name')}
|
||||
aria-label={t('customers.filter.name')}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${filterStyles.field} ${styles.filterCellNarrow}`}>
|
||||
<label htmlFor="filter-customer-cell">Cell number</label>
|
||||
<input
|
||||
id="filter-customer-cell"
|
||||
value={draftCell}
|
||||
onChange={(e) => setDraftCell(e.target.value)}
|
||||
placeholder="0912..."
|
||||
placeholder={t('customers.filter.cell')}
|
||||
aria-label={t('customers.filter.cell')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
@@ -272,8 +271,8 @@ export function CustomersPage() {
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
||||
onClick={applyFilters}
|
||||
disabled={loading}
|
||||
aria-label="Search"
|
||||
title="Search"
|
||||
aria-label={t('customers.search')}
|
||||
title={t('customers.search')}
|
||||
>
|
||||
<Search size={18} />
|
||||
</button>
|
||||
@@ -282,8 +281,8 @@ export function CustomersPage() {
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
||||
onClick={clearFilters}
|
||||
disabled={loading}
|
||||
aria-label="Clear filters"
|
||||
title="Clear filters"
|
||||
aria-label={t('customers.clearFilters')}
|
||||
title={t('customers.clearFilters')}
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
@@ -294,15 +293,17 @@ export function CustomersPage() {
|
||||
<div className={styles.tablePanel}>
|
||||
<div className={styles.tableWrap}>
|
||||
<div className={styles.tableHeader}>
|
||||
<div className={styles.tableHeaderTitle}>Customer list</div>
|
||||
<div className={styles.tableHeaderTitle}>{t('customers.listTitle')}</div>
|
||||
<div className={styles.meta}>
|
||||
{data ? (
|
||||
data.total > 0 ? (
|
||||
<>
|
||||
Showing {showingFrom} - {showingTo} of {data.total}
|
||||
</>
|
||||
t('customers.showing', {
|
||||
from: showingFrom,
|
||||
to: showingTo,
|
||||
total: data.total,
|
||||
})
|
||||
) : (
|
||||
'No customers'
|
||||
t('customers.none')
|
||||
)
|
||||
) : (
|
||||
' '
|
||||
@@ -312,11 +313,13 @@ export function CustomersPage() {
|
||||
|
||||
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||
|
||||
<table className={styles.table}>
|
||||
<table
|
||||
className={`${styles.table}${locale === 'fa' ? ` ${styles.tableFa}` : ''}`}
|
||||
dir={locale === 'fa' ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<colgroup>
|
||||
<col className={styles.colName} />
|
||||
<col className={styles.colCell} />
|
||||
<col className={styles.colEmail} />
|
||||
<col className={styles.colOrders} />
|
||||
<col className={styles.colTransactions} />
|
||||
<col className={styles.colDate} />
|
||||
@@ -324,123 +327,133 @@ export function CustomersPage() {
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.th}>Name</th>
|
||||
<th className={styles.th}>Cell number</th>
|
||||
<th className={styles.th}>Email</th>
|
||||
<th className={styles.th}>Orders</th>
|
||||
<th className={styles.th}>Transactions</th>
|
||||
<th className={styles.th}>Date joined</th>
|
||||
<th className={`${styles.th} ${styles.thActions}`}>Actions</th>
|
||||
<th className={styles.th}>{t('customers.col.name')}</th>
|
||||
<th className={styles.th}>{t('customers.col.cell')}</th>
|
||||
<th className={styles.th}>{t('customers.col.orders')}</th>
|
||||
<th className={styles.th}>{t('customers.col.transactions')}</th>
|
||||
<th className={styles.th}>{t('customers.col.date')}</th>
|
||||
<th className={`${styles.th} ${styles.thActions}`}>{t('customers.col.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{loading && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
{t('customers.loading')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!loading && data?.items?.length === 0 && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
No results found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && data?.items?.length === 0 && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
{t('customers.empty')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
data?.items?.map((customer) => (
|
||||
{!loading &&
|
||||
data?.items?.map((customer) => {
|
||||
const name = displayName(customer)
|
||||
const nameLocale = textLocaleAttrs(name)
|
||||
return (
|
||||
<tr
|
||||
key={customer.id}
|
||||
className={!customer.isEnabled ? styles.inactiveRow : undefined}
|
||||
>
|
||||
<td className={styles.td}>
|
||||
{(() => {
|
||||
const name = displayName(customer)
|
||||
const locale = textLocaleAttrs(name)
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
styles.customerName,
|
||||
locale.className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
lang={locale.lang}
|
||||
dir={locale.dir}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div
|
||||
className={[
|
||||
styles.customerName,
|
||||
nameLocale.className,
|
||||
locale === 'fa' ? styles.nameAlignFa : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
lang={nameLocale.lang}
|
||||
dir={nameLocale.dir}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
{!customer.isEnabled && (
|
||||
<div className={styles.statusDisabled}>Disabled</div>
|
||||
<div className={styles.statusDisabled}>{t('customers.disabled')}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className={styles.td}>{formatCellForDisplay(customer.cellNumber)}</td>
|
||||
<td className={`${styles.td} ${styles.emailCell}`}>
|
||||
{customer.email ?? <span className={styles.subText}>—</span>}
|
||||
<td className={styles.td}>
|
||||
{customer.orderCount == null || customer.orderCount === 0 ? (
|
||||
<span className={styles.subText}>{t('customers.noOrders')}</span>
|
||||
) : (
|
||||
customer.orderCount
|
||||
)}
|
||||
</td>
|
||||
<td className={styles.td}>{formatOrderCount(customer.orderCount)}</td>
|
||||
<td className={styles.td}>
|
||||
{formatTransactionTotal(customer.totalTransactionsIrt)}
|
||||
</td>
|
||||
<td className={`${styles.td} ${styles.dateCell}`}>
|
||||
{formatDate(customer.createdAt)}
|
||||
{formatDate(customer.createdAt, locale)}
|
||||
</td>
|
||||
<td className={`${styles.td} ${styles.tdActions}`}>
|
||||
<div className={styles.rowActions}>
|
||||
<Tooltip
|
||||
label={`${customer.isEnabled ? 'Disable' : 'Enable'} customer`}
|
||||
label={
|
||||
customer.isEnabled
|
||||
? t('customers.disable')
|
||||
: t('customers.enable')
|
||||
}
|
||||
>
|
||||
<span className={styles.toggleInActions}>
|
||||
<ToggleSwitch
|
||||
checked={customer.isEnabled}
|
||||
disabled={togglingId === customer.id || removing}
|
||||
size="compact"
|
||||
ariaLabel={`${customer.isEnabled ? 'Disable' : 'Enable'} ${displayName(customer)}`}
|
||||
onChange={(isEnabled) => void handleToggleEnabled(customer, isEnabled)}
|
||||
ariaLabel={
|
||||
customer.isEnabled
|
||||
? t('customers.disableAria', { name })
|
||||
: t('customers.enableAria', { name })
|
||||
}
|
||||
onChange={(isEnabled) =>
|
||||
void handleToggleEnabled(customer, isEnabled)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label="Edit customer">
|
||||
<Tooltip label={t('customers.edit')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionBtn}
|
||||
onClick={() => setEditTarget(customer)}
|
||||
aria-label="Edit customer"
|
||||
aria-label={t('customers.edit')}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Send SMS">
|
||||
<Tooltip label={t('customers.sms')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionBtn}
|
||||
onClick={() => handleSendSms(customer)}
|
||||
aria-label="Send SMS"
|
||||
aria-label={t('customers.sms')}
|
||||
>
|
||||
<MessageSquare size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Tickets">
|
||||
<Tooltip label={t('customers.tickets')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionBtn}
|
||||
onClick={() => handleTickets(customer)}
|
||||
aria-label="Tickets"
|
||||
aria-label={t('customers.tickets')}
|
||||
>
|
||||
<Ticket size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove customer">
|
||||
<Tooltip label={t('customers.remove')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
|
||||
onClick={() => setRemoveTarget(customer)}
|
||||
aria-label="Remove customer"
|
||||
aria-label={t('customers.remove')}
|
||||
disabled={removing}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
@@ -449,13 +462,19 @@ export function CustomersPage() {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className={styles.pagination}>
|
||||
<div>
|
||||
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
|
||||
{t('customers.pageMeta', {
|
||||
page,
|
||||
totalPages,
|
||||
pageSize: PAGE_SIZE,
|
||||
total: data?.total ?? 0,
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
@@ -483,10 +502,10 @@ export function CustomersPage() {
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={removeTarget !== null}
|
||||
title="Remove customer?"
|
||||
title={t('customers.deleteTitle')}
|
||||
message={
|
||||
removeTarget
|
||||
? `Remove "${displayName(removeTarget)}" from your business? Their account will not be deleted.`
|
||||
? t('customers.deleteMessage', { name: displayName(removeTarget) })
|
||||
: ''
|
||||
}
|
||||
onCancel={() => setRemoveTarget(null)}
|
||||
@@ -497,7 +516,7 @@ export function CustomersPage() {
|
||||
type="button"
|
||||
className={styles.fab}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
aria-label="Add customer"
|
||||
aria-label={t('customers.add')}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user