mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
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:
@@ -0,0 +1,977 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
AlertTriangle,
|
||||
Globe,
|
||||
Lock,
|
||||
Pencil,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Trash2,
|
||||
UserCog,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { ToggleSwitch } from '../components/ToggleSwitch'
|
||||
import { formatCellForDisplay, toE164CellNumber } from '../lib/cellNumber'
|
||||
import type { BusinessesListResponse, BusinessListItem } from '../types/business'
|
||||
import type { BusinessCategory } from '../types/category'
|
||||
import type { ListBusinessesParams } from '../services/businessService'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
addBusinessDomain,
|
||||
createBusiness,
|
||||
listBusinesses,
|
||||
removeBusiness,
|
||||
setBusinessActive,
|
||||
updateBusiness,
|
||||
updateBusinessDomain,
|
||||
} from '../services/businessService'
|
||||
import { listBusinessCategories } from '../services/categoryService'
|
||||
import { Modal } from '../components/Modal'
|
||||
import { MultiSelectDropdown } from '../components/MultiSelectDropdown'
|
||||
import { PrimaryColorPicker } from '../components/PrimaryColorPicker'
|
||||
import { PrimaryColorSwatchControl } from '../components/PrimaryColorSwatchControl'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import {
|
||||
DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
|
||||
normalizeBusinessPrimaryColorId,
|
||||
type BusinessPrimaryColorId,
|
||||
} from '../utils/businessPrimaryColors'
|
||||
import {
|
||||
getBusinessSettings,
|
||||
updateBusinessPrimaryColor,
|
||||
} from '../services/businessSettingsService'
|
||||
import { flattenBusinessCategories } from '../utils/categories'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './BusinessesPage.module.css'
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function BusinessesPage() {
|
||||
const { showToast } = useToast()
|
||||
const navigate = useNavigate()
|
||||
const [data, setData] = useState<BusinessesListResponse | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [categories, setCategories] = useState<BusinessCategory[]>([])
|
||||
|
||||
const [appliedFilters, setAppliedFilters] = useState<Omit<ListBusinessesParams, 'page' | 'pageSize'>>(
|
||||
{},
|
||||
)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
|
||||
const [draftName, setDraftName] = useState('')
|
||||
const [draftDomain, setDraftDomain] = useState('')
|
||||
const [draftCategory, setDraftCategory] = useState('')
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editBusiness, setEditBusiness] = useState<BusinessListItem | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editPrimaryColor, setEditPrimaryColor] = useState<BusinessPrimaryColorId>(
|
||||
DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
|
||||
)
|
||||
const [editLoadingSettings, setEditLoadingSettings] = useState(false)
|
||||
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||
|
||||
const [domainOpen, setDomainOpen] = useState(false)
|
||||
const [domainBusiness, setDomainBusiness] = useState<BusinessListItem | null>(null)
|
||||
const [domainId, setDomainId] = useState<number | null>(null)
|
||||
const [domainHost, setDomainHost] = useState('')
|
||||
const [domainSubmitting, setDomainSubmitting] = useState(false)
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createName, setCreateName] = useState('')
|
||||
const [createNameFa, setCreateNameFa] = useState('')
|
||||
const [createAbout, setCreateAbout] = useState('')
|
||||
const [createCategoryIds, setCreateCategoryIds] = useState<number[]>([])
|
||||
const [createOwnerFirstName, setCreateOwnerFirstName] = useState('')
|
||||
const [createOwnerLastName, setCreateOwnerLastName] = useState('')
|
||||
const [createOwnerCell, setCreateOwnerCell] = useState('')
|
||||
const [createOwnerPassword, setCreateOwnerPassword] = useState('')
|
||||
const [createSubmitting, setCreateSubmitting] = useState(false)
|
||||
|
||||
const [removeTarget, setRemoveTarget] = useState<BusinessListItem | null>(null)
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null)
|
||||
const [savingColorId, setSavingColorId] = useState<string | null>(null)
|
||||
|
||||
async function fetchList() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await listBusinesses({
|
||||
page,
|
||||
pageSize,
|
||||
...appliedFilters,
|
||||
})
|
||||
setData(result)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load businesses.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [result, categoryResult] = await Promise.all([
|
||||
listBusinesses(
|
||||
{
|
||||
page,
|
||||
pageSize,
|
||||
...appliedFilters,
|
||||
},
|
||||
controller.signal,
|
||||
),
|
||||
listBusinessCategories(controller.signal),
|
||||
])
|
||||
if (controller.signal.aborted) return
|
||||
setData(result)
|
||||
setCategories(categoryResult.items)
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || controller.signal.aborted) return
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load businesses.')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
}, [page, pageSize, appliedFilters.name, appliedFilters.domain, appliedFilters.category])
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
const total = data?.total ?? 0
|
||||
return Math.max(1, Math.ceil(total / pageSize))
|
||||
}, [data?.total, pageSize])
|
||||
|
||||
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
|
||||
|
||||
const showingFrom = useMemo(() => {
|
||||
if (!data) return 0
|
||||
return (page - 1) * pageSize + 1
|
||||
}, [data, page, pageSize])
|
||||
|
||||
const showingTo = useMemo(() => {
|
||||
if (!data) return 0
|
||||
return Math.min(data.total, page * pageSize)
|
||||
}, [data, page, pageSize])
|
||||
|
||||
const categoryOptions = useMemo(() => flattenBusinessCategories(categories), [categories])
|
||||
|
||||
function applyFilters() {
|
||||
setPage(1)
|
||||
setAppliedFilters({
|
||||
...(draftName.trim() ? { name: draftName.trim() } : {}),
|
||||
...(draftDomain.trim() ? { domain: draftDomain.trim() } : {}),
|
||||
...(draftCategory.trim() ? { category: draftCategory.trim() } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
setDraftName('')
|
||||
setDraftDomain('')
|
||||
setDraftCategory('')
|
||||
setPage(1)
|
||||
setAppliedFilters({})
|
||||
}
|
||||
|
||||
function openEdit(b: BusinessListItem) {
|
||||
setEditBusiness(b)
|
||||
setEditName(b.name)
|
||||
setEditPrimaryColor(normalizeBusinessPrimaryColorId(b.primaryColor))
|
||||
setEditOpen(true)
|
||||
setEditLoadingSettings(true)
|
||||
|
||||
const controller = new AbortController()
|
||||
void getBusinessSettings(b.id, controller.signal)
|
||||
.then((data) => {
|
||||
setEditPrimaryColor(data.settings.branding.primaryColor)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load business theme.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setEditLoadingSettings(false)
|
||||
})
|
||||
}
|
||||
|
||||
function goToBusinessUsers(b: BusinessListItem, membership: 'staff' | 'all') {
|
||||
const params = new URLSearchParams({
|
||||
businessId: String(b.id),
|
||||
membership,
|
||||
businessName: b.name,
|
||||
})
|
||||
navigate(`/users?${params.toString()}`)
|
||||
}
|
||||
|
||||
async function handlePrimaryColorChange(
|
||||
b: BusinessListItem,
|
||||
primaryColor: BusinessPrimaryColorId,
|
||||
) {
|
||||
const currentColor = normalizeBusinessPrimaryColorId(b.primaryColor)
|
||||
if (currentColor === primaryColor) return
|
||||
|
||||
setSavingColorId(b.id)
|
||||
setError('')
|
||||
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === b.id ? { ...item, primaryColor } : item,
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
if (editBusiness?.id === b.id) {
|
||||
setEditPrimaryColor(primaryColor)
|
||||
}
|
||||
|
||||
try {
|
||||
await updateBusinessPrimaryColor(b.id, primaryColor)
|
||||
showToast(`Theme updated for "${b.name}".`, 'success')
|
||||
} catch (err) {
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === b.id ? { ...item, primaryColor: currentColor } : item,
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
if (editBusiness?.id === b.id) {
|
||||
setEditPrimaryColor(currentColor)
|
||||
}
|
||||
|
||||
showToast(
|
||||
err instanceof ApiError ? err.message : 'Unable to update theme.',
|
||||
'error',
|
||||
)
|
||||
} finally {
|
||||
setSavingColorId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editBusiness) return
|
||||
setEditSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
await updateBusiness(editBusiness.id, { name: editName })
|
||||
await updateBusinessPrimaryColor(editBusiness.id, editPrimaryColor)
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === editBusiness.id
|
||||
? { ...item, name: editName.trim(), primaryColor: editPrimaryColor }
|
||||
: item,
|
||||
),
|
||||
}
|
||||
})
|
||||
setEditOpen(false)
|
||||
setEditBusiness(null)
|
||||
showToast('Business updated.', 'success')
|
||||
await fetchList()
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to update business.')
|
||||
} finally {
|
||||
setEditSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openDomainEdit(b: BusinessListItem) {
|
||||
setDomainBusiness(b)
|
||||
setDomainId(b.domainId)
|
||||
setDomainHost(b.domain ?? '')
|
||||
setDomainOpen(true)
|
||||
}
|
||||
|
||||
async function submitDomain() {
|
||||
if (!domainBusiness) return
|
||||
const host = domainHost.trim()
|
||||
if (!host) return
|
||||
|
||||
setDomainSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
if (domainId) {
|
||||
await updateBusinessDomain(domainBusiness.id, domainId, { host })
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === domainBusiness.id ? { ...item, domain: host } : item,
|
||||
),
|
||||
}
|
||||
})
|
||||
showToast(`Domain updated to "${host}".`, 'success')
|
||||
} else {
|
||||
const created = (await addBusinessDomain(domainBusiness.id, {
|
||||
host,
|
||||
isPrimary: true,
|
||||
})) as { id: number; host: string; sslEnabled: boolean }
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === domainBusiness.id
|
||||
? {
|
||||
...item,
|
||||
domainId: created.id,
|
||||
domain: created.host,
|
||||
sslEnabled: created.sslEnabled ?? false,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
}
|
||||
})
|
||||
showToast(`Domain "${host}" added.`, 'success')
|
||||
}
|
||||
setDomainOpen(false)
|
||||
setDomainBusiness(null)
|
||||
setDomainId(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to save domain.')
|
||||
} finally {
|
||||
setDomainSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function resetCreateForm() {
|
||||
setCreateName('')
|
||||
setCreateNameFa('')
|
||||
setCreateAbout('')
|
||||
setCreateCategoryIds([])
|
||||
setCreateOwnerFirstName('')
|
||||
setCreateOwnerLastName('')
|
||||
setCreateOwnerCell('')
|
||||
setCreateOwnerPassword('')
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
resetCreateForm()
|
||||
setCreateOpen(true)
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
const ownerCellNumber = toE164CellNumber(createOwnerCell.trim())
|
||||
if (!ownerCellNumber) return
|
||||
|
||||
setCreateSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
await createBusiness({
|
||||
name: createName.trim(),
|
||||
nameFa: createNameFa.trim(),
|
||||
about: createAbout.trim() || undefined,
|
||||
categoryIds: createCategoryIds,
|
||||
ownerFirstName: createOwnerFirstName.trim(),
|
||||
ownerLastName: createOwnerLastName.trim(),
|
||||
ownerCellNumber,
|
||||
ownerPassword: createOwnerPassword,
|
||||
})
|
||||
setCreateOpen(false)
|
||||
resetCreateForm()
|
||||
showToast(`"${createName.trim()}" has been created.`, 'success')
|
||||
await fetchList()
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to create business.')
|
||||
} finally {
|
||||
setCreateSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleActive(b: BusinessListItem, isActive: boolean) {
|
||||
setTogglingId(b.id)
|
||||
setError('')
|
||||
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((item) => (item.id === b.id ? { ...item, isActive } : item)),
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await setBusinessActive(b.id, { isActive })
|
||||
showToast(`"${b.name}" has been ${isActive ? 'enabled' : 'disabled'}.`, 'success')
|
||||
} catch (err) {
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === b.id ? { ...item, isActive: !isActive } : item,
|
||||
),
|
||||
}
|
||||
})
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to update business status.')
|
||||
} finally {
|
||||
setTogglingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRemove() {
|
||||
if (!removeTarget) return
|
||||
setError('')
|
||||
try {
|
||||
await removeBusiness(removeTarget.id)
|
||||
setRemoveTarget(null)
|
||||
await fetchList()
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to remove business.')
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmitCreate =
|
||||
createName.trim().length >= 2 &&
|
||||
createNameFa.trim().length >= 2 &&
|
||||
createCategoryIds.length > 0 &&
|
||||
createOwnerFirstName.trim().length >= 2 &&
|
||||
createOwnerLastName.trim().length >= 2 &&
|
||||
toE164CellNumber(createOwnerCell.trim()).length > 0 &&
|
||||
createOwnerPassword.length >= 8
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>Businesses</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
Manage businesses, domains, and SSL settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.filtersPanel}>
|
||||
<div className={styles.filtersTitle}>Filters</div>
|
||||
|
||||
<div className={styles.filtersGrid}>
|
||||
<div className={styles.filtersInputs}>
|
||||
<div className={`${styles.field} ${styles.fieldCol3}`}>
|
||||
<label htmlFor="filter-name">Name</label>
|
||||
<input
|
||||
id="filter-name"
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
placeholder="Search by business name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.fieldCol3}`}>
|
||||
<label htmlFor="filter-domain">Domain</label>
|
||||
<input
|
||||
id="filter-domain"
|
||||
value={draftDomain}
|
||||
onChange={(e) => setDraftDomain(e.target.value)}
|
||||
placeholder="shop-a.local"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.fieldCol3}`}>
|
||||
<label htmlFor="filter-category">Category</label>
|
||||
<input
|
||||
id="filter-category"
|
||||
value={draftCategory}
|
||||
onChange={(e) => setDraftCategory(e.target.value)}
|
||||
placeholder="e.g. electronics / phones"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.iconActionBtn} ${styles.iconActionBtnPrimary}`}
|
||||
onClick={applyFilters}
|
||||
disabled={loading}
|
||||
aria-label="Search"
|
||||
title="Search"
|
||||
>
|
||||
<Search size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.iconActionBtn} ${styles.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}>Business list</div>
|
||||
<div className={styles.meta}>
|
||||
{data ? (
|
||||
<>
|
||||
Showing {showingFrom} - {showingTo} of {data.total}
|
||||
</>
|
||||
) : (
|
||||
' '
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ padding: 16 }}>
|
||||
<div className={styles.meta} style={{ color: '#b91c1c' }}>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.th}>Name</th>
|
||||
<th className={styles.th}>Date created</th>
|
||||
<th className={styles.th}>Domain</th>
|
||||
<th className={styles.th}>Owner</th>
|
||||
<th className={`${styles.th} ${styles.thTheme}`}>Theme</th>
|
||||
<th className={`${styles.th} ${styles.thActions}`}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={6}>
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!loading && data?.items?.length === 0 && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={6}>
|
||||
No results found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
data?.items?.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td className={styles.td}>{b.name}</td>
|
||||
<td className={styles.td}>{formatDate(b.createdAt)}</td>
|
||||
<td className={styles.td}>
|
||||
{b.domain ? (
|
||||
<div className={styles.domainCell}>
|
||||
<span>{b.domain}</span>
|
||||
{b.sslEnabled ? (
|
||||
<Lock size={16} className={styles.sslOk} aria-label="SSL enabled" />
|
||||
) : (
|
||||
<AlertTriangle
|
||||
size={16}
|
||||
className={styles.sslWarn}
|
||||
aria-label="SSL not enabled"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className={styles.subText}>No domain</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
<div className={styles.ownerCell}>
|
||||
<div className={styles.ownerName}>{b.ownerName ?? '—'}</div>
|
||||
<div className={styles.ownerPhone}>
|
||||
{b.ownerCellNumber ? formatCellForDisplay(b.ownerCellNumber) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={`${styles.td} ${styles.tdTheme}`}>
|
||||
<PrimaryColorSwatchControl
|
||||
value={normalizeBusinessPrimaryColorId(b.primaryColor)}
|
||||
disabled={savingColorId === b.id}
|
||||
onChange={(primaryColor) =>
|
||||
void handlePrimaryColorChange(b, primaryColor)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className={`${styles.td} ${styles.tdActions}`}>
|
||||
<div className={styles.rowActions}>
|
||||
<span className={styles.toggleInActions}>
|
||||
<ToggleSwitch
|
||||
checked={b.isActive}
|
||||
disabled={togglingId === b.id}
|
||||
ariaLabel={`${b.isActive ? 'Disable' : 'Enable'} ${b.name}`}
|
||||
onChange={(isActive) => void handleToggleActive(b, isActive)}
|
||||
/>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.controlBtn}
|
||||
onClick={() => goToBusinessUsers(b, 'staff')}
|
||||
title="Staff"
|
||||
aria-label="View staff"
|
||||
>
|
||||
<UserCog size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.controlBtn}
|
||||
onClick={() => goToBusinessUsers(b, 'all')}
|
||||
title="All users"
|
||||
aria-label="View all users"
|
||||
>
|
||||
<Users size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.controlBtn}
|
||||
onClick={() => openEdit(b)}
|
||||
title="Edit"
|
||||
aria-label="Edit"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.controlBtn}
|
||||
onClick={() => openDomainEdit(b)}
|
||||
title="Edit domain"
|
||||
aria-label="Edit domain"
|
||||
>
|
||||
<Globe size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.controlBtn} ${styles.controlBtnDanger}`}
|
||||
onClick={() => setRemoveTarget(b)}
|
||||
title="Remove"
|
||||
aria-label="Remove"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.fab}
|
||||
onClick={openCreate}
|
||||
aria-label="Add business"
|
||||
title="Add business"
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
|
||||
<Modal
|
||||
open={editOpen}
|
||||
title="Edit business"
|
||||
onClose={() => {
|
||||
setEditOpen(false)
|
||||
setEditBusiness(null)
|
||||
}}
|
||||
>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="edit-name">Business name</label>
|
||||
<input
|
||||
id="edit-name"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
disabled={editSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label>Dashboard primary color</label>
|
||||
{editLoadingSettings ? (
|
||||
<p className={styles.helperText}>Loading theme...</p>
|
||||
) : (
|
||||
<PrimaryColorPicker
|
||||
value={editPrimaryColor}
|
||||
onChange={setEditPrimaryColor}
|
||||
disabled={editSubmitting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height: 12 }} />
|
||||
<div className={styles.actionsRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.btn} ${styles.btnGhost}`}
|
||||
onClick={() => setEditOpen(false)}
|
||||
disabled={editSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.btn} ${styles.btnPrimary}`}
|
||||
onClick={() => void submitEdit()}
|
||||
disabled={editSubmitting || !editName.trim() || editLoadingSettings}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={domainOpen}
|
||||
title={domainId ? 'Edit domain' : 'Add domain'}
|
||||
onClose={() => {
|
||||
setDomainOpen(false)
|
||||
setDomainBusiness(null)
|
||||
setDomainId(null)
|
||||
}}
|
||||
>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="domain-host">Domain</label>
|
||||
<input
|
||||
id="domain-host"
|
||||
value={domainHost}
|
||||
onChange={(e) => setDomainHost(e.target.value)}
|
||||
placeholder="shop-a.local"
|
||||
disabled={domainSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ height: 12 }} />
|
||||
<div className={styles.actionsRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.btn} ${styles.btnGhost}`}
|
||||
onClick={() => setDomainOpen(false)}
|
||||
disabled={domainSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.btn} ${styles.btnPrimary}`}
|
||||
onClick={() => void submitDomain()}
|
||||
disabled={domainSubmitting || !domainHost.trim()}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={createOpen}
|
||||
title="Add business"
|
||||
wide
|
||||
onClose={() => {
|
||||
setCreateOpen(false)
|
||||
resetCreateForm()
|
||||
}}
|
||||
>
|
||||
<div className={styles.formGrid}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="create-name-en">Name (EN)</label>
|
||||
<input
|
||||
id="create-name-en"
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
placeholder="New Shop"
|
||||
disabled={createSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="create-name-fa">Name (FA)</label>
|
||||
<input
|
||||
id="create-name-fa"
|
||||
className="faText"
|
||||
value={createNameFa}
|
||||
onChange={(e) => setCreateNameFa(e.target.value)}
|
||||
placeholder="نام فارسی"
|
||||
dir="rtl"
|
||||
disabled={createSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.formFull}`}>
|
||||
<label htmlFor="create-categories">Categories</label>
|
||||
<MultiSelectDropdown
|
||||
id="create-categories"
|
||||
options={categoryOptions}
|
||||
value={createCategoryIds}
|
||||
onChange={setCreateCategoryIds}
|
||||
placeholder="Select categories"
|
||||
disabled={createSubmitting || categories.length === 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.formFull}`}>
|
||||
<div className={styles.sectionLabel}>Owner</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="create-owner-first-name">First name</label>
|
||||
<input
|
||||
id="create-owner-first-name"
|
||||
value={createOwnerFirstName}
|
||||
onChange={(e) => setCreateOwnerFirstName(e.target.value)}
|
||||
disabled={createSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="create-owner-last-name">Last name</label>
|
||||
<input
|
||||
id="create-owner-last-name"
|
||||
value={createOwnerLastName}
|
||||
onChange={(e) => setCreateOwnerLastName(e.target.value)}
|
||||
disabled={createSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.formFull}`}>
|
||||
<label htmlFor="create-owner-cell">Cell number</label>
|
||||
<input
|
||||
id="create-owner-cell"
|
||||
value={createOwnerCell}
|
||||
onChange={(e) => setCreateOwnerCell(e.target.value)}
|
||||
placeholder="0912..."
|
||||
name="owner-cell"
|
||||
autoComplete="off"
|
||||
disabled={createSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.formFull}`}>
|
||||
<label htmlFor="create-owner-password">Password</label>
|
||||
<input
|
||||
id="create-owner-password"
|
||||
type="password"
|
||||
value={createOwnerPassword}
|
||||
onChange={(e) => setCreateOwnerPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
name="owner-password"
|
||||
autoComplete="new-password"
|
||||
disabled={createSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.formFull}`}>
|
||||
<label htmlFor="create-about">About business</label>
|
||||
<textarea
|
||||
id="create-about"
|
||||
value={createAbout}
|
||||
onChange={(e) => setCreateAbout(e.target.value)}
|
||||
placeholder="Short description about the business"
|
||||
disabled={createSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 12 }} />
|
||||
<div className={styles.actionsRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.btn} ${styles.btnGhost}`}
|
||||
onClick={() => setCreateOpen(false)}
|
||||
disabled={createSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.btn} ${styles.btnPrimary}`}
|
||||
onClick={() => void submitCreate()}
|
||||
disabled={createSubmitting || !canSubmitCreate}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={removeTarget !== null}
|
||||
title="Remove business?"
|
||||
message={
|
||||
removeTarget
|
||||
? `Are you sure you want to remove "${removeTarget.name}"? This action cannot be undone.`
|
||||
: ''
|
||||
}
|
||||
onCancel={() => setRemoveTarget(null)}
|
||||
onConfirm={() => void confirmRemove()}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user