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 { const selected = new Set(enabled) return Object.fromEntries( BUSINESS_MODULE_IDS.map((id) => [id, selected.has(id)]), ) as Record } 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 = { 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 { 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(null) const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [categories, setCategories] = useState([]) const [appliedFilters, setAppliedFilters] = useState>( {}, ) 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(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(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(null) const [domainId, setDomainId] = useState(null) const [domainHost, setDomainHost] = useState('') const [domainGitRepoUrl, setDomainGitRepoUrl] = useState('') const [domainUpdateDns, setDomainUpdateDns] = useState(false) const [domainDnsProvider, setDomainDnsProvider] = useState('arvan') const [domainSubmitting, setDomainSubmitting] = useState(false) const [domainError, setDomainError] = useState('') const [promptsOpen, setPromptsOpen] = useState(false) const [promptsBusiness, setPromptsBusiness] = useState(null) const [domainPrompts, setDomainPrompts] = useState([]) const [allPromptTemplates, setAllPromptTemplates] = useState([]) const [promptsLoading, setPromptsLoading] = useState(false) const [promptsError, setPromptsError] = useState('') const [promptEditorOpen, setPromptEditorOpen] = useState(false) const [editingDomainPrompt, setEditingDomainPrompt] = useState(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(null) const [createOpen, setCreateOpen] = useState(false) const [createName, setCreateName] = useState('') const [createNameFa, setCreateNameFa] = useState('') const [createAbout, setCreateAbout] = useState('') const [createCategoryIds, setCreateCategoryIds] = useState([]) 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([]) const [removeTarget, setRemoveTarget] = useState(null) const [togglingId, setTogglingId] = useState(null) const [savingColorId, setSavingColorId] = useState(null) const [savingRenewalId, setSavingRenewalId] = useState(null) const [savingLocaleId, setSavingLocaleId] = useState(null) const [modulesOpen, setModulesOpen] = useState(false) const [modulesBusiness, setModulesBusiness] = useState(null) const [modulesSelected, setModulesSelected] = useState>( () => 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(null) const [migrateOldId, setMigrateOldId] = useState('') const [migrateEntities, setMigrateEntities] = useState>( () => emptyMigrateEntities(), ) const [migrateSubmitting, setMigrateSubmitting] = useState(false) const [migrateError, setMigrateError] = useState('') const [migrateResult, setMigrateResult] = useState(null) const [migrateLookup, setMigrateLookup] = useState(null) const [migrateLookupLoading, setMigrateLookupLoading] = useState(false) const [migrateLookupError, setMigrateLookupError] = useState('') const [purgeOpen, setPurgeOpen] = useState(false) const [purgeBusiness, setPurgeBusiness] = useState(null) const [purgeEntities, setPurgeEntities] = useState>( () => emptyMigrateEntities(), ) const [purgeSubmitting, setPurgeSubmitting] = useState(false) const [purgeError, setPurgeError] = useState('') const [purgeResult, setPurgeResult] = useState(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 (

Businesses

Manage businesses, domains, and SSL settings.

{ e.preventDefault(); applyFilters() }}>
setDraftName(e.target.value)} placeholder="Name" aria-label="Name" />
setDraftDomain(e.target.value)} placeholder="Domain" aria-label="Domain" />
setDraftCategory(e.target.value)} placeholder="Category" aria-label="Category" />
Business list
{data ? ( <> Showing {showingFrom} - {showingTo} of {data.total} ) : ( ' ' )}
{error && (
{error}
)}
{loading && ( )} {!loading && data?.items?.length === 0 && ( )} {!loading && data?.items?.map((b) => { const renewal = formatRenewalCell( b.annualRenewalAt ?? null, b.createdAt, ) return ( ) })}
Name Contract Data / Renewal Domain Owner SMS sent Theme Lang Modules Actions
Loading...
No results found.
{b.name}
{renewal.dateLabel} {renewal.daysLabel ? (
{renewal.daysLabel}
) : null}
{b.domain ? (
{b.domain} {b.sslEnabled ? ( ) : ( )}
) : ( No domain )}
{b.ownerNameEn || '—'}
{b.ownerCellNumber ? formatCellForDisplay(b.ownerCellNumber) : '—'}
{(b.smsSentCount ?? 0).toLocaleString('en-US')} void handlePrimaryColorChange(b, primaryColor) } onThemeModeChange={(themeMode) => void handleThemeModeChange(b, themeMode) } />
void handleToggleActive(b, isActive)} /> void openAiPrompts(b)} onSmsSettings={() => openSmsSettings(b)} onAllUsers={() => goToBusinessUsers(b, 'all')} onWipeData={() => openPurge(b)} onRemove={() => setRemoveTarget(b)} />
Page {page} / {totalPages} · {pageSize} per page · {data?.total ?? 0} total
{ if (promptEditorSubmitting) return setPromptsOpen(false) setPromptsBusiness(null) setPromptsError('') setPromptEditorOpen(false) }} > {promptsError ? (

{promptsError}

) : null} {promptsLoading ? (

Loading…

) : promptsBusiness?.domainId ? ( domainPrompts.length === 0 ? (
) : (
    {domainPrompts.map((prompt) => (
  • {prompt.name} {prompt.body}
  • ))}
) ) : null}
{ if (promptEditorSubmitting) return setPromptEditorOpen(false) setEditingDomainPrompt(null) setPromptEditorError('') }} > {promptEditorError ? (

{promptEditorError}

) : null}
setPromptDraftName(e.target.value)} placeholder="Prompt name" disabled={promptEditorSubmitting} />