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>
2903 lines
98 KiB
TypeScript
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>
|
|
)
|
|
}
|