Files
dashboards/apps/super-admin/src/pages/BusinessesPage.tsx
T
Alireza HassaniandCursor 7d6c1cd4e3 Add optional git repo URL on Add Domain for storefront wiring.
Super Admin can opt into websites VM provision so Deploy shows on Websites without manual slug maps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 14:07:56 +03:30

1841 lines
61 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
AlertTriangle,
Database,
FileText,
Globe,
Lock,
Pencil,
Plus,
RotateCcw,
Search,
UserCog,
} from 'lucide-react'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { BusinessActionsOverflowMenu } from '../components/BusinessActionsOverflowMenu'
import { ToggleSwitch } from '../components/ToggleSwitch'
import { formatCellForDisplay, toE164CellNumber } from '../lib/cellNumber'
import type {
BusinessesListResponse,
BusinessListItem,
MigrateEntityResult,
MigrateFromOldEntity,
MigrateFromOldResponse,
PurgeBusinessDataEntity,
PurgeBusinessDataResponse,
PurgeEntityResult,
} 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,
migrateBusinessFromOld,
purgeBusinessData,
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,
updateBusinessBranding,
updateBusinessDefaultLocale,
updateBusinessModules,
updateBusinessPrimaryColor,
type DashboardLocale,
} from '../services/businessSettingsService'
import {
BUSINESS_MODULE_IDS,
BUSINESS_MODULE_LABELS,
DEFAULT_ENABLED_BUSINESS_MODULES,
DEFAULT_HOME_CHARTS,
HOME_CHART_IDS,
HOME_CHART_LABELS,
normalizeEnabledBusinessModules,
normalizeHomeCharts,
type BusinessModuleId,
type HomeChartId,
} from '../utils/businessModules'
import { flattenBusinessCategories } from '../utils/categories'
import { Pagination } from '@meshkee/dashboard-ui'
import pageStyles from '../components/PageContent.module.css'
import styles from './BusinessesPage.module.css'
const DEFAULT_PAGE_SIZE = 10
const DEFAULT_LOCALE: DashboardLocale = 'fa'
function normalizeDefaultLocale(value: unknown): DashboardLocale {
return value === 'en' || value === 'fa' ? value : DEFAULT_LOCALE
}
function emptyModuleSelection(
enabled: BusinessModuleId[] = DEFAULT_ENABLED_BUSINESS_MODULES,
): Record<BusinessModuleId, boolean> {
const selected = new Set(enabled)
return Object.fromEntries(
BUSINESS_MODULE_IDS.map((id) => [id, selected.has(id)]),
) as Record<BusinessModuleId, boolean>
}
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' })
}
const MIGRATE_ENTITY_LABELS: Record<MigrateFromOldEntity, string> = {
product_categories: 'Product categories',
product: 'Products',
customer_categories: 'Customer categories',
customer: 'Customers',
blog_categories: 'Blog categories',
blog: 'Blogs',
portfolio_categories: 'Portfolio categories',
portfolio: 'Portfolios',
}
const DATA_ENTITY_GROUPS = [
{
title: 'Products',
items: [
['product_categories', 'Categories'],
['product', 'Products'],
],
},
{
title: 'Customers',
items: [
['customer_categories', 'Categories'],
['customer', 'Customers'],
],
},
{
title: 'Blogs',
items: [
['blog_categories', 'Categories'],
['blog', 'Blogs'],
],
},
{
title: 'Portfolios',
items: [
['portfolio_categories', 'Categories'],
['portfolio', 'Portfolios'],
],
},
] as const
function formatMigrateEntityResult(result: MigrateEntityResult): string {
if ('created' in result) {
const base = `${result.created} created, ${result.skipped} skipped (${result.total} total)`
const extras = [
result.imagesCopied != null ? `${result.imagesCopied} images copied` : null,
result.imagesResized != null && result.imagesResized > 0
? `${result.imagesResized} resized (≤1280px)`
: null,
result.titlesUpdated != null && result.titlesUpdated > 0
? `${result.titlesUpdated} titles updated`
: null,
result.imagesFailed != null && result.imagesFailed > 0
? `${result.imagesFailed} images failed`
: null,
result.skippedInvalidCell != null && result.skippedInvalidCell > 0
? `${result.skippedInvalidCell} invalid/missing cell`
: null,
result.skippedAlreadyLinked != null && result.skippedAlreadyLinked > 0
? `${result.skippedAlreadyLinked} already linked`
: null,
result.skippedCreateFailed != null && result.skippedCreateFailed > 0
? `${result.skippedCreateFailed} create failed`
: null,
].filter(Boolean)
return extras.length ? `${base}; ${extras.join(', ')}` : base
}
return 'Not implemented yet'
}
function formatPurgeEntityResult(result: PurgeEntityResult): string {
if ('deleted' in result) {
const extras = [
result.imagesDeleted != null ? `${result.imagesDeleted} images deleted` : null,
].filter(Boolean)
const base = `${result.deleted} deleted`
return extras.length ? `${base}; ${extras.join(', ')}` : base
}
return 'Not implemented yet'
}
function emptyMigrateEntities(): Record<MigrateFromOldEntity, boolean> {
return {
product_categories: false,
product: false,
customer_categories: false,
customer: false,
blog_categories: false,
blog: false,
portfolio_categories: false,
portfolio: false,
}
}
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 [editDefaultLocale, setEditDefaultLocale] = useState<DashboardLocale>(DEFAULT_LOCALE)
const [editLoadingSettings, setEditLoadingSettings] = useState(false)
const [editSubmitting, setEditSubmitting] = useState(false)
const [editError, setEditError] = useState('')
const [domainOpen, setDomainOpen] = useState(false)
const [domainBusiness, setDomainBusiness] = useState<BusinessListItem | null>(null)
const [domainId, setDomainId] = useState<number | null>(null)
const [domainHost, setDomainHost] = useState('')
const [domainGitRepoUrl, setDomainGitRepoUrl] = useState('')
const [domainSubmitting, setDomainSubmitting] = useState(false)
const [domainError, setDomainError] = useState('')
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 [createOwnerFirstNameEn, setCreateOwnerFirstNameEn] = useState('')
const [createOwnerLastNameEn, setCreateOwnerLastNameEn] = useState('')
const [createOwnerCell, setCreateOwnerCell] = useState('')
const [createOwnerPassword, setCreateOwnerPassword] = useState('')
const [createSubmitting, setCreateSubmitting] = useState(false)
const [createError, setCreateError] = useState('')
const [removeTarget, setRemoveTarget] = useState<BusinessListItem | null>(null)
const [togglingId, setTogglingId] = useState<string | null>(null)
const [savingColorId, setSavingColorId] = useState<string | null>(null)
const [savingLocaleId, setSavingLocaleId] = useState<string | null>(null)
const [modulesOpen, setModulesOpen] = useState(false)
const [modulesBusiness, setModulesBusiness] = useState<BusinessListItem | null>(null)
const [modulesSelected, setModulesSelected] = useState<Record<BusinessModuleId, boolean>>(
() => emptyModuleSelection(),
)
const [modulesCharts, setModulesCharts] = useState<[HomeChartId, HomeChartId]>([
...DEFAULT_HOME_CHARTS,
])
const [modulesSubmitting, setModulesSubmitting] = useState(false)
const [modulesError, setModulesError] = useState('')
const [migrateOpen, setMigrateOpen] = useState(false)
const [migrateBusiness, setMigrateBusiness] = useState<BusinessListItem | null>(null)
const [migrateOldId, setMigrateOldId] = useState('')
const [migrateEntities, setMigrateEntities] = useState<Record<MigrateFromOldEntity, boolean>>(
() => emptyMigrateEntities(),
)
const [migrateSubmitting, setMigrateSubmitting] = useState(false)
const [migrateError, setMigrateError] = useState('')
const [migrateResult, setMigrateResult] = useState<MigrateFromOldResponse | null>(null)
const [purgeOpen, setPurgeOpen] = useState(false)
const [purgeBusiness, setPurgeBusiness] = useState<BusinessListItem | null>(null)
const [purgeEntities, setPurgeEntities] = useState<Record<PurgeBusinessDataEntity, boolean>>(
() => emptyMigrateEntities(),
)
const [purgeSubmitting, setPurgeSubmitting] = useState(false)
const [purgeError, setPurgeError] = useState('')
const [purgeResult, setPurgeResult] = useState<PurgeBusinessDataResponse | 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 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))
setEditDefaultLocale(normalizeDefaultLocale(b.defaultLocale))
setEditError('')
setEditOpen(true)
setEditLoadingSettings(true)
const controller = new AbortController()
void getBusinessSettings(b.id, controller.signal)
.then((data) => {
setEditPrimaryColor(data.settings.branding.primaryColor)
setEditDefaultLocale(normalizeDefaultLocale(data.settings.branding.defaultLocale))
})
.catch((err) => {
if (isAbortError(err)) return
setEditError(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 handleDefaultLocaleChange(b: BusinessListItem, defaultLocale: DashboardLocale) {
const currentLocale = normalizeDefaultLocale(b.defaultLocale)
if (currentLocale === defaultLocale) return
setSavingLocaleId(b.id)
setError('')
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === b.id ? { ...item, defaultLocale } : item,
),
}
})
if (editBusiness?.id === b.id) {
setEditDefaultLocale(defaultLocale)
}
try {
await updateBusinessDefaultLocale(b.id, defaultLocale)
showToast(`Default language 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, defaultLocale: currentLocale } : item,
),
}
})
if (editBusiness?.id === b.id) {
setEditDefaultLocale(currentLocale)
}
showToast(
err instanceof ApiError ? err.message : 'Unable to update default language.',
'error',
)
} finally {
setSavingLocaleId(null)
}
}
function openModules(b: BusinessListItem) {
setModulesBusiness(b)
setModulesSelected(
emptyModuleSelection(
normalizeEnabledBusinessModules(b.enabledModules ?? DEFAULT_ENABLED_BUSINESS_MODULES),
),
)
setModulesCharts(normalizeHomeCharts(b.homeCharts ?? DEFAULT_HOME_CHARTS))
setModulesError('')
setModulesOpen(true)
}
function closeModules() {
if (modulesSubmitting) return
setModulesOpen(false)
setModulesBusiness(null)
setModulesError('')
}
async function submitModules() {
if (!modulesBusiness) return
const enabled = BUSINESS_MODULE_IDS.filter((id) => modulesSelected[id])
const charts = normalizeHomeCharts(modulesCharts)
setModulesSubmitting(true)
setModulesError('')
try {
await updateBusinessModules(modulesBusiness.id, enabled, charts)
setData((prev) =>
prev
? {
...prev,
items: prev.items.map((item) =>
item.id === modulesBusiness.id
? {
...item,
enabledModules: enabled,
moduleCount: enabled.length,
homeCharts: charts,
}
: item,
),
}
: prev,
)
showToast('Modules updated.', 'success')
setModulesOpen(false)
setModulesBusiness(null)
} catch (err) {
setModulesError(
err instanceof ApiError ? err.message : 'Unable to update modules.',
)
} finally {
setModulesSubmitting(false)
}
}
async function submitEdit() {
if (!editBusiness) return
setEditSubmitting(true)
setEditError('')
try {
await updateBusiness(editBusiness.id, { name: editName })
await updateBusinessBranding(editBusiness.id, {
primaryColor: editPrimaryColor,
defaultLocale: editDefaultLocale,
})
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === editBusiness.id
? {
...item,
name: editName.trim(),
primaryColor: editPrimaryColor,
defaultLocale: editDefaultLocale,
}
: item,
),
}
})
setEditOpen(false)
setEditBusiness(null)
showToast('Business updated.', 'success')
await fetchList()
} catch (err) {
setEditError(err instanceof ApiError ? err.message : 'Unable to update business.')
} finally {
setEditSubmitting(false)
}
}
function openDomainEdit(b: BusinessListItem) {
setDomainBusiness(b)
setDomainId(b.domainId)
setDomainHost(b.domain ?? '')
setDomainGitRepoUrl('')
setDomainError('')
setDomainOpen(true)
}
function openMigrate(b: BusinessListItem) {
setMigrateBusiness(b)
setMigrateOldId(b.oldBusinessId != null ? String(b.oldBusinessId) : '')
setMigrateEntities(emptyMigrateEntities())
setMigrateError('')
setMigrateResult(null)
setMigrateOpen(true)
}
const migrateSubmittingRef = useRef(false)
migrateSubmittingRef.current = migrateSubmitting
const closeMigrate = useCallback(() => {
if (migrateSubmittingRef.current) return
setMigrateOpen(false)
setMigrateBusiness(null)
setMigrateError('')
setMigrateResult(null)
}, [])
function selectMigrateEntity(entity: MigrateFromOldEntity) {
setMigrateResult(null)
setMigrateError('')
setMigrateEntities({ ...emptyMigrateEntities(), [entity]: true })
}
async function submitMigrate() {
if (!migrateBusiness) return
const oldBusinessId = Number(String(migrateOldId).trim())
if (!Number.isInteger(oldBusinessId) || oldBusinessId <= 0) {
setMigrateResult(null)
setMigrateError('Enter a valid old business id (positive integer).')
return
}
const entities = (Object.keys(migrateEntities) as MigrateFromOldEntity[]).filter(
(key) => migrateEntities[key],
)
if (entities.length === 0) {
setMigrateResult(null)
setMigrateError('Select a data type to migrate.')
return
}
setMigrateSubmitting(true)
setMigrateError('')
setMigrateResult(null)
try {
const result = await migrateBusinessFromOld(migrateBusiness.id, {
oldBusinessId,
entities,
})
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === migrateBusiness.id
? { ...item, oldBusinessId: String(oldBusinessId) }
: item,
),
}
})
setMigrateResult(result)
} catch (err) {
setMigrateError(
err instanceof ApiError ? err.message : 'Unable to migrate data from old CMS.',
)
} finally {
setMigrateSubmitting(false)
}
}
function openPurge(b: BusinessListItem) {
setPurgeBusiness(b)
setPurgeEntities(emptyMigrateEntities())
setPurgeError('')
setPurgeResult(null)
setPurgeOpen(true)
}
const purgeSubmittingRef = useRef(false)
purgeSubmittingRef.current = purgeSubmitting
const closePurge = useCallback(() => {
if (purgeSubmittingRef.current) return
setPurgeOpen(false)
setPurgeBusiness(null)
setPurgeError('')
setPurgeResult(null)
}, [])
function selectPurgeEntity(entity: PurgeBusinessDataEntity) {
setPurgeResult(null)
setPurgeError('')
setPurgeEntities({ ...emptyMigrateEntities(), [entity]: true })
}
async function submitPurge() {
if (!purgeBusiness) return
const entities = (Object.keys(purgeEntities) as PurgeBusinessDataEntity[]).filter(
(key) => purgeEntities[key],
)
if (entities.length === 0) {
setPurgeResult(null)
setPurgeError('Select a data type to delete.')
return
}
setPurgeSubmitting(true)
setPurgeError('')
setPurgeResult(null)
try {
const result = await purgeBusinessData(purgeBusiness.id, { entities })
setPurgeResult(result)
showToast(result.message, 'success')
} catch (err) {
setPurgeError(
err instanceof ApiError ? err.message : 'Unable to delete selected business data.',
)
} finally {
setPurgeSubmitting(false)
}
}
async function submitDomain() {
if (!domainBusiness) return
const host = domainHost.trim()
if (!host) return
const gitRepoUrl = domainGitRepoUrl.trim()
setDomainSubmitting(true)
setDomainError('')
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,
...(gitRepoUrl ? { gitRepoUrl } : {}),
})) as {
id: number
host: string
sslEnabled: boolean
deploySlug?: string | null
provisionError?: string | null
}
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,
),
}
})
if (created.provisionError) {
showToast(`Domain "${host}" added, but storefront setup failed.`, 'error')
showToast(created.provisionError, 'error')
} else if (gitRepoUrl && created.deploySlug) {
showToast(
`Domain "${host}" added. Storefront deploy is ready on Websites.`,
'success',
)
} else {
showToast(`Domain "${host}" added.`, 'success')
}
}
setDomainOpen(false)
setDomainBusiness(null)
setDomainId(null)
setDomainGitRepoUrl('')
} catch (err) {
setDomainError(err instanceof ApiError ? err.message : 'Unable to save domain.')
} finally {
setDomainSubmitting(false)
}
}
function resetCreateForm() {
setCreateName('')
setCreateNameFa('')
setCreateAbout('')
setCreateCategoryIds([])
setCreateOwnerFirstName('')
setCreateOwnerLastName('')
setCreateOwnerFirstNameEn('')
setCreateOwnerLastNameEn('')
setCreateOwnerCell('')
setCreateOwnerPassword('')
setCreateError('')
}
function openCreate() {
resetCreateForm()
setCreateOpen(true)
}
async function submitCreate() {
const ownerCellNumber = toE164CellNumber(createOwnerCell.trim())
if (!ownerCellNumber) return
setCreateSubmitting(true)
setCreateError('')
try {
await createBusiness({
name: createName.trim(),
nameFa: createNameFa.trim(),
about: createAbout.trim() || undefined,
categoryIds: createCategoryIds,
ownerFirstName: createOwnerFirstName.trim(),
ownerLastName: createOwnerLastName.trim(),
ownerFirstNameEn: createOwnerFirstNameEn.trim(),
ownerLastNameEn: createOwnerLastNameEn.trim(),
ownerCellNumber,
ownerPassword: createOwnerPassword,
})
setCreateOpen(false)
resetCreateForm()
showToast(`"${createName.trim()}" has been created.`, 'success')
await fetchList()
} catch (err) {
setCreateError(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 &&
createOwnerFirstNameEn.trim().length >= 2 &&
createOwnerLastNameEn.trim().length >= 2 &&
toE164CellNumber(createOwnerCell.trim()).length > 0 &&
createOwnerPassword.length >= 8
const canSubmitMigrate =
Number.isInteger(Number(String(migrateOldId).trim())) &&
Number(String(migrateOldId).trim()) > 0 &&
Object.values(migrateEntities).some(Boolean)
const canSubmitPurge = Object.values(purgeEntities).some(Boolean)
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.thLocale}`}>Lang</th>
<th className={`${styles.th} ${styles.thModules}`}>Modules</th>
<th className={`${styles.th} ${styles.thActions}`}>Actions</th>
</tr>
</thead>
<tbody>
{loading && (
<tr>
<td className={styles.td} colSpan={8}>
Loading...
</td>
</tr>
)}
{!loading && data?.items?.length === 0 && (
<tr>
<td className={styles.td} colSpan={8}>
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 ? (
<span
className={styles.sslIcon}
title="SSL certificate valid"
aria-label="SSL certificate valid"
>
<Lock size={16} className={styles.sslOk} aria-hidden="true" />
</span>
) : (
<span
className={styles.sslIcon}
title="SSL certificate missing or invalid"
aria-label="SSL certificate missing or invalid"
>
<AlertTriangle
size={16}
className={styles.sslWarn}
aria-hidden="true"
/>
</span>
)}
</div>
) : (
<span className={styles.subText}>No domain</span>
)}
</td>
<td className={styles.td}>
<div className={styles.ownerCell}>
<div className={styles.ownerName}>{b.ownerNameEn || '—'}</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.tdLocale}`}>
<select
className={styles.localeSelect}
value={normalizeDefaultLocale(b.defaultLocale)}
disabled={savingLocaleId === b.id}
aria-label={`Default language for ${b.name}`}
onChange={(e) =>
void handleDefaultLocaleChange(
b,
normalizeDefaultLocale(e.target.value),
)
}
>
<option value="fa">FA</option>
<option value="en">EN</option>
</select>
</td>
<td className={`${styles.td} ${styles.tdModules}`}>
<button
type="button"
className={styles.modulesCountBtn}
onClick={() => openModules(b)}
title="Edit modules"
aria-label={`Edit modules for ${b.name}`}
>
{typeof b.moduleCount === 'number'
? b.moduleCount
: normalizeEnabledBusinessModules(
b.enabledModules ?? DEFAULT_ENABLED_BUSINESS_MODULES,
).length}
</button>
</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={() => navigate(`/businesses/${b.id}/invoices`)}
title="Invoices"
aria-label="View invoices"
>
<FileText size={16} />
</button>
<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={(e) => {
e.preventDefault()
e.stopPropagation()
openMigrate(b)
}}
title="Migrate from old CMS"
aria-label="Migrate from old CMS"
>
<Database 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>
<BusinessActionsOverflowMenu
businessName={b.name}
onAllUsers={() => goToBusinessUsers(b, 'all')}
onWipeData={() => openPurge(b)}
onRemove={() => setRemoveTarget(b)}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className={styles.pagination}>
<div>
Page {page} / {totalPages} · {pageSize} per page · {data?.total ?? 0} total
</div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</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)
setEditError('')
}}
>
{editError ? (
<p className={styles.alertError} role="alert">
{editError}
</p>
) : 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 className={styles.field}>
<label htmlFor="edit-default-locale">Default dashboard language</label>
{editLoadingSettings ? (
<p className={styles.helperText}>Loading language...</p>
) : (
<select
id="edit-default-locale"
className={styles.localeSelect}
value={editDefaultLocale}
disabled={editSubmitting}
onChange={(e) => setEditDefaultLocale(normalizeDefaultLocale(e.target.value))}
>
<option value="fa">Farsi (FA)</option>
<option value="en">English (EN)</option>
</select>
)}
<p className={styles.helperText}>
Business and customer dashboards open in this language.
</p>
</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)
setDomainGitRepoUrl('')
setDomainError('')
}}
>
{domainError ? (
<p className={styles.alertError} role="alert">
{domainError}
</p>
) : null}
<div className={styles.field}>
<label htmlFor="domain-host">Domain</label>
<input
id="domain-host"
value={domainHost}
onChange={(e) => setDomainHost(e.target.value)}
placeholder="oaktasty.com"
disabled={domainSubmitting}
/>
</div>
{!domainId ? (
<>
<div style={{ height: 12 }} />
<div className={styles.field}>
<label htmlFor="domain-git-repo">Git repo URL (optional)</label>
<input
id="domain-git-repo"
value={domainGitRepoUrl}
onChange={(e) => setDomainGitRepoUrl(e.target.value)}
placeholder="git@git.meshkee.com:Meshkee-Websites/oaktasty.git"
disabled={domainSubmitting}
autoComplete="off"
spellCheck={false}
/>
<p className={styles.subText} style={{ marginTop: 6 }}>
Fill to enable storefront Deploy on Websites. Leave empty for domain-only
(dashboards).
</p>
</div>
</>
) : null}
<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={modulesOpen}
title={
modulesBusiness
? `Modules — ${modulesBusiness.name}`
: 'Modules'
}
onClose={closeModules}
>
{modulesError ? (
<p className={styles.alertError} role="alert">
{modulesError}
</p>
) : null}
<p className={styles.helperText}>
Choose which optional modules this business can use. Customers, website,
and other general sections stay available for every business.
</p>
<div className={styles.modulesChecks}>
{BUSINESS_MODULE_IDS.map((id) => (
<label key={id} className={styles.entityCheck}>
<input
type="checkbox"
checked={modulesSelected[id]}
disabled={modulesSubmitting}
onChange={(e) =>
setModulesSelected((prev) => ({
...prev,
[id]: e.target.checked,
}))
}
/>
<span>{BUSINESS_MODULE_LABELS[id]}</span>
</label>
))}
</div>
<div className={styles.modulesCharts}>
<p className={styles.entityGroupLabel}>Home charts</p>
<div className={styles.field}>
<label htmlFor="modules-chart-1">Chart 1</label>
<select
id="modules-chart-1"
value={modulesCharts[0]}
disabled={modulesSubmitting}
onChange={(e) => {
const value = e.target.value
if (!HOME_CHART_IDS.includes(value as HomeChartId)) return
setModulesCharts([value as HomeChartId, modulesCharts[1]])
}}
>
{HOME_CHART_IDS.map((id) => (
<option key={id} value={id}>
{HOME_CHART_LABELS[id]}
</option>
))}
</select>
</div>
<div className={styles.field}>
<label htmlFor="modules-chart-2">Chart 2</label>
<select
id="modules-chart-2"
value={modulesCharts[1]}
disabled={modulesSubmitting}
onChange={(e) => {
const value = e.target.value
if (!HOME_CHART_IDS.includes(value as HomeChartId)) return
setModulesCharts([modulesCharts[0], value as HomeChartId])
}}
>
{HOME_CHART_IDS.map((id) => (
<option key={id} value={id}>
{HOME_CHART_LABELS[id]}
</option>
))}
</select>
</div>
</div>
<div style={{ height: 12 }} />
<div className={styles.actionsRow}>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
onClick={closeModules}
disabled={modulesSubmitting}
>
Cancel
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={() => void submitModules()}
disabled={modulesSubmitting}
>
{modulesSubmitting ? 'Saving…' : 'Save modules'}
</button>
</div>
</Modal>
<Modal
open={migrateOpen}
title={
migrateBusiness
? `Migrate from old CMS — ${migrateBusiness.name}`
: 'Migrate from old CMS'
}
onClose={closeMigrate}
>
{migrateSubmitting ? (
<p className={styles.alertInfo} role="status" aria-live="polite">
Migrating selected data This can take a moment.
</p>
) : null}
{migrateError ? (
<p className={styles.alertError} role="alert">
{migrateError}
</p>
) : null}
{migrateResult ? (
<div className={styles.alertSuccess} role="status" aria-live="polite">
<p className={styles.migrateResultTitle}>{migrateResult.message}</p>
{migrateResult.results ? (
<ul className={styles.migrateResultList}>
{(Object.keys(migrateResult.results) as MigrateFromOldEntity[]).map((key) => {
const item = migrateResult.results?.[key]
if (!item) return null
return (
<li key={key}>
<strong>{MIGRATE_ENTITY_LABELS[key]}:</strong>{' '}
{formatMigrateEntityResult(item)}
</li>
)
})}
</ul>
) : null}
</div>
) : null}
<p className={styles.helperText}>
Link this business to a legacy WillaEngine business id, then choose one
data type to migrate. Selecting blogs also migrates news under a top-level
News category. Selecting customers also migrates client categories when
needed.
</p>
<div className={styles.field}>
<label htmlFor="migrate-old-id">Old business id</label>
<input
id="migrate-old-id"
type="number"
min={1}
step={1}
value={String(migrateOldId)}
onChange={(e) => {
setMigrateOldId(e.target.value)
setMigrateResult(null)
setMigrateError('')
}}
placeholder="e.g. 2410"
disabled={migrateSubmitting}
/>
</div>
<div className={styles.field} style={{ marginTop: 12 }}>
<span className={styles.entityGroupLabel}>Data to migrate (one)</span>
<div className={styles.entityChecks}>
{DATA_ENTITY_GROUPS.map((group) => (
<div key={group.title} className={styles.entityGroup}>
<span className={styles.entityGroupTitle}>{group.title}</span>
<div className={styles.entityNestedChecks}>
{group.items.map(([value, label]) => (
<label key={value} className={styles.entityCheck}>
<input
type="radio"
name="migrate-entity"
checked={migrateEntities[value]}
onChange={() => selectMigrateEntity(value)}
disabled={migrateSubmitting}
/>
<span>{label}</span>
</label>
))}
</div>
</div>
))}
</div>
</div>
<div style={{ height: 12 }} />
<div className={styles.actionsRow}>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
onClick={closeMigrate}
disabled={migrateSubmitting}
>
{migrateResult ? 'Close' : 'Cancel'}
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={() => void submitMigrate()}
disabled={migrateSubmitting || !canSubmitMigrate}
>
{migrateSubmitting
? 'Migrating…'
: migrateResult
? 'Migrate again'
: 'Save & migrate'}
</button>
</div>
</Modal>
<Modal
open={purgeOpen}
title={
purgeBusiness ? `Delete data — ${purgeBusiness.name}` : 'Delete data'
}
onClose={closePurge}
>
{purgeSubmitting ? (
<p className={styles.alertInfo} role="status" aria-live="polite">
Deleting selected data This can take a moment.
</p>
) : null}
{purgeError ? (
<p className={styles.alertError} role="alert">
{purgeError}
</p>
) : null}
{purgeResult ? (
<div className={styles.alertSuccess} role="status" aria-live="polite">
<p className={styles.migrateResultTitle}>{purgeResult.message}</p>
{purgeResult.results ? (
<ul className={styles.migrateResultList}>
{(Object.keys(purgeResult.results) as PurgeBusinessDataEntity[]).map((key) => {
const item = purgeResult.results?.[key]
if (!item) return null
return (
<li key={key}>
<strong>{MIGRATE_ENTITY_LABELS[key]}:</strong>{' '}
{formatPurgeEntityResult(item)}
</li>
)
})}
</ul>
) : null}
</div>
) : null}
<p className={styles.helperText}>
Permanently remove one selected data type from this business so you can
migrate again. Deleting blogs or portfolios also removes their images
from storage. Deleting customers removes business links (not global user
accounts).
</p>
<div className={styles.field} style={{ marginTop: 12 }}>
<span className={styles.entityGroupLabel}>Data to delete (one)</span>
<div className={styles.entityChecks}>
{DATA_ENTITY_GROUPS.map((group) => (
<div key={group.title} className={styles.entityGroup}>
<span className={styles.entityGroupTitle}>{group.title}</span>
<div className={styles.entityNestedChecks}>
{group.items.map(([value, label]) => (
<label key={value} className={styles.entityCheck}>
<input
type="radio"
name="purge-entity"
checked={purgeEntities[value]}
onChange={() => selectPurgeEntity(value)}
disabled={purgeSubmitting}
/>
<span>{label}</span>
</label>
))}
</div>
</div>
))}
</div>
</div>
<div style={{ height: 12 }} />
<div className={styles.actionsRow}>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
onClick={closePurge}
disabled={purgeSubmitting}
>
{purgeResult ? 'Close' : 'Cancel'}
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnDanger}`}
onClick={() => void submitPurge()}
disabled={purgeSubmitting || !canSubmitPurge}
>
{purgeSubmitting
? 'Deleting…'
: purgeResult
? 'Delete again'
: 'Delete selected data'}
</button>
</div>
</Modal>
<Modal
open={createOpen}
title="Add business"
wide
onClose={() => {
setCreateOpen(false)
resetCreateForm()
}}
>
{createError ? (
<p className={styles.alertError} role="alert">
{createError}
</p>
) : null}
<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-en">First name (EN)</label>
<input
id="create-owner-first-name-en"
value={createOwnerFirstNameEn}
onChange={(e) => setCreateOwnerFirstNameEn(e.target.value)}
placeholder="First name"
disabled={createSubmitting}
/>
</div>
<div className={styles.field}>
<label htmlFor="create-owner-last-name-en">Last name (EN)</label>
<input
id="create-owner-last-name-en"
value={createOwnerLastNameEn}
onChange={(e) => setCreateOwnerLastNameEn(e.target.value)}
placeholder="Last name"
disabled={createSubmitting}
/>
</div>
<div className={styles.field}>
<label htmlFor="create-owner-first-name">First name (FA)</label>
<input
id="create-owner-first-name"
className="faText"
value={createOwnerFirstName}
onChange={(e) => setCreateOwnerFirstName(e.target.value)}
placeholder="نام"
dir="rtl"
lang="fa"
disabled={createSubmitting}
/>
</div>
<div className={styles.field}>
<label htmlFor="create-owner-last-name">Last name (FA)</label>
<input
id="create-owner-last-name"
className="faText"
value={createOwnerLastName}
onChange={(e) => setCreateOwnerLastName(e.target.value)}
placeholder="نام خانوادگی"
dir="rtl"
lang="fa"
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>
)
}