Files
dashboards/apps/super-admin/src/pages/BusinessesPage.tsx
T
Alireza HassaniandCursor 15aad9f9d8 Use compact unlabeled filter rows across Super Admin lists.
Also ship pending add-customer name flexibility, store-gated e-payment settings, and logo-only customer login branding.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 12:32:18 +03:30

2903 lines
98 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
AlertTriangle,
Copy,
Database,
FileText,
Globe,
Lock,
Pencil,
Plus,
RefreshCw,
RotateCcw,
Search,
Trash2,
UserCog,
} from 'lucide-react'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { BusinessActionsOverflowMenu } from '../components/BusinessActionsOverflowMenu'
import { MultiSelectDropdown } from '../components/MultiSelectDropdown'
import { StepDatePicker } from '../components/StepDatePicker'
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 { ParkedDnsProvider } from '../types/domain'
import type { BusinessCategory } from '../types/category'
import type { ListBusinessesParams } from '../services/businessService'
import { ApiError, isAbortError } from '../lib/api'
import {
addBusinessDomain,
createBusiness,
listBusinesses,
lookupLegacyMigrate,
migrateBusinessFromOld,
purgeBusinessData,
removeBusiness,
setBusinessActive,
updateBusiness,
updateBusinessDomain,
type LegacyMigrateLookupResponse,
} from '../services/businessService'
import { listBusinessCategories } from '../services/categoryService'
import { listUserTitles, type UserTitleOption } from '../services/userTitleService'
import {
createDomainAiPrompt,
deleteDomainAiPrompt,
listAiPromptTemplates,
listDomainAiPrompts,
updateDomainAiPrompt,
} from '../services/aiPromptService'
import type { AiPromptTemplate, DomainAiPrompt } from '../types/aiPrompt'
import { Modal } from '../components/Modal'
import { DnsProviderSwitch } from '../components/DnsProviderSwitch'
import { PrimaryColorSwatchControl } from '../components/PrimaryColorSwatchControl'
import { useToast } from '../context/ToastContext'
import {
normalizeBusinessPrimaryColorId,
type BusinessPrimaryColorId,
} from '../utils/businessPrimaryColors'
import {
updateBusinessDefaultLocale,
updateBusinessModules,
updateBusinessPrimaryColor,
updateBusinessThemeMode,
type DashboardLocale,
type DashboardThemeMode,
} from '../services/businessSettingsService'
import {
BUSINESS_DASHBOARD_MODULE_IDS,
BUSINESS_DASHBOARD_MODULE_LABELS,
BUSINESS_MODULE_IDS,
CUSTOMER_MODULE_IDS,
CUSTOMER_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 { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import { Pagination } from '@meshkee/dashboard-ui'
import pageStyles from '../components/PageContent.module.css'
import styles from './BusinessesPage.module.css'
const DEFAULT_PAGE_SIZE = 24
const DEFAULT_LOCALE: DashboardLocale = 'fa'
const DEFAULT_THEME_MODE: DashboardThemeMode = 'light'
function normalizeDefaultLocale(value: unknown): DashboardLocale {
return value === 'en' || value === 'fa' ? value : DEFAULT_LOCALE
}
function normalizeThemeMode(value: unknown): DashboardThemeMode {
return value === 'dark' || value === 'light' ? value : DEFAULT_THEME_MODE
}
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 startOfLocalDay(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
}
/** Anniversary of the contract date. Does not roll a missed date into next year. */
function nextAnnualRenewal(contractAt: string, now = new Date()) {
const contract = new Date(contractAt)
if (Number.isNaN(contract.getTime())) return null
const startToday = startOfLocalDay(now)
const contractDay = startOfLocalDay(contract)
const due =
contractDay.getTime() > startToday.getTime()
? contractDay
: new Date(
startToday.getFullYear(),
contract.getMonth(),
contract.getDate(),
)
const daysRemaining = Math.round(
(due.getTime() - startToday.getTime()) / (24 * 60 * 60 * 1000),
)
return { next: due, year: due.getFullYear(), daysRemaining }
}
function addOneYearToDueIso(contractAt: string, now = new Date()) {
const current = nextAnnualRenewal(contractAt, now)
if (!current) return null
const next = new Date(
current.next.getFullYear() + 1,
current.next.getMonth(),
current.next.getDate(),
12,
0,
0,
)
return next.toISOString()
}
function toDateInputValue(iso: string | null | undefined): string {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function renewalTone(daysRemaining: number): 'ok' | 'warn' | 'danger' {
const months = daysRemaining / 30.437
if (months > 8) return 'ok'
if (months >= 2) return 'warn'
return 'danger'
}
function formatRenewalCell(
annualRenewalAt: string | null,
createdAt?: string | null,
) {
const contractAt = annualRenewalAt || createdAt || null
if (!contractAt) {
return { dateLabel: '—', daysLabel: null as string | null, tone: null as 'ok' | 'warn' | 'danger' | null }
}
const next = nextAnnualRenewal(contractAt)
if (!next) {
return { dateLabel: '—', daysLabel: null, tone: null }
}
const overdue = next.daysRemaining < 0
const absDays = Math.abs(next.daysRemaining)
const daysLabel =
next.daysRemaining === 0
? 'Renews today'
: overdue
? absDays === 1
? '1 day past'
: `${absDays} days past`
: next.daysRemaining === 1
? '1 day left'
: `${next.daysRemaining} days left`
return {
dateLabel: next.next.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}),
daysLabel,
tone: overdue ? ('danger' as const) : renewalTone(next.daysRemaining),
}
}
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',
video_categories: 'Video categories',
video: 'Videos',
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: 'Videos',
items: [
['video_categories', 'Categories'],
['video', 'Videos'],
],
},
{
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,
video_categories: false,
video: 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 [editNameFa, setEditNameFa] = useState('')
const [editAbout, setEditAbout] = useState('')
const [editAnnualRenewalAt, setEditAnnualRenewalAt] = useState('')
const [editSubmitting, setEditSubmitting] = useState(false)
const [editError, setEditError] = useState('')
const [smsSettingsOpen, setSmsSettingsOpen] = useState(false)
const [smsSettingsBusiness, setSmsSettingsBusiness] = useState<BusinessListItem | null>(null)
const [smsSenderNumber, setSmsSenderNumber] = useState('')
const [smsTopUpIrt, setSmsTopUpIrt] = useState('')
const [smsSettingsSubmitting, setSmsSettingsSubmitting] = useState(false)
const [smsSettingsError, setSmsSettingsError] = 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 [domainUpdateDns, setDomainUpdateDns] = useState(false)
const [domainDnsProvider, setDomainDnsProvider] = useState<ParkedDnsProvider>('arvan')
const [domainSubmitting, setDomainSubmitting] = useState(false)
const [domainError, setDomainError] = useState('')
const [promptsOpen, setPromptsOpen] = useState(false)
const [promptsBusiness, setPromptsBusiness] = useState<BusinessListItem | null>(null)
const [domainPrompts, setDomainPrompts] = useState<DomainAiPrompt[]>([])
const [allPromptTemplates, setAllPromptTemplates] = useState<AiPromptTemplate[]>([])
const [promptsLoading, setPromptsLoading] = useState(false)
const [promptsError, setPromptsError] = useState('')
const [promptEditorOpen, setPromptEditorOpen] = useState(false)
const [editingDomainPrompt, setEditingDomainPrompt] = useState<DomainAiPrompt | null>(null)
const [promptDraftTemplateId, setPromptDraftTemplateId] = useState('')
const [promptDraftName, setPromptDraftName] = useState('')
const [promptDraftBody, setPromptDraftBody] = useState('')
const [promptEditorSubmitting, setPromptEditorSubmitting] = useState(false)
const [promptEditorError, setPromptEditorError] = useState('')
const [promptBusyId, setPromptBusyId] = useState<string | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [createName, setCreateName] = useState('')
const [createNameFa, setCreateNameFa] = useState('')
const [createAbout, setCreateAbout] = useState('')
const [createCategoryIds, setCreateCategoryIds] = useState<number[]>([])
const [createAnnualRenewalAt, setCreateAnnualRenewalAt] = useState('')
const [createOwnerTitleId, setCreateOwnerTitleId] = useState('')
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 [userTitles, setUserTitles] = useState<UserTitleOption[]>([])
const [removeTarget, setRemoveTarget] = useState<BusinessListItem | null>(null)
const [togglingId, setTogglingId] = useState<string | null>(null)
const [savingColorId, setSavingColorId] = useState<string | null>(null)
const [savingRenewalId, setSavingRenewalId] = 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 [migrateLookup, setMigrateLookup] = useState<LegacyMigrateLookupResponse | null>(null)
const [migrateLookupLoading, setMigrateLookupLoading] = useState(false)
const [migrateLookupError, setMigrateLookupError] = useState('')
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, titlesResult] = await Promise.all([
listBusinesses(
{
page,
pageSize,
...appliedFilters,
},
controller.signal,
),
listBusinessCategories(controller.signal),
listUserTitles(controller.signal),
])
if (controller.signal.aborted) return
setData(result)
setCategories(categoryResult.items)
setUserTitles(titlesResult.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)
setEditNameFa(b.nameFa ?? '')
setEditAbout(b.about ?? '')
setEditAnnualRenewalAt(
toDateInputValue(b.annualRenewalAt || b.createdAt),
)
setEditError('')
setEditOpen(true)
}
function openSmsSettings(b: BusinessListItem) {
setSmsSettingsBusiness(b)
setSmsSenderNumber(b.smsSenderNumber ?? '')
setSmsTopUpIrt('')
setSmsSettingsError('')
setSmsSettingsOpen(true)
}
async function openAiPrompts(b: BusinessListItem) {
setPromptsBusiness(b)
setPromptsOpen(true)
setPromptsError('')
setDomainPrompts([])
setAllPromptTemplates([])
setPromptEditorOpen(false)
setEditingDomainPrompt(null)
if (!b.domainId) {
setPromptsError('Add a website/domain for this business before assigning AI prompts.')
return
}
setPromptsLoading(true)
try {
const [allTemplates, assigned] = await Promise.all([
listAiPromptTemplates(),
listDomainAiPrompts(b.domainId),
])
setAllPromptTemplates(allTemplates.items)
setDomainPrompts(assigned.items)
} catch (err) {
setPromptsError(
err instanceof ApiError ? err.message : 'Unable to load AI prompts.',
)
} finally {
setPromptsLoading(false)
}
}
function openAddDomainPrompt() {
setEditingDomainPrompt(null)
setPromptDraftTemplateId('')
setPromptDraftName('')
setPromptDraftBody('')
setPromptEditorError('')
setPromptEditorOpen(true)
}
function openEditDomainPrompt(prompt: DomainAiPrompt) {
setEditingDomainPrompt(prompt)
setPromptDraftTemplateId(prompt.sourceTemplateId ?? '')
setPromptDraftName(prompt.name)
setPromptDraftBody(prompt.body)
setPromptEditorError('')
setPromptEditorOpen(true)
}
function applyTemplateToDraft(templateId: string) {
setPromptDraftTemplateId(templateId)
const template = allPromptTemplates.find((item) => item.id === templateId)
if (!template) return
setPromptDraftName(template.name)
setPromptDraftBody(template.body)
}
async function submitDomainPromptEditor() {
if (!promptsBusiness?.domainId) return
const name = promptDraftName.trim()
const body = promptDraftBody.trim()
if (!name) {
setPromptEditorError('Name is required.')
return
}
if (!body) {
setPromptEditorError('Prompt text is required.')
return
}
setPromptEditorSubmitting(true)
setPromptEditorError('')
try {
if (editingDomainPrompt) {
const updated = await updateDomainAiPrompt(
promptsBusiness.domainId,
editingDomainPrompt.id,
{ name, body },
)
setDomainPrompts((prev) =>
prev.map((item) => (item.id === updated.id ? updated : item)),
)
showToast('Prompt updated.', 'success')
} else {
const created = await createDomainAiPrompt(promptsBusiness.domainId, {
name,
body,
...(promptDraftTemplateId
? { sourceTemplateId: Number(promptDraftTemplateId) }
: {}),
})
setDomainPrompts((prev) => [...prev, created])
showToast('Prompt added.', 'success')
}
setPromptEditorOpen(false)
setEditingDomainPrompt(null)
} catch (err) {
setPromptEditorError(
err instanceof ApiError ? err.message : 'Unable to save prompt.',
)
} finally {
setPromptEditorSubmitting(false)
}
}
async function removeDomainPromptItem(prompt: DomainAiPrompt) {
if (!promptsBusiness?.domainId) return
setPromptBusyId(prompt.id)
try {
await deleteDomainAiPrompt(promptsBusiness.domainId, prompt.id)
setDomainPrompts((prev) => prev.filter((item) => item.id !== prompt.id))
showToast('Prompt removed.', 'success')
} catch (err) {
showToast(
err instanceof ApiError ? err.message : 'Unable to remove prompt.',
'error',
)
} finally {
setPromptBusyId(null)
}
}
async function copyDomainPromptLink(url: string) {
try {
await navigator.clipboard.writeText(url)
showToast('Public link copied.', 'success')
} catch {
showToast('Unable to copy link.', 'error')
}
}
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,
),
}
})
try {
await updateBusinessPrimaryColor(b.id, primaryColor)
showToast(`Theme color 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,
),
}
})
showToast(
err instanceof ApiError ? err.message : 'Unable to update theme color.',
'error',
)
} finally {
setSavingColorId(null)
}
}
async function handleThemeModeChange(
b: BusinessListItem,
themeMode: DashboardThemeMode,
) {
const currentMode = normalizeThemeMode(b.themeMode)
if (currentMode === themeMode) return
setSavingColorId(b.id)
setError('')
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === b.id ? { ...item, themeMode } : item,
),
}
})
try {
await updateBusinessThemeMode(b.id, themeMode)
showToast(
`${themeMode === 'dark' ? 'Dark' : 'Light'} theme set for "${b.name}".`,
'success',
)
} catch (err) {
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === b.id ? { ...item, themeMode: currentMode } : item,
),
}
})
showToast(
err instanceof ApiError ? err.message : 'Unable to update theme mode.',
'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,
),
}
})
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,
),
}
})
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
const name = editName.trim()
const nameFa = editNameFa.trim()
if (!name || !nameFa) return
setEditSubmitting(true)
setEditError('')
const annualRenewalAt = editAnnualRenewalAt
? new Date(editAnnualRenewalAt).toISOString()
: editBusiness.createdAt
? new Date(editBusiness.createdAt).toISOString()
: null
try {
await updateBusiness(editBusiness.id, {
name,
nameFa,
about: editAbout.trim(),
annualRenewalAt,
})
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === editBusiness.id
? {
...item,
name,
nameFa,
about: editAbout.trim() || null,
annualRenewalAt,
}
: item,
),
}
})
setEditOpen(false)
setEditBusiness(null)
showToast('Business updated.', 'success')
} catch (err) {
setEditError(
err instanceof ApiError ? err.message : 'Unable to update business.',
)
} finally {
setEditSubmitting(false)
}
}
async function submitSmsSettings() {
if (!smsSettingsBusiness) return
const sender = smsSenderNumber.replace(/\D/g, '')
const topUpParsed = parseIrtInput(smsTopUpIrt) ?? undefined
if (
topUpParsed !== undefined &&
(!Number.isFinite(topUpParsed) || topUpParsed < 1)
) {
setSmsSettingsError('Top-up amount must be a positive integer (IRT).')
return
}
if (topUpParsed !== undefined && topUpParsed < 500) {
setSmsSettingsError('Minimum top-up is 500 IRT (1 SMS credit).')
return
}
const creditsAdded =
topUpParsed !== undefined ? Math.floor(topUpParsed / 500) : 0
setSmsSettingsSubmitting(true)
setSmsSettingsError('')
try {
await updateBusiness(smsSettingsBusiness.id, {
smsSenderNumber: sender || null,
...(topUpParsed !== undefined ? { smsTopUpIrt: topUpParsed } : {}),
})
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === smsSettingsBusiness.id
? {
...item,
smsSenderNumber: sender || null,
smsBalance: (item.smsBalance ?? 0) + creditsAdded,
}
: item,
),
}
})
setSmsSettingsOpen(false)
setSmsSettingsBusiness(null)
showToast(
creditsAdded > 0
? `SMS settings saved. Added ${creditsAdded.toLocaleString('en-US')} credits.`
: 'SMS settings saved.',
'success',
)
} catch (err) {
setSmsSettingsError(
err instanceof ApiError ? err.message : 'Unable to save SMS settings.',
)
} finally {
setSmsSettingsSubmitting(false)
}
}
async function restartRenewalCountdown(b: BusinessListItem) {
const contractAt = b.annualRenewalAt || b.createdAt
const annualRenewalAt = addOneYearToDueIso(contractAt)
if (!annualRenewalAt) {
showToast('Unable to restart renewal countdown.', 'error')
return
}
setSavingRenewalId(b.id)
try {
await updateBusiness(b.id, { annualRenewalAt })
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === b.id ? { ...item, annualRenewalAt } : item,
),
}
})
showToast(`Renewal countdown restarted for "${b.name}".`, 'success')
} catch (err) {
showToast(
err instanceof ApiError
? err.message
: 'Unable to restart renewal countdown.',
'error',
)
} finally {
setSavingRenewalId(null)
}
}
function openDomainEdit(b: BusinessListItem) {
setDomainBusiness(b)
setDomainId(b.domainId)
setDomainHost(b.domain ?? '')
setDomainGitRepoUrl(b.gitRepoUrl ?? '')
setDomainUpdateDns(!b.domainId)
setDomainDnsProvider('arvan')
setDomainError('')
setDomainOpen(true)
}
function openMigrate(b: BusinessListItem) {
setMigrateBusiness(b)
setMigrateOldId(b.oldBusinessId != null ? String(b.oldBusinessId) : '')
setMigrateEntities(emptyMigrateEntities())
setMigrateError('')
setMigrateResult(null)
setMigrateLookup(null)
setMigrateLookupError('')
setMigrateOpen(true)
}
const migrateSubmittingRef = useRef(false)
migrateSubmittingRef.current = migrateSubmitting
const closeMigrate = useCallback(() => {
if (migrateSubmittingRef.current) return
setMigrateOpen(false)
setMigrateBusiness(null)
setMigrateError('')
setMigrateResult(null)
setMigrateLookup(null)
setMigrateLookupError('')
}, [])
useEffect(() => {
if (!migrateOpen || !migrateBusiness) return
const trimmed = String(migrateOldId).trim()
const explicitId = Number(trimmed)
const hasExplicitId = trimmed !== '' && Number.isInteger(explicitId) && explicitId > 0
const controller = new AbortController()
const timer = window.setTimeout(() => {
void (async () => {
setMigrateLookupLoading(true)
setMigrateLookupError('')
try {
const result = await lookupLegacyMigrate(
migrateBusiness.id,
hasExplicitId ? explicitId : undefined,
controller.signal,
)
if (controller.signal.aborted) return
setMigrateLookup(result)
// Auto-fill from Meshkee domain match when the field is empty
if (
!hasExplicitId &&
result.source === 'meshkee_domain' &&
result.oldBusinessId
) {
setMigrateOldId(result.oldBusinessId)
}
} catch (err) {
if (isAbortError(err)) return
setMigrateLookup(null)
setMigrateLookupError(
err instanceof ApiError
? err.message
: 'Unable to look up old CMS domain.',
)
} finally {
if (!controller.signal.aborted) setMigrateLookupLoading(false)
}
})()
}, hasExplicitId ? 350 : 0)
return () => {
controller.abort()
window.clearTimeout(timer)
}
}, [migrateOpen, migrateBusiness, migrateOldId])
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)
}
}
const domainSubmitGenRef = useRef(0)
function closeDomainModal() {
domainSubmitGenRef.current += 1
setDomainSubmitting(false)
setDomainOpen(false)
setDomainBusiness(null)
setDomainId(null)
setDomainHost('')
setDomainGitRepoUrl('')
setDomainUpdateDns(false)
setDomainDnsProvider('arvan')
setDomainError('')
}
async function submitDomain() {
if (!domainBusiness) return
const host = domainHost.trim()
if (!host) return
const gitRepoUrl = domainGitRepoUrl.trim()
const businessId = domainBusiness.id
const submitId = ++domainSubmitGenRef.current
setDomainSubmitting(true)
setDomainError('')
try {
if (domainId) {
const updated = (await updateBusinessDomain(businessId, domainId, {
host,
updateDns: domainUpdateDns,
...(domainUpdateDns ? { dnsProvider: domainDnsProvider } : {}),
...(gitRepoUrl ? { gitRepoUrl } : {}),
})) as {
id: number
host: string
deploySlug?: string | null
gitRepoUrl?: string | null
provisionError?: string | null
dnsError?: string | null
}
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === businessId
? {
...item,
domain: host,
...(gitRepoUrl && !updated.provisionError
? {
gitRepoUrl: updated.gitRepoUrl ?? gitRepoUrl,
deploySlug: updated.deploySlug ?? item.deploySlug,
}
: {}),
}
: item,
),
}
})
if (updated.provisionError) {
showToast(`Domain updated to "${host}", but storefront setup failed.`, 'error')
showToast(updated.provisionError, 'error')
}
if (updated.dnsError) {
showToast(`Domain updated to "${host}", but Arvan DNS failed.`, 'error')
showToast(updated.dnsError, 'error')
} else if (!updated.provisionError && gitRepoUrl && updated.deploySlug) {
showToast(
`Domain updated. Storefront deploy is ready on Websites.`,
'success',
)
} else if (!updated.provisionError) {
showToast(`Domain updated to "${host}".`, 'success')
}
} else {
const created = (await addBusinessDomain(businessId, {
host,
isPrimary: true,
updateDns: domainUpdateDns,
...(domainUpdateDns ? { dnsProvider: domainDnsProvider } : {}),
...(gitRepoUrl ? { gitRepoUrl } : {}),
})) as {
id: number
host: string
sslEnabled: boolean
deploySlug?: string | null
gitRepoUrl?: string | null
provisionError?: string | null
dnsError?: string | null
}
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === businessId
? {
...item,
domainId: created.id,
domain: created.host,
sslEnabled: created.sslEnabled ?? false,
...(gitRepoUrl && !created.provisionError
? {
gitRepoUrl: created.gitRepoUrl ?? gitRepoUrl,
deploySlug: created.deploySlug ?? null,
}
: {}),
}
: item,
),
}
})
if (created.provisionError) {
showToast(`Domain "${host}" added, but storefront setup failed.`, 'error')
showToast(created.provisionError, 'error')
}
if (created.dnsError) {
showToast(`Domain "${host}" added, but Arvan DNS failed.`, 'error')
showToast(created.dnsError, 'error')
} else if (!created.provisionError && gitRepoUrl && created.deploySlug) {
showToast(
`Domain "${host}" added. Storefront deploy is ready on Websites.`,
'success',
)
} else if (!created.provisionError) {
showToast(`Domain "${host}" added.`, 'success')
}
}
if (submitId !== domainSubmitGenRef.current) return
setDomainOpen(false)
setDomainBusiness(null)
setDomainId(null)
setDomainHost('')
setDomainGitRepoUrl('')
setDomainUpdateDns(false)
setDomainDnsProvider('arvan')
setDomainError('')
} catch (err) {
if (submitId !== domainSubmitGenRef.current) return
setDomainError(err instanceof ApiError ? err.message : 'Unable to save domain.')
} finally {
if (submitId === domainSubmitGenRef.current) {
setDomainSubmitting(false)
}
}
}
function resetCreateForm() {
setCreateName('')
setCreateNameFa('')
setCreateAbout('')
setCreateCategoryIds([])
setCreateAnnualRenewalAt(toDateInputValue(new Date().toISOString()))
setCreateOwnerTitleId('')
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,
...(createAnnualRenewalAt
? { annualRenewalAt: new Date(createAnnualRenewalAt).toISOString() }
: {}),
...(createOwnerTitleId
? { ownerTitleId: Number(createOwnerTitleId) }
: {}),
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}>
<form className={styles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
<div className={styles.filtersInputs}>
<div className={styles.field}>
<input
id="filter-name"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder="Name"
aria-label="Name"
/>
</div>
<div className={styles.field}>
<input
id="filter-domain"
value={draftDomain}
onChange={(e) => setDraftDomain(e.target.value)}
placeholder="Domain"
aria-label="Domain"
/>
</div>
<div className={styles.field}>
<input
id="filter-category"
value={draftCategory}
onChange={(e) => setDraftCategory(e.target.value)}
placeholder="Category"
aria-label="Category"
/>
</div>
</div>
<div className={styles.filterActions}>
<button
type="submit"
className={`${styles.iconActionBtn} ${styles.iconActionBtnPrimary}`}
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>
</form>
</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}>Contract Data / Renewal</th>
<th className={styles.th}>Domain</th>
<th className={styles.th}>Owner</th>
<th className={`${styles.th} ${styles.thSms}`}>SMS sent</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={9}>
Loading...
</td>
</tr>
)}
{!loading && data?.items?.length === 0 && (
<tr>
<td className={styles.td} colSpan={9}>
No results found.
</td>
</tr>
)}
{!loading &&
data?.items?.map((b) => {
const renewal = formatRenewalCell(
b.annualRenewalAt ?? null,
b.createdAt,
)
return (
<tr key={b.id}>
<td className={styles.td}>{b.name}</td>
<td className={styles.td}>
<div className={styles.renewalCell}>
<span className={styles.renewalDate}>{renewal.dateLabel}</span>
{renewal.daysLabel ? (
<div className={styles.renewalDaysRow}>
<span
className={`${styles.renewalDays} ${
renewal.tone === 'ok'
? styles.renewalOk
: renewal.tone === 'warn'
? styles.renewalWarn
: styles.renewalDanger
}`}
>
{renewal.daysLabel}
</span>
<button
type="button"
className={styles.renewalRefreshBtn}
onClick={() => void restartRenewalCountdown(b)}
disabled={savingRenewalId === b.id}
aria-label={`Advance renewal one year from the contract date for ${b.name}`}
title="Advance one year from the contract date"
>
<RefreshCw
size={12}
aria-hidden="true"
className={
savingRenewalId === b.id
? styles.renewalRefreshSpin
: undefined
}
/>
</button>
</div>
) : null}
</div>
</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.tdSms}`}>
{(b.smsSentCount ?? 0).toLocaleString('en-US')}
</td>
<td className={`${styles.td} ${styles.tdTheme}`}>
<PrimaryColorSwatchControl
value={normalizeBusinessPrimaryColorId(b.primaryColor)}
themeMode={normalizeThemeMode(b.themeMode)}
disabled={savingColorId === b.id}
onChange={(primaryColor) =>
void handlePrimaryColorChange(b, primaryColor)
}
onThemeModeChange={(themeMode) =>
void handleThemeModeChange(b, themeMode)
}
/>
</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}
host={b.domain}
onAiPrompts={() => void openAiPrompts(b)}
onSmsSettings={() => openSmsSettings(b)}
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={promptsOpen}
title={
promptsBusiness
? `AI prompts · ${promptsBusiness.name}`
: 'AI prompts'
}
wide
onClose={() => {
if (promptEditorSubmitting) return
setPromptsOpen(false)
setPromptsBusiness(null)
setPromptsError('')
setPromptEditorOpen(false)
}}
>
{promptsError ? (
<p className={styles.alertError} role="alert">
{promptsError}
</p>
) : null}
{promptsLoading ? (
<p className={styles.subText}>Loading…</p>
) : promptsBusiness?.domainId ? (
domainPrompts.length === 0 ? (
<div className={styles.promptsEmpty}>
<FileText size={20} aria-hidden="true" />
<p>No prompts yet. Use Add to create one from a template.</p>
</div>
) : (
<ul className={styles.promptsList}>
{domainPrompts.map((prompt) => (
<li key={prompt.id} className={styles.promptsRow}>
<div className={styles.promptsRowText}>
<span className={styles.promptsRowName}>{prompt.name}</span>
<span className={styles.promptsRowPreview}>{prompt.body}</span>
</div>
<div className={styles.promptsRowActions}>
<button
type="button"
className={styles.controlBtn}
onClick={() => openEditDomainPrompt(prompt)}
disabled={promptBusyId === prompt.id}
aria-label={`Edit ${prompt.name}`}
title="Edit"
>
<Pencil size={15} />
</button>
<button
type="button"
className={styles.controlBtn}
onClick={() => void copyDomainPromptLink(prompt.publicUrl)}
disabled={promptBusyId === prompt.id}
aria-label={`Copy link for ${prompt.name}`}
title="Copy public link"
>
<Copy size={15} />
</button>
<button
type="button"
className={styles.controlBtn}
onClick={() => void removeDomainPromptItem(prompt)}
disabled={promptBusyId === prompt.id}
aria-label={`Remove ${prompt.name}`}
title="Remove"
>
<Trash2 size={15} />
</button>
</div>
</li>
))}
</ul>
)
) : null}
<div style={{ height: 12 }} />
<div className={styles.actionsRow}>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
onClick={() => setPromptsOpen(false)}
disabled={promptEditorSubmitting}
>
Close
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={openAddDomainPrompt}
disabled={promptEditorSubmitting || !promptsBusiness?.domainId || promptsLoading}
>
<Plus size={16} />
Add
</button>
</div>
</Modal>
<Modal
open={promptEditorOpen}
title={editingDomainPrompt ? 'Edit prompt' : 'Add prompt'}
wide
onClose={() => {
if (promptEditorSubmitting) return
setPromptEditorOpen(false)
setEditingDomainPrompt(null)
setPromptEditorError('')
}}
>
{promptEditorError ? (
<p className={styles.alertError} role="alert">
{promptEditorError}
</p>
) : null}
<div className={styles.field}>
<label htmlFor="domain-prompt-template">Template</label>
<select
id="domain-prompt-template"
value={promptDraftTemplateId}
onChange={(e) => applyTemplateToDraft(e.target.value)}
disabled={promptEditorSubmitting || allPromptTemplates.length === 0}
>
<option value="">Select a template (optional)</option>
{allPromptTemplates.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
<div className={styles.field}>
<label htmlFor="domain-prompt-name">Name</label>
<input
id="domain-prompt-name"
value={promptDraftName}
onChange={(e) => setPromptDraftName(e.target.value)}
placeholder="Prompt name"
disabled={promptEditorSubmitting}
/>
</div>
<div className={styles.field}>
<label htmlFor="domain-prompt-body">Prompt</label>
<textarea
id="domain-prompt-body"
className={styles.promptsTextarea}
value={promptDraftBody}
onChange={(e) => setPromptDraftBody(e.target.value)}
placeholder="Prompt text…"
disabled={promptEditorSubmitting}
rows={12}
/>
</div>
<div style={{ height: 12 }} />
<div className={styles.actionsRow}>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
onClick={() => setPromptEditorOpen(false)}
disabled={promptEditorSubmitting}
>
Cancel
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={() => void submitDomainPromptEditor()}
disabled={promptEditorSubmitting}
>
{editingDomainPrompt ? 'Save' : 'Add'}
</button>
</div>
</Modal>
<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-en">Name (EN)</label>
<input
id="edit-name-en"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Business name"
disabled={editSubmitting}
/>
</div>
<div className={styles.field}>
<label htmlFor="edit-name-fa">Name (FA)</label>
<input
id="edit-name-fa"
className="faText"
value={editNameFa}
onChange={(e) => setEditNameFa(e.target.value)}
placeholder="نام فارسی"
dir="rtl"
lang="fa"
disabled={editSubmitting}
/>
</div>
<div className={styles.field}>
<label htmlFor="edit-annual-renewal">Annual renewal</label>
<StepDatePicker
id="edit-annual-renewal"
value={editAnnualRenewalAt}
onChange={setEditAnnualRenewalAt}
disabled={editSubmitting}
placeholder="Select renewal date"
/>
</div>
<div className={styles.field}>
<label htmlFor="edit-about">About business</label>
<textarea
id="edit-about"
value={editAbout}
onChange={(e) => setEditAbout(e.target.value)}
placeholder="Short description about the business"
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() || !editNameFa.trim()}
>
Save
</button>
</div>
</Modal>
<Modal
open={smsSettingsOpen}
title={
smsSettingsBusiness
? `SMS settings · ${smsSettingsBusiness.name}`
: 'SMS settings'
}
onClose={() => {
if (smsSettingsSubmitting) return
setSmsSettingsOpen(false)
setSmsSettingsBusiness(null)
setSmsSettingsError('')
}}
>
{smsSettingsError ? (
<p className={styles.alertError} role="alert">
{smsSettingsError}
</p>
) : null}
<div className={styles.field}>
<label htmlFor="sms-settings-sender">SMS sender number</label>
<input
id="sms-settings-sender"
inputMode="numeric"
value={smsSenderNumber}
onChange={(e) => setSmsSenderNumber(e.target.value.replace(/\D/g, '').slice(0, 16))}
placeholder="e.g. 5000123456 (empty = 5000990009)"
disabled={smsSettingsSubmitting}
autoComplete="off"
/>
<p className={styles.helperText}>
Used for free-text customer SMS. Leave empty for Meshkee advertising line
5000990009. Verified templates always use 5000110005.
</p>
</div>
<div className={styles.field}>
<div
className={`${styles.dataRow} ${
(smsSettingsBusiness?.smsBalance ?? 0) < 400
? styles.dataRowLow
: styles.dataRowOk
}`}
role="status"
aria-label="Current SMS credit balance"
>
<span className={styles.dataRowLabel}>Current SMS balance</span>
<strong className={styles.dataRowValue}>
{(smsSettingsBusiness?.smsBalance ?? 0).toLocaleString('en-US')} SMS credits
</strong>
</div>
<p className={styles.helperText}>
Prepaid SMS credits. 1 credit = one 64-character segment per recipient (OTP,
customer SMS, partner SMS). New businesses start with 1,000 credits. Values below
400 credits show as low balance in the business dashboard.
</p>
</div>
<div className={styles.field}>
<label htmlFor="sms-settings-topup">Add top-up (IRT)</label>
<input
id="sms-settings-topup"
type="text"
inputMode="numeric"
value={smsTopUpIrt}
onChange={(e) => {
const digits = e.target.value.replace(/\D/g, '').slice(0, 12)
setSmsTopUpIrt(formatIrtInput(digits))
}}
placeholder="e.g. 1,000,000"
disabled={smsSettingsSubmitting}
autoComplete="off"
/>
{(() => {
const irt = parseIrtInput(smsTopUpIrt) ?? 0
const credits = Math.floor(irt / 500)
const current = smsSettingsBusiness?.smsBalance ?? 0
const next = current + credits
return (
<p className={styles.smsTopUpCredits} aria-live="polite">
{irt > 0
? `SMS credit charge: +${credits.toLocaleString('en-US')} → new balance ${next.toLocaleString('en-US')} (was ${current.toLocaleString('en-US')})`
: 'SMS credit charge: —'}
</p>
)
})()}
<p className={styles.helperText}>
Each SMS credit costs 500 IRT (1,000,000 → +2,000 credits). Top-ups are added to
the current balance — they never reset it. Leave empty to only update the sender
number.
</p>
</div>
<div className={styles.field}>
<div className={styles.dataRowStack} role="group" aria-label="SMS sent by type">
<div
className={`${styles.dataRow} ${styles.dataRowNeutral}`}
role="status"
>
<span className={styles.dataRowLabel}>SMS sent (total)</span>
<strong className={styles.dataRowValue}>
{(smsSettingsBusiness?.smsSentCount ?? 0).toLocaleString('en-US')}
</strong>
</div>
<div className={styles.smsSentTypesRow}>
{(
[
['Mass send', smsSettingsBusiness?.smsSentByType?.mass],
['Singular', smsSettingsBusiness?.smsSentByType?.singular],
['Auth service', smsSettingsBusiness?.smsSentByType?.auth],
[
'Auto customer',
smsSettingsBusiness?.smsSentByType?.autoCustomer,
],
[
'Admin notification',
smsSettingsBusiness?.smsSentByType?.admin,
],
] as const
).map(([label, value]) => (
<div key={label} className={styles.smsSentTypeCell} role="status">
<span className={styles.smsSentTypeLabel}>{label}</span>
<strong className={styles.smsSentTypeValue}>
{(value ?? 0).toLocaleString('en-US')}
</strong>
</div>
))}
</div>
</div>
<p className={styles.helperText}>
Lifetime messages billed (one per recipient), split by type. Auto customer and
admin notification are reserved for upcoming flows.
</p>
</div>
<div style={{ height: 12 }} />
<div className={styles.actionsRow}>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
onClick={() => {
setSmsSettingsOpen(false)
setSmsSettingsBusiness(null)
}}
disabled={smsSettingsSubmitting}
>
Cancel
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={() => void submitSmsSettings()}
disabled={smsSettingsSubmitting}
>
Save
</button>
</div>
</Modal>
<Modal
open={domainOpen}
title={domainId ? 'Edit domain' : 'Add domain'}
onClose={closeDomainModal}
>
{domainError ? (
<p className={styles.alertError} role="alert">
{domainError}
</p>
) : null}
{domainSubmitting && (domainGitRepoUrl.trim() || domainUpdateDns) ? (
<p className={styles.helperText} role="status">
{domainGitRepoUrl.trim()
? 'Saving domain and setting up storefront on the websites server…'
: `Saving domain and updating ${domainDnsProvider === 'cloudflare' ? 'Cloudflare' : 'Arvan'} DNS…`}
</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>
<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="https://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>
<div style={{ height: 12 }} />
<div className={styles.dnsUpdateRow}>
<label className={styles.entityCheck} htmlFor="domain-update-dns">
<input
id="domain-update-dns"
type="checkbox"
checked={domainUpdateDns}
onChange={(e) => setDomainUpdateDns(e.target.checked)}
disabled={domainSubmitting}
/>
<span>Update DNS records</span>
</label>
<DnsProviderSwitch
value={domainDnsProvider}
onChange={setDomainDnsProvider}
disabled={domainSubmitting || !domainUpdateDns}
/>
</div>
<p className={styles.subText} style={{ marginTop: 6 }}>
{domainDnsProvider === 'cloudflare'
? 'When checked, upserts Cloudflare records (DNS-only): @ CNAME → ns.meshkee.com, plus www, business, customer, and api CNAMEs.'
: 'When checked, upserts Arvan records: @ ANAME → ns.meshkee.com, plus www, business, customer, and api CNAMEs.'}
</p>
<div style={{ height: 12 }} />
<div className={styles.actionsRow}>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
onClick={closeDomainModal}
>
Cancel
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={() => void submitDomain()}
disabled={domainSubmitting || !domainHost.trim()}
>
{domainSubmitting ? 'Saving…' : 'Save'}
</button>
</div>
</Modal>
<Modal
open={modulesOpen}
title={
modulesBusiness
? `Modules — ${modulesBusiness.name}`
: 'Modules'
}
onClose={closeModules}
wide
>
{modulesError ? (
<p className={styles.alertError} role="alert">
{modulesError}
</p>
) : null}
<p className={styles.helperText}>
Choose which optional modules this business can use. Home, profile,
website, and other general sections stay available for every business.
</p>
<div className={styles.modulesSection}>
<p className={styles.entityGroupLabel}>Business modules</p>
<div className={styles.modulesChecks}>
{BUSINESS_DASHBOARD_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_DASHBOARD_MODULE_LABELS[id]}</span>
</label>
))}
</div>
</div>
<div className={styles.modulesSection}>
<p className={styles.entityGroupLabel}>Customer modules</p>
<div className={styles.modulesChecks}>
{CUSTOMER_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>{CUSTOMER_MODULE_LABELS[id]}</span>
</label>
))}
</div>
</div>
<div className={styles.modulesCharts}>
<p className={styles.entityGroupLabel}>Home charts</p>
<div className={styles.modulesChartsRow}>
<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>
<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. The old id is resolved from the Meshkee domain when possible.
</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>
{migrateLookupLoading ? (
<p className={styles.alertInfo} role="status">
Looking up old CMS domain…
</p>
) : null}
{migrateLookupError ? (
<p className={styles.alertError} role="alert">
{migrateLookupError}
</p>
) : null}
{migrateLookup && !migrateLookupLoading ? (
migrateLookup.oldBusinessId && migrateLookup.domains.length > 0 ? (
<div className={styles.alertInfo} role="status">
<strong>Old CMS match</strong>
<div style={{ marginTop: 6 }}>
Domain{migrateLookup.domains.length > 1 ? 's' : ''}:{' '}
<strong dir="ltr">{migrateLookup.domains.join(', ')}</strong>
</div>
{(migrateLookup.brandEn || migrateLookup.brandFa) && (
<div style={{ marginTop: 4 }}>
Brand:{' '}
<strong>
{[migrateLookup.brandEn, migrateLookup.brandFa]
.filter(Boolean)
.join(' / ')}
</strong>
</div>
)}
{migrateLookup.matchedHost ? (
<div style={{ marginTop: 4 }}>
Matched from Meshkee host:{' '}
<strong dir="ltr">{migrateLookup.matchedHost}</strong>
</div>
) : null}
{migrateLookup.meshkeeHosts.length > 0 ? (
<div style={{ marginTop: 4, opacity: 0.85 }}>
This business hosts:{' '}
<span dir="ltr">{migrateLookup.meshkeeHosts.join(', ')}</span>
</div>
) : null}
</div>
) : (
<p className={styles.alertInfo} role="status">
{migrateOldId.trim()
? 'No old CMS business found for this id. Check the number before migrating.'
: migrateLookup.meshkeeHosts.length
? `No old CMS domain match for ${migrateLookup.meshkeeHosts.join(', ')}. Enter the old business id manually.`
: 'This Meshkee business has no domain yet. Enter the old business id manually.'}
</p>
)
) : null}
<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}
<p className={styles.helperText} style={{ marginTop: 0, marginBottom: 12 }}>
New businesses receive an initial SMS balance of 1,000 credits (1 credit = 64 characters
per recipient; top-ups cost 500 IRT per credit).
</p>
<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.formCol8}`}>
<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.formCol4}`}>
<label htmlFor="create-annual-renewal">Annual renewal</label>
<StepDatePicker
id="create-annual-renewal"
value={createAnnualRenewalAt}
onChange={setCreateAnnualRenewalAt}
disabled={createSubmitting}
placeholder="Select renewal date"
/>
</div>
<div className={`${styles.field} ${styles.formFull}`}>
<div className={styles.sectionLabel}>Owner</div>
</div>
<div className={`${styles.field} ${styles.formCol3}`}>
<label htmlFor="create-owner-title">Title</label>
<select
id="create-owner-title"
value={createOwnerTitleId}
onChange={(e) => setCreateOwnerTitleId(e.target.value)}
disabled={createSubmitting || userTitles.length === 0}
>
<option value="">—</option>
{userTitles.map((title) => (
<option key={title.id} value={title.id}>
{title.nameEn} / {title.nameFa}
</option>
))}
</select>
</div>
<div className={`${styles.field} ${styles.formCol4}`}>
<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} ${styles.formCol5}`}>
<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}>
<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}>
<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>
)
}