Initial commit: Meshkee dashboards monorepo.

Includes business, customer, and super-admin apps with shared packages and production deploy scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-07-22 13:48:53 +03:30
co-authored by Cursor
commit f566387c61
509 changed files with 62690 additions and 0 deletions
+515
View File
@@ -0,0 +1,515 @@
import { useEffect, useMemo, useState } from 'react'
import { MessageSquare, Pencil, Plus, RotateCcw, Search, Ticket, Trash2 } from 'lucide-react'
import { AddCustomerModal } from '../components/AddCustomerModal'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { EditCustomerModal } from '../components/EditCustomerModal'
import { ToggleSwitch } from '../components/ToggleSwitch'
import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext'
import { ApiError, isAbortError } from '../lib/api'
import { formatCellForDisplay } from '../lib/cellNumber'
import {
listCustomers,
removeCustomer,
updateCustomerEnabled,
type BusinessCustomerListItem,
type CustomersListResponse,
} from '../services/customerService'
import { formatIrtPrice } from '../utils/irtPrice'
import filterStyles from '../components/ListFiltersPanel.module.css'
import pageStyles from '../components/PageContent.module.css'
import styles from './CustomersPage.module.css'
const PAGE_SIZE = 24
const COLUMN_COUNT = 7
function formatDate(value: string) {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })
}
function buildPageNumbers(page: number, totalPages: number) {
const start = Math.max(1, page - 2)
const end = Math.min(totalPages, page + 2)
const numbers: number[] = []
for (let i = start; i <= end; i++) numbers.push(i)
return numbers
}
function displayName(customer: BusinessCustomerListItem) {
const name = [customer.firstName, customer.lastName].filter(Boolean).join(' ').trim()
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>
}
return formatIrtPrice(total)
}
export function CustomersPage() {
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 [page, setPage] = useState(1)
const [draftName, setDraftName] = useState('')
const [draftCell, setDraftCell] = useState('')
const [togglingId, setTogglingId] = useState<string | null>(null)
const [editTarget, setEditTarget] = useState<BusinessCustomerListItem | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [removeTarget, setRemoveTarget] = useState<BusinessCustomerListItem | null>(null)
const [removing, setRemoving] = useState(false)
useEffect(() => {
const controller = new AbortController()
async function load() {
setLoading(true)
setError('')
try {
const result = await listCustomers(
{
page,
pageSize: PAGE_SIZE,
...appliedFilters,
},
controller.signal,
)
if (controller.signal.aborted) return
setData(result)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
setError(err instanceof ApiError ? err.message : 'Unable to load customers.')
} finally {
if (!controller.signal.aborted) setLoading(false)
}
}
void load()
return () => {
controller.abort()
}
}, [page, appliedFilters.name, appliedFilters.cellNumber])
const totalPages = useMemo(() => {
const total = data?.total ?? 0
return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => {
if (!data || data.total === 0) return 0
return (page - 1) * PAGE_SIZE + 1
}, [data, page])
const showingTo = useMemo(() => {
if (!data) return 0
return Math.min(data.total, page * PAGE_SIZE)
}, [data, page])
function applyFilters() {
setPage(1)
setAppliedFilters({
...(draftName.trim() ? { name: draftName.trim() } : {}),
...(draftCell.trim() ? { cellNumber: draftCell.trim() } : {}),
})
}
function clearFilters() {
setDraftName('')
setDraftCell('')
setPage(1)
setAppliedFilters({})
}
async function handleToggleEnabled(customer: BusinessCustomerListItem, isEnabled: boolean) {
setTogglingId(customer.id)
setError('')
try {
await updateCustomerEnabled(customer.id, isEnabled)
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === customer.id ? { ...item, isEnabled } : item,
),
}
})
showToast(
isEnabled
? `"${displayName(customer)}" has been enabled.`
: `"${displayName(customer)}" has been disabled.`,
'success',
)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update customer.')
} finally {
setTogglingId(null)
}
}
async function confirmRemove() {
if (!removeTarget) return
setRemoving(true)
setError('')
try {
await removeCustomer(removeTarget.id)
setData((prev) => {
if (!prev) return prev
return {
...prev,
total: Math.max(0, prev.total - 1),
items: prev.items.filter((item) => item.id !== removeTarget.id),
}
})
showToast(`"${displayName(removeTarget)}" has been removed.`, 'success')
setRemoveTarget(null)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to remove customer.')
} finally {
setRemoving(false)
}
}
function handleSendSms(customer: BusinessCustomerListItem) {
showToast(`SMS to ${formatCellForDisplay(customer.cellNumber)} is not available yet.`, 'info')
}
function handleTickets(customer: BusinessCustomerListItem) {
showToast(`Tickets for "${displayName(customer)}" are not available yet.`, 'info')
}
function handleCustomerSaved(updated: BusinessCustomerListItem) {
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) => (item.id === updated.id ? updated : item)),
}
})
showToast(`"${displayName(updated)}" has been updated.`, 'success')
}
function handleCustomerCreated(customer: BusinessCustomerListItem) {
setPage(1)
setData((prev) => {
if (!prev) {
return {
items: [customer],
total: 1,
page: 1,
pageSize: PAGE_SIZE,
}
}
return {
...prev,
total: prev.total + 1,
page: 1,
items: [customer, ...prev.items.filter((item) => item.id !== customer.id)].slice(
0,
PAGE_SIZE,
),
}
})
showToast(`"${displayName(customer)}" has been added.`, 'success')
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Customers' },
]}
/>
<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>
</div>
</div>
<div className={filterStyles.filtersPanel}>
<div className={filterStyles.filtersTitle}>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"
/>
</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..."
autoComplete="off"
/>
</div>
</div>
<div className={filterStyles.filterActions}>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters}
disabled={loading}
aria-label="Search"
title="Search"
>
<Search size={18} />
</button>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters}
disabled={loading}
aria-label="Clear filters"
title="Clear filters"
>
<RotateCcw size={18} />
</button>
</div>
</div>
</div>
<div className={styles.tablePanel}>
<div className={styles.tableWrap}>
<div className={styles.tableHeader}>
<div className={styles.tableHeaderTitle}>Customer list</div>
<div className={styles.meta}>
{data ? (
data.total > 0 ? (
<>
Showing {showingFrom} - {showingTo} of {data.total}
</>
) : (
'No customers'
)
) : (
' '
)}
</div>
</div>
{error && <div className={styles.errorBanner}>{error}</div>}
<table className={styles.table}>
<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} />
<col className={styles.colActions} />
</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>
</tr>
</thead>
<tbody>
{loading && (
<tr>
<td className={styles.td} colSpan={COLUMN_COUNT}>
Loading...
</td>
</tr>
)}
{!loading && data?.items?.length === 0 && (
<tr>
<td className={styles.td} colSpan={COLUMN_COUNT}>
No results found.
</td>
</tr>
)}
{!loading &&
data?.items?.map((customer) => (
<tr
key={customer.id}
className={!customer.isEnabled ? styles.inactiveRow : undefined}
>
<td className={styles.td}>
<div className={styles.customerName}>{displayName(customer)}</div>
{!customer.isEnabled && (
<div className={styles.statusDisabled}>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>
<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)}
</td>
<td className={`${styles.td} ${styles.tdActions}`}>
<div className={styles.rowActions}>
<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)}
/>
</span>
<Tooltip label="Edit customer">
<button
type="button"
className={styles.actionBtn}
onClick={() => setEditTarget(customer)}
aria-label="Edit customer"
>
<Pencil size={15} />
</button>
</Tooltip>
<Tooltip label="Send SMS">
<button
type="button"
className={styles.actionBtn}
onClick={() => handleSendSms(customer)}
aria-label="Send SMS"
>
<MessageSquare size={15} />
</button>
</Tooltip>
<Tooltip label="Tickets">
<button
type="button"
className={styles.actionBtn}
onClick={() => handleTickets(customer)}
aria-label="Tickets"
>
<Ticket size={15} />
</button>
</Tooltip>
<Tooltip label="Remove customer">
<button
type="button"
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
onClick={() => setRemoveTarget(customer)}
aria-label="Remove customer"
disabled={removing}
>
<Trash2 size={15} />
</button>
</Tooltip>
</div>
</td>
</tr>
))}
</tbody>
</table>
<div className={styles.pagination}>
<div>
Page {page} / {totalPages}
</div>
<div className={styles.pagerBtns}>
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
Prev
</button>
{pageNumbers.map((n) => (
<button
key={n}
type="button"
className={`${styles.pageBtn} ${n === page ? styles.pageBtnActive : ''}`}
onClick={() => setPage(n)}
disabled={loading}
>
{n}
</button>
))}
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
Next
</button>
</div>
</div>
</div>
</div>
<AddCustomerModal
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={handleCustomerCreated}
/>
<EditCustomerModal
open={editTarget !== null}
customer={editTarget}
onClose={() => setEditTarget(null)}
onSaved={handleCustomerSaved}
/>
<ConfirmDeleteModal
open={removeTarget !== null}
title="Remove customer?"
message={
removeTarget
? `Remove "${displayName(removeTarget)}" from your business? Their account will not be deleted.`
: ''
}
onCancel={() => setRemoveTarget(null)}
onConfirm={() => void confirmRemove()}
/>
<button
type="button"
className={styles.fab}
onClick={() => setCreateOpen(true)}
aria-label="Add customer"
>
<Plus size={24} />
</button>
</main>
)
}