mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Super Admin can opt into websites VM provision so Deploy shows on Websites without manual slug maps. Co-authored-by: Cursor <cursoragent@cursor.com>
1841 lines
61 KiB
TypeScript
1841 lines
61 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import {
|
|
AlertTriangle,
|
|
Database,
|
|
FileText,
|
|
Globe,
|
|
Lock,
|
|
Pencil,
|
|
Plus,
|
|
RotateCcw,
|
|
Search,
|
|
UserCog,
|
|
} from 'lucide-react'
|
|
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
|
import { BusinessActionsOverflowMenu } from '../components/BusinessActionsOverflowMenu'
|
|
import { ToggleSwitch } from '../components/ToggleSwitch'
|
|
import { formatCellForDisplay, toE164CellNumber } from '../lib/cellNumber'
|
|
import type {
|
|
BusinessesListResponse,
|
|
BusinessListItem,
|
|
MigrateEntityResult,
|
|
MigrateFromOldEntity,
|
|
MigrateFromOldResponse,
|
|
PurgeBusinessDataEntity,
|
|
PurgeBusinessDataResponse,
|
|
PurgeEntityResult,
|
|
} from '../types/business'
|
|
import type { BusinessCategory } from '../types/category'
|
|
import type { ListBusinessesParams } from '../services/businessService'
|
|
import { ApiError, isAbortError } from '../lib/api'
|
|
import {
|
|
addBusinessDomain,
|
|
createBusiness,
|
|
listBusinesses,
|
|
migrateBusinessFromOld,
|
|
purgeBusinessData,
|
|
removeBusiness,
|
|
setBusinessActive,
|
|
updateBusiness,
|
|
updateBusinessDomain,
|
|
} from '../services/businessService'
|
|
import { listBusinessCategories } from '../services/categoryService'
|
|
import { Modal } from '../components/Modal'
|
|
import { MultiSelectDropdown } from '../components/MultiSelectDropdown'
|
|
import { PrimaryColorPicker } from '../components/PrimaryColorPicker'
|
|
import { PrimaryColorSwatchControl } from '../components/PrimaryColorSwatchControl'
|
|
import { useToast } from '../context/ToastContext'
|
|
import {
|
|
DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
|
|
normalizeBusinessPrimaryColorId,
|
|
type BusinessPrimaryColorId,
|
|
} from '../utils/businessPrimaryColors'
|
|
import {
|
|
getBusinessSettings,
|
|
updateBusinessBranding,
|
|
updateBusinessDefaultLocale,
|
|
updateBusinessModules,
|
|
updateBusinessPrimaryColor,
|
|
type DashboardLocale,
|
|
} from '../services/businessSettingsService'
|
|
import {
|
|
BUSINESS_MODULE_IDS,
|
|
BUSINESS_MODULE_LABELS,
|
|
DEFAULT_ENABLED_BUSINESS_MODULES,
|
|
DEFAULT_HOME_CHARTS,
|
|
HOME_CHART_IDS,
|
|
HOME_CHART_LABELS,
|
|
normalizeEnabledBusinessModules,
|
|
normalizeHomeCharts,
|
|
type BusinessModuleId,
|
|
type HomeChartId,
|
|
} from '../utils/businessModules'
|
|
import { flattenBusinessCategories } from '../utils/categories'
|
|
import { Pagination } from '@meshkee/dashboard-ui'
|
|
import pageStyles from '../components/PageContent.module.css'
|
|
import styles from './BusinessesPage.module.css'
|
|
|
|
const DEFAULT_PAGE_SIZE = 10
|
|
const DEFAULT_LOCALE: DashboardLocale = 'fa'
|
|
|
|
function normalizeDefaultLocale(value: unknown): DashboardLocale {
|
|
return value === 'en' || value === 'fa' ? value : DEFAULT_LOCALE
|
|
}
|
|
|
|
function emptyModuleSelection(
|
|
enabled: BusinessModuleId[] = DEFAULT_ENABLED_BUSINESS_MODULES,
|
|
): Record<BusinessModuleId, boolean> {
|
|
const selected = new Set(enabled)
|
|
return Object.fromEntries(
|
|
BUSINESS_MODULE_IDS.map((id) => [id, selected.has(id)]),
|
|
) as Record<BusinessModuleId, boolean>
|
|
}
|
|
|
|
function formatDate(value: string) {
|
|
const d = new Date(value)
|
|
if (Number.isNaN(d.getTime())) return value
|
|
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })
|
|
}
|
|
|
|
const MIGRATE_ENTITY_LABELS: Record<MigrateFromOldEntity, string> = {
|
|
product_categories: 'Product categories',
|
|
product: 'Products',
|
|
customer_categories: 'Customer categories',
|
|
customer: 'Customers',
|
|
blog_categories: 'Blog categories',
|
|
blog: 'Blogs',
|
|
portfolio_categories: 'Portfolio categories',
|
|
portfolio: 'Portfolios',
|
|
}
|
|
|
|
const DATA_ENTITY_GROUPS = [
|
|
{
|
|
title: 'Products',
|
|
items: [
|
|
['product_categories', 'Categories'],
|
|
['product', 'Products'],
|
|
],
|
|
},
|
|
{
|
|
title: 'Customers',
|
|
items: [
|
|
['customer_categories', 'Categories'],
|
|
['customer', 'Customers'],
|
|
],
|
|
},
|
|
{
|
|
title: 'Blogs',
|
|
items: [
|
|
['blog_categories', 'Categories'],
|
|
['blog', 'Blogs'],
|
|
],
|
|
},
|
|
{
|
|
title: 'Portfolios',
|
|
items: [
|
|
['portfolio_categories', 'Categories'],
|
|
['portfolio', 'Portfolios'],
|
|
],
|
|
},
|
|
] as const
|
|
|
|
function formatMigrateEntityResult(result: MigrateEntityResult): string {
|
|
if ('created' in result) {
|
|
const base = `${result.created} created, ${result.skipped} skipped (${result.total} total)`
|
|
const extras = [
|
|
result.imagesCopied != null ? `${result.imagesCopied} images copied` : null,
|
|
result.imagesResized != null && result.imagesResized > 0
|
|
? `${result.imagesResized} resized (≤1280px)`
|
|
: null,
|
|
result.titlesUpdated != null && result.titlesUpdated > 0
|
|
? `${result.titlesUpdated} titles updated`
|
|
: null,
|
|
result.imagesFailed != null && result.imagesFailed > 0
|
|
? `${result.imagesFailed} images failed`
|
|
: null,
|
|
result.skippedInvalidCell != null && result.skippedInvalidCell > 0
|
|
? `${result.skippedInvalidCell} invalid/missing cell`
|
|
: null,
|
|
result.skippedAlreadyLinked != null && result.skippedAlreadyLinked > 0
|
|
? `${result.skippedAlreadyLinked} already linked`
|
|
: null,
|
|
result.skippedCreateFailed != null && result.skippedCreateFailed > 0
|
|
? `${result.skippedCreateFailed} create failed`
|
|
: null,
|
|
].filter(Boolean)
|
|
return extras.length ? `${base}; ${extras.join(', ')}` : base
|
|
}
|
|
return 'Not implemented yet'
|
|
}
|
|
|
|
function formatPurgeEntityResult(result: PurgeEntityResult): string {
|
|
if ('deleted' in result) {
|
|
const extras = [
|
|
result.imagesDeleted != null ? `${result.imagesDeleted} images deleted` : null,
|
|
].filter(Boolean)
|
|
const base = `${result.deleted} deleted`
|
|
return extras.length ? `${base}; ${extras.join(', ')}` : base
|
|
}
|
|
return 'Not implemented yet'
|
|
}
|
|
|
|
function emptyMigrateEntities(): Record<MigrateFromOldEntity, boolean> {
|
|
return {
|
|
product_categories: false,
|
|
product: false,
|
|
customer_categories: false,
|
|
customer: false,
|
|
blog_categories: false,
|
|
blog: false,
|
|
portfolio_categories: false,
|
|
portfolio: false,
|
|
}
|
|
}
|
|
|
|
export function BusinessesPage() {
|
|
const { showToast } = useToast()
|
|
const navigate = useNavigate()
|
|
const [data, setData] = useState<BusinessesListResponse | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState('')
|
|
|
|
const [categories, setCategories] = useState<BusinessCategory[]>([])
|
|
|
|
const [appliedFilters, setAppliedFilters] = useState<Omit<ListBusinessesParams, 'page' | 'pageSize'>>(
|
|
{},
|
|
)
|
|
const [page, setPage] = useState(1)
|
|
const [pageSize] = useState(DEFAULT_PAGE_SIZE)
|
|
|
|
const [draftName, setDraftName] = useState('')
|
|
const [draftDomain, setDraftDomain] = useState('')
|
|
const [draftCategory, setDraftCategory] = useState('')
|
|
|
|
const [editOpen, setEditOpen] = useState(false)
|
|
const [editBusiness, setEditBusiness] = useState<BusinessListItem | null>(null)
|
|
const [editName, setEditName] = useState('')
|
|
const [editPrimaryColor, setEditPrimaryColor] = useState<BusinessPrimaryColorId>(
|
|
DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
|
|
)
|
|
const [editDefaultLocale, setEditDefaultLocale] = useState<DashboardLocale>(DEFAULT_LOCALE)
|
|
const [editLoadingSettings, setEditLoadingSettings] = useState(false)
|
|
const [editSubmitting, setEditSubmitting] = useState(false)
|
|
const [editError, setEditError] = useState('')
|
|
|
|
const [domainOpen, setDomainOpen] = useState(false)
|
|
const [domainBusiness, setDomainBusiness] = useState<BusinessListItem | null>(null)
|
|
const [domainId, setDomainId] = useState<number | null>(null)
|
|
const [domainHost, setDomainHost] = useState('')
|
|
const [domainGitRepoUrl, setDomainGitRepoUrl] = useState('')
|
|
const [domainSubmitting, setDomainSubmitting] = useState(false)
|
|
const [domainError, setDomainError] = useState('')
|
|
|
|
const [createOpen, setCreateOpen] = useState(false)
|
|
const [createName, setCreateName] = useState('')
|
|
const [createNameFa, setCreateNameFa] = useState('')
|
|
const [createAbout, setCreateAbout] = useState('')
|
|
const [createCategoryIds, setCreateCategoryIds] = useState<number[]>([])
|
|
const [createOwnerFirstName, setCreateOwnerFirstName] = useState('')
|
|
const [createOwnerLastName, setCreateOwnerLastName] = useState('')
|
|
const [createOwnerFirstNameEn, setCreateOwnerFirstNameEn] = useState('')
|
|
const [createOwnerLastNameEn, setCreateOwnerLastNameEn] = useState('')
|
|
const [createOwnerCell, setCreateOwnerCell] = useState('')
|
|
const [createOwnerPassword, setCreateOwnerPassword] = useState('')
|
|
const [createSubmitting, setCreateSubmitting] = useState(false)
|
|
const [createError, setCreateError] = useState('')
|
|
|
|
const [removeTarget, setRemoveTarget] = useState<BusinessListItem | null>(null)
|
|
const [togglingId, setTogglingId] = useState<string | null>(null)
|
|
const [savingColorId, setSavingColorId] = useState<string | null>(null)
|
|
const [savingLocaleId, setSavingLocaleId] = useState<string | null>(null)
|
|
|
|
const [modulesOpen, setModulesOpen] = useState(false)
|
|
const [modulesBusiness, setModulesBusiness] = useState<BusinessListItem | null>(null)
|
|
const [modulesSelected, setModulesSelected] = useState<Record<BusinessModuleId, boolean>>(
|
|
() => emptyModuleSelection(),
|
|
)
|
|
const [modulesCharts, setModulesCharts] = useState<[HomeChartId, HomeChartId]>([
|
|
...DEFAULT_HOME_CHARTS,
|
|
])
|
|
const [modulesSubmitting, setModulesSubmitting] = useState(false)
|
|
const [modulesError, setModulesError] = useState('')
|
|
|
|
const [migrateOpen, setMigrateOpen] = useState(false)
|
|
const [migrateBusiness, setMigrateBusiness] = useState<BusinessListItem | null>(null)
|
|
const [migrateOldId, setMigrateOldId] = useState('')
|
|
const [migrateEntities, setMigrateEntities] = useState<Record<MigrateFromOldEntity, boolean>>(
|
|
() => emptyMigrateEntities(),
|
|
)
|
|
const [migrateSubmitting, setMigrateSubmitting] = useState(false)
|
|
const [migrateError, setMigrateError] = useState('')
|
|
const [migrateResult, setMigrateResult] = useState<MigrateFromOldResponse | null>(null)
|
|
|
|
const [purgeOpen, setPurgeOpen] = useState(false)
|
|
const [purgeBusiness, setPurgeBusiness] = useState<BusinessListItem | null>(null)
|
|
const [purgeEntities, setPurgeEntities] = useState<Record<PurgeBusinessDataEntity, boolean>>(
|
|
() => emptyMigrateEntities(),
|
|
)
|
|
const [purgeSubmitting, setPurgeSubmitting] = useState(false)
|
|
const [purgeError, setPurgeError] = useState('')
|
|
const [purgeResult, setPurgeResult] = useState<PurgeBusinessDataResponse | null>(null)
|
|
|
|
async function fetchList() {
|
|
setLoading(true)
|
|
setError('')
|
|
try {
|
|
const result = await listBusinesses({
|
|
page,
|
|
pageSize,
|
|
...appliedFilters,
|
|
})
|
|
setData(result)
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : 'Unable to load businesses.')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController()
|
|
|
|
async function load() {
|
|
setLoading(true)
|
|
setError('')
|
|
try {
|
|
const [result, categoryResult] = await Promise.all([
|
|
listBusinesses(
|
|
{
|
|
page,
|
|
pageSize,
|
|
...appliedFilters,
|
|
},
|
|
controller.signal,
|
|
),
|
|
listBusinessCategories(controller.signal),
|
|
])
|
|
if (controller.signal.aborted) return
|
|
setData(result)
|
|
setCategories(categoryResult.items)
|
|
} catch (err) {
|
|
if (isAbortError(err) || controller.signal.aborted) return
|
|
setError(err instanceof ApiError ? err.message : 'Unable to load businesses.')
|
|
} finally {
|
|
if (!controller.signal.aborted) setLoading(false)
|
|
}
|
|
}
|
|
|
|
void load()
|
|
|
|
return () => {
|
|
controller.abort()
|
|
}
|
|
}, [page, pageSize, appliedFilters.name, appliedFilters.domain, appliedFilters.category])
|
|
|
|
const totalPages = useMemo(() => {
|
|
const total = data?.total ?? 0
|
|
return Math.max(1, Math.ceil(total / pageSize))
|
|
}, [data?.total, pageSize])
|
|
|
|
const showingFrom = useMemo(() => {
|
|
if (!data) return 0
|
|
return (page - 1) * pageSize + 1
|
|
}, [data, page, pageSize])
|
|
|
|
const showingTo = useMemo(() => {
|
|
if (!data) return 0
|
|
return Math.min(data.total, page * pageSize)
|
|
}, [data, page, pageSize])
|
|
|
|
const categoryOptions = useMemo(() => flattenBusinessCategories(categories), [categories])
|
|
|
|
function applyFilters() {
|
|
setPage(1)
|
|
setAppliedFilters({
|
|
...(draftName.trim() ? { name: draftName.trim() } : {}),
|
|
...(draftDomain.trim() ? { domain: draftDomain.trim() } : {}),
|
|
...(draftCategory.trim() ? { category: draftCategory.trim() } : {}),
|
|
})
|
|
}
|
|
|
|
function clearFilters() {
|
|
setDraftName('')
|
|
setDraftDomain('')
|
|
setDraftCategory('')
|
|
setPage(1)
|
|
setAppliedFilters({})
|
|
}
|
|
|
|
function openEdit(b: BusinessListItem) {
|
|
setEditBusiness(b)
|
|
setEditName(b.name)
|
|
setEditPrimaryColor(normalizeBusinessPrimaryColorId(b.primaryColor))
|
|
setEditDefaultLocale(normalizeDefaultLocale(b.defaultLocale))
|
|
setEditError('')
|
|
setEditOpen(true)
|
|
setEditLoadingSettings(true)
|
|
|
|
const controller = new AbortController()
|
|
void getBusinessSettings(b.id, controller.signal)
|
|
.then((data) => {
|
|
setEditPrimaryColor(data.settings.branding.primaryColor)
|
|
setEditDefaultLocale(normalizeDefaultLocale(data.settings.branding.defaultLocale))
|
|
})
|
|
.catch((err) => {
|
|
if (isAbortError(err)) return
|
|
setEditError(err instanceof ApiError ? err.message : 'Unable to load business theme.')
|
|
})
|
|
.finally(() => {
|
|
if (!controller.signal.aborted) setEditLoadingSettings(false)
|
|
})
|
|
}
|
|
|
|
function goToBusinessUsers(b: BusinessListItem, membership: 'staff' | 'all') {
|
|
const params = new URLSearchParams({
|
|
businessId: String(b.id),
|
|
membership,
|
|
businessName: b.name,
|
|
})
|
|
navigate(`/users?${params.toString()}`)
|
|
}
|
|
|
|
async function handlePrimaryColorChange(
|
|
b: BusinessListItem,
|
|
primaryColor: BusinessPrimaryColorId,
|
|
) {
|
|
const currentColor = normalizeBusinessPrimaryColorId(b.primaryColor)
|
|
if (currentColor === primaryColor) return
|
|
|
|
setSavingColorId(b.id)
|
|
setError('')
|
|
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) =>
|
|
item.id === b.id ? { ...item, primaryColor } : item,
|
|
),
|
|
}
|
|
})
|
|
|
|
if (editBusiness?.id === b.id) {
|
|
setEditPrimaryColor(primaryColor)
|
|
}
|
|
|
|
try {
|
|
await updateBusinessPrimaryColor(b.id, primaryColor)
|
|
showToast(`Theme updated for "${b.name}".`, 'success')
|
|
} catch (err) {
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) =>
|
|
item.id === b.id ? { ...item, primaryColor: currentColor } : item,
|
|
),
|
|
}
|
|
})
|
|
|
|
if (editBusiness?.id === b.id) {
|
|
setEditPrimaryColor(currentColor)
|
|
}
|
|
|
|
showToast(
|
|
err instanceof ApiError ? err.message : 'Unable to update theme.',
|
|
'error',
|
|
)
|
|
} finally {
|
|
setSavingColorId(null)
|
|
}
|
|
}
|
|
|
|
async function handleDefaultLocaleChange(b: BusinessListItem, defaultLocale: DashboardLocale) {
|
|
const currentLocale = normalizeDefaultLocale(b.defaultLocale)
|
|
if (currentLocale === defaultLocale) return
|
|
|
|
setSavingLocaleId(b.id)
|
|
setError('')
|
|
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) =>
|
|
item.id === b.id ? { ...item, defaultLocale } : item,
|
|
),
|
|
}
|
|
})
|
|
|
|
if (editBusiness?.id === b.id) {
|
|
setEditDefaultLocale(defaultLocale)
|
|
}
|
|
|
|
try {
|
|
await updateBusinessDefaultLocale(b.id, defaultLocale)
|
|
showToast(`Default language updated for "${b.name}".`, 'success')
|
|
} catch (err) {
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) =>
|
|
item.id === b.id ? { ...item, defaultLocale: currentLocale } : item,
|
|
),
|
|
}
|
|
})
|
|
|
|
if (editBusiness?.id === b.id) {
|
|
setEditDefaultLocale(currentLocale)
|
|
}
|
|
|
|
showToast(
|
|
err instanceof ApiError ? err.message : 'Unable to update default language.',
|
|
'error',
|
|
)
|
|
} finally {
|
|
setSavingLocaleId(null)
|
|
}
|
|
}
|
|
|
|
function openModules(b: BusinessListItem) {
|
|
setModulesBusiness(b)
|
|
setModulesSelected(
|
|
emptyModuleSelection(
|
|
normalizeEnabledBusinessModules(b.enabledModules ?? DEFAULT_ENABLED_BUSINESS_MODULES),
|
|
),
|
|
)
|
|
setModulesCharts(normalizeHomeCharts(b.homeCharts ?? DEFAULT_HOME_CHARTS))
|
|
setModulesError('')
|
|
setModulesOpen(true)
|
|
}
|
|
|
|
function closeModules() {
|
|
if (modulesSubmitting) return
|
|
setModulesOpen(false)
|
|
setModulesBusiness(null)
|
|
setModulesError('')
|
|
}
|
|
|
|
async function submitModules() {
|
|
if (!modulesBusiness) return
|
|
const enabled = BUSINESS_MODULE_IDS.filter((id) => modulesSelected[id])
|
|
const charts = normalizeHomeCharts(modulesCharts)
|
|
setModulesSubmitting(true)
|
|
setModulesError('')
|
|
try {
|
|
await updateBusinessModules(modulesBusiness.id, enabled, charts)
|
|
setData((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
items: prev.items.map((item) =>
|
|
item.id === modulesBusiness.id
|
|
? {
|
|
...item,
|
|
enabledModules: enabled,
|
|
moduleCount: enabled.length,
|
|
homeCharts: charts,
|
|
}
|
|
: item,
|
|
),
|
|
}
|
|
: prev,
|
|
)
|
|
showToast('Modules updated.', 'success')
|
|
setModulesOpen(false)
|
|
setModulesBusiness(null)
|
|
} catch (err) {
|
|
setModulesError(
|
|
err instanceof ApiError ? err.message : 'Unable to update modules.',
|
|
)
|
|
} finally {
|
|
setModulesSubmitting(false)
|
|
}
|
|
}
|
|
|
|
async function submitEdit() {
|
|
if (!editBusiness) return
|
|
setEditSubmitting(true)
|
|
setEditError('')
|
|
try {
|
|
await updateBusiness(editBusiness.id, { name: editName })
|
|
await updateBusinessBranding(editBusiness.id, {
|
|
primaryColor: editPrimaryColor,
|
|
defaultLocale: editDefaultLocale,
|
|
})
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) =>
|
|
item.id === editBusiness.id
|
|
? {
|
|
...item,
|
|
name: editName.trim(),
|
|
primaryColor: editPrimaryColor,
|
|
defaultLocale: editDefaultLocale,
|
|
}
|
|
: item,
|
|
),
|
|
}
|
|
})
|
|
setEditOpen(false)
|
|
setEditBusiness(null)
|
|
showToast('Business updated.', 'success')
|
|
await fetchList()
|
|
} catch (err) {
|
|
setEditError(err instanceof ApiError ? err.message : 'Unable to update business.')
|
|
} finally {
|
|
setEditSubmitting(false)
|
|
}
|
|
}
|
|
|
|
function openDomainEdit(b: BusinessListItem) {
|
|
setDomainBusiness(b)
|
|
setDomainId(b.domainId)
|
|
setDomainHost(b.domain ?? '')
|
|
setDomainGitRepoUrl('')
|
|
setDomainError('')
|
|
setDomainOpen(true)
|
|
}
|
|
|
|
function openMigrate(b: BusinessListItem) {
|
|
setMigrateBusiness(b)
|
|
setMigrateOldId(b.oldBusinessId != null ? String(b.oldBusinessId) : '')
|
|
setMigrateEntities(emptyMigrateEntities())
|
|
setMigrateError('')
|
|
setMigrateResult(null)
|
|
setMigrateOpen(true)
|
|
}
|
|
|
|
const migrateSubmittingRef = useRef(false)
|
|
migrateSubmittingRef.current = migrateSubmitting
|
|
|
|
const closeMigrate = useCallback(() => {
|
|
if (migrateSubmittingRef.current) return
|
|
setMigrateOpen(false)
|
|
setMigrateBusiness(null)
|
|
setMigrateError('')
|
|
setMigrateResult(null)
|
|
}, [])
|
|
|
|
function selectMigrateEntity(entity: MigrateFromOldEntity) {
|
|
setMigrateResult(null)
|
|
setMigrateError('')
|
|
setMigrateEntities({ ...emptyMigrateEntities(), [entity]: true })
|
|
}
|
|
|
|
async function submitMigrate() {
|
|
if (!migrateBusiness) return
|
|
const oldBusinessId = Number(String(migrateOldId).trim())
|
|
if (!Number.isInteger(oldBusinessId) || oldBusinessId <= 0) {
|
|
setMigrateResult(null)
|
|
setMigrateError('Enter a valid old business id (positive integer).')
|
|
return
|
|
}
|
|
|
|
const entities = (Object.keys(migrateEntities) as MigrateFromOldEntity[]).filter(
|
|
(key) => migrateEntities[key],
|
|
)
|
|
if (entities.length === 0) {
|
|
setMigrateResult(null)
|
|
setMigrateError('Select a data type to migrate.')
|
|
return
|
|
}
|
|
|
|
setMigrateSubmitting(true)
|
|
setMigrateError('')
|
|
setMigrateResult(null)
|
|
try {
|
|
const result = await migrateBusinessFromOld(migrateBusiness.id, {
|
|
oldBusinessId,
|
|
entities,
|
|
})
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) =>
|
|
item.id === migrateBusiness.id
|
|
? { ...item, oldBusinessId: String(oldBusinessId) }
|
|
: item,
|
|
),
|
|
}
|
|
})
|
|
setMigrateResult(result)
|
|
} catch (err) {
|
|
setMigrateError(
|
|
err instanceof ApiError ? err.message : 'Unable to migrate data from old CMS.',
|
|
)
|
|
} finally {
|
|
setMigrateSubmitting(false)
|
|
}
|
|
}
|
|
|
|
function openPurge(b: BusinessListItem) {
|
|
setPurgeBusiness(b)
|
|
setPurgeEntities(emptyMigrateEntities())
|
|
setPurgeError('')
|
|
setPurgeResult(null)
|
|
setPurgeOpen(true)
|
|
}
|
|
|
|
const purgeSubmittingRef = useRef(false)
|
|
purgeSubmittingRef.current = purgeSubmitting
|
|
|
|
const closePurge = useCallback(() => {
|
|
if (purgeSubmittingRef.current) return
|
|
setPurgeOpen(false)
|
|
setPurgeBusiness(null)
|
|
setPurgeError('')
|
|
setPurgeResult(null)
|
|
}, [])
|
|
|
|
function selectPurgeEntity(entity: PurgeBusinessDataEntity) {
|
|
setPurgeResult(null)
|
|
setPurgeError('')
|
|
setPurgeEntities({ ...emptyMigrateEntities(), [entity]: true })
|
|
}
|
|
|
|
async function submitPurge() {
|
|
if (!purgeBusiness) return
|
|
const entities = (Object.keys(purgeEntities) as PurgeBusinessDataEntity[]).filter(
|
|
(key) => purgeEntities[key],
|
|
)
|
|
if (entities.length === 0) {
|
|
setPurgeResult(null)
|
|
setPurgeError('Select a data type to delete.')
|
|
return
|
|
}
|
|
|
|
setPurgeSubmitting(true)
|
|
setPurgeError('')
|
|
setPurgeResult(null)
|
|
try {
|
|
const result = await purgeBusinessData(purgeBusiness.id, { entities })
|
|
setPurgeResult(result)
|
|
showToast(result.message, 'success')
|
|
} catch (err) {
|
|
setPurgeError(
|
|
err instanceof ApiError ? err.message : 'Unable to delete selected business data.',
|
|
)
|
|
} finally {
|
|
setPurgeSubmitting(false)
|
|
}
|
|
}
|
|
|
|
async function submitDomain() {
|
|
if (!domainBusiness) return
|
|
const host = domainHost.trim()
|
|
if (!host) return
|
|
const gitRepoUrl = domainGitRepoUrl.trim()
|
|
|
|
setDomainSubmitting(true)
|
|
setDomainError('')
|
|
try {
|
|
if (domainId) {
|
|
await updateBusinessDomain(domainBusiness.id, domainId, { host })
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) =>
|
|
item.id === domainBusiness.id ? { ...item, domain: host } : item,
|
|
),
|
|
}
|
|
})
|
|
showToast(`Domain updated to "${host}".`, 'success')
|
|
} else {
|
|
const created = (await addBusinessDomain(domainBusiness.id, {
|
|
host,
|
|
isPrimary: true,
|
|
...(gitRepoUrl ? { gitRepoUrl } : {}),
|
|
})) as {
|
|
id: number
|
|
host: string
|
|
sslEnabled: boolean
|
|
deploySlug?: string | null
|
|
provisionError?: string | null
|
|
}
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) =>
|
|
item.id === domainBusiness.id
|
|
? {
|
|
...item,
|
|
domainId: created.id,
|
|
domain: created.host,
|
|
sslEnabled: created.sslEnabled ?? false,
|
|
}
|
|
: item,
|
|
),
|
|
}
|
|
})
|
|
if (created.provisionError) {
|
|
showToast(`Domain "${host}" added, but storefront setup failed.`, 'error')
|
|
showToast(created.provisionError, 'error')
|
|
} else if (gitRepoUrl && created.deploySlug) {
|
|
showToast(
|
|
`Domain "${host}" added. Storefront deploy is ready on Websites.`,
|
|
'success',
|
|
)
|
|
} else {
|
|
showToast(`Domain "${host}" added.`, 'success')
|
|
}
|
|
}
|
|
setDomainOpen(false)
|
|
setDomainBusiness(null)
|
|
setDomainId(null)
|
|
setDomainGitRepoUrl('')
|
|
} catch (err) {
|
|
setDomainError(err instanceof ApiError ? err.message : 'Unable to save domain.')
|
|
} finally {
|
|
setDomainSubmitting(false)
|
|
}
|
|
}
|
|
|
|
function resetCreateForm() {
|
|
setCreateName('')
|
|
setCreateNameFa('')
|
|
setCreateAbout('')
|
|
setCreateCategoryIds([])
|
|
setCreateOwnerFirstName('')
|
|
setCreateOwnerLastName('')
|
|
setCreateOwnerFirstNameEn('')
|
|
setCreateOwnerLastNameEn('')
|
|
setCreateOwnerCell('')
|
|
setCreateOwnerPassword('')
|
|
setCreateError('')
|
|
}
|
|
|
|
function openCreate() {
|
|
resetCreateForm()
|
|
setCreateOpen(true)
|
|
}
|
|
|
|
async function submitCreate() {
|
|
const ownerCellNumber = toE164CellNumber(createOwnerCell.trim())
|
|
if (!ownerCellNumber) return
|
|
|
|
setCreateSubmitting(true)
|
|
setCreateError('')
|
|
try {
|
|
await createBusiness({
|
|
name: createName.trim(),
|
|
nameFa: createNameFa.trim(),
|
|
about: createAbout.trim() || undefined,
|
|
categoryIds: createCategoryIds,
|
|
ownerFirstName: createOwnerFirstName.trim(),
|
|
ownerLastName: createOwnerLastName.trim(),
|
|
ownerFirstNameEn: createOwnerFirstNameEn.trim(),
|
|
ownerLastNameEn: createOwnerLastNameEn.trim(),
|
|
ownerCellNumber,
|
|
ownerPassword: createOwnerPassword,
|
|
})
|
|
setCreateOpen(false)
|
|
resetCreateForm()
|
|
showToast(`"${createName.trim()}" has been created.`, 'success')
|
|
await fetchList()
|
|
} catch (err) {
|
|
setCreateError(err instanceof ApiError ? err.message : 'Unable to create business.')
|
|
} finally {
|
|
setCreateSubmitting(false)
|
|
}
|
|
}
|
|
|
|
async function handleToggleActive(b: BusinessListItem, isActive: boolean) {
|
|
setTogglingId(b.id)
|
|
setError('')
|
|
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) => (item.id === b.id ? { ...item, isActive } : item)),
|
|
}
|
|
})
|
|
|
|
try {
|
|
await setBusinessActive(b.id, { isActive })
|
|
showToast(`"${b.name}" has been ${isActive ? 'enabled' : 'disabled'}.`, 'success')
|
|
} catch (err) {
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) =>
|
|
item.id === b.id ? { ...item, isActive: !isActive } : item,
|
|
),
|
|
}
|
|
})
|
|
setError(err instanceof ApiError ? err.message : 'Unable to update business status.')
|
|
} finally {
|
|
setTogglingId(null)
|
|
}
|
|
}
|
|
|
|
async function confirmRemove() {
|
|
if (!removeTarget) return
|
|
setError('')
|
|
try {
|
|
await removeBusiness(removeTarget.id)
|
|
setRemoveTarget(null)
|
|
await fetchList()
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : 'Unable to remove business.')
|
|
}
|
|
}
|
|
|
|
const canSubmitCreate =
|
|
createName.trim().length >= 2 &&
|
|
createNameFa.trim().length >= 2 &&
|
|
createCategoryIds.length > 0 &&
|
|
createOwnerFirstName.trim().length >= 2 &&
|
|
createOwnerLastName.trim().length >= 2 &&
|
|
createOwnerFirstNameEn.trim().length >= 2 &&
|
|
createOwnerLastNameEn.trim().length >= 2 &&
|
|
toE164CellNumber(createOwnerCell.trim()).length > 0 &&
|
|
createOwnerPassword.length >= 8
|
|
|
|
const canSubmitMigrate =
|
|
Number.isInteger(Number(String(migrateOldId).trim())) &&
|
|
Number(String(migrateOldId).trim()) > 0 &&
|
|
Object.values(migrateEntities).some(Boolean)
|
|
|
|
const canSubmitPurge = Object.values(purgeEntities).some(Boolean)
|
|
|
|
return (
|
|
<main className={pageStyles.content}>
|
|
<div className={pageStyles.pageHeader}>
|
|
<div>
|
|
<h2 className={pageStyles.pageTitle}>Businesses</h2>
|
|
<p className={pageStyles.pageSubtitle}>
|
|
Manage businesses, domains, and SSL settings.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.filtersPanel}>
|
|
<div className={styles.filtersTitle}>Filters</div>
|
|
|
|
<div className={styles.filtersGrid}>
|
|
<div className={styles.filtersInputs}>
|
|
<div className={`${styles.field} ${styles.fieldCol3}`}>
|
|
<label htmlFor="filter-name">Name</label>
|
|
<input
|
|
id="filter-name"
|
|
value={draftName}
|
|
onChange={(e) => setDraftName(e.target.value)}
|
|
placeholder="Search by business name"
|
|
/>
|
|
</div>
|
|
|
|
<div className={`${styles.field} ${styles.fieldCol3}`}>
|
|
<label htmlFor="filter-domain">Domain</label>
|
|
<input
|
|
id="filter-domain"
|
|
value={draftDomain}
|
|
onChange={(e) => setDraftDomain(e.target.value)}
|
|
placeholder="shop-a.local"
|
|
/>
|
|
</div>
|
|
|
|
<div className={`${styles.field} ${styles.fieldCol3}`}>
|
|
<label htmlFor="filter-category">Category</label>
|
|
<input
|
|
id="filter-category"
|
|
value={draftCategory}
|
|
onChange={(e) => setDraftCategory(e.target.value)}
|
|
placeholder="e.g. electronics / phones"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.filterActions}>
|
|
<button
|
|
type="button"
|
|
className={`${styles.iconActionBtn} ${styles.iconActionBtnPrimary}`}
|
|
onClick={applyFilters}
|
|
disabled={loading}
|
|
aria-label="Search"
|
|
title="Search"
|
|
>
|
|
<Search size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${styles.iconActionBtn} ${styles.iconActionBtnGhost}`}
|
|
onClick={clearFilters}
|
|
disabled={loading}
|
|
aria-label="Clear filters"
|
|
title="Clear filters"
|
|
>
|
|
<RotateCcw size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.tablePanel}>
|
|
<div className={styles.tableWrap}>
|
|
<div className={styles.tableHeader}>
|
|
<div className={styles.tableHeaderTitle}>Business list</div>
|
|
<div className={styles.meta}>
|
|
{data ? (
|
|
<>
|
|
Showing {showingFrom} - {showingTo} of {data.total}
|
|
</>
|
|
) : (
|
|
' '
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div style={{ padding: 16 }}>
|
|
<div className={styles.meta} style={{ color: '#b91c1c' }}>
|
|
{error}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<table className={styles.table}>
|
|
<thead>
|
|
<tr>
|
|
<th className={styles.th}>Name</th>
|
|
<th className={styles.th}>Date created</th>
|
|
<th className={styles.th}>Domain</th>
|
|
<th className={styles.th}>Owner</th>
|
|
<th className={`${styles.th} ${styles.thTheme}`}>Theme</th>
|
|
<th className={`${styles.th} ${styles.thLocale}`}>Lang</th>
|
|
<th className={`${styles.th} ${styles.thModules}`}>Modules</th>
|
|
<th className={`${styles.th} ${styles.thActions}`}>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading && (
|
|
<tr>
|
|
<td className={styles.td} colSpan={8}>
|
|
Loading...
|
|
</td>
|
|
</tr>
|
|
)}
|
|
|
|
{!loading && data?.items?.length === 0 && (
|
|
<tr>
|
|
<td className={styles.td} colSpan={8}>
|
|
No results found.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
|
|
{!loading &&
|
|
data?.items?.map((b) => (
|
|
<tr key={b.id}>
|
|
<td className={styles.td}>{b.name}</td>
|
|
<td className={styles.td}>{formatDate(b.createdAt)}</td>
|
|
<td className={styles.td}>
|
|
{b.domain ? (
|
|
<div className={styles.domainCell}>
|
|
<span>{b.domain}</span>
|
|
{b.sslEnabled ? (
|
|
<span
|
|
className={styles.sslIcon}
|
|
title="SSL certificate valid"
|
|
aria-label="SSL certificate valid"
|
|
>
|
|
<Lock size={16} className={styles.sslOk} aria-hidden="true" />
|
|
</span>
|
|
) : (
|
|
<span
|
|
className={styles.sslIcon}
|
|
title="SSL certificate missing or invalid"
|
|
aria-label="SSL certificate missing or invalid"
|
|
>
|
|
<AlertTriangle
|
|
size={16}
|
|
className={styles.sslWarn}
|
|
aria-hidden="true"
|
|
/>
|
|
</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<span className={styles.subText}>No domain</span>
|
|
)}
|
|
</td>
|
|
<td className={styles.td}>
|
|
<div className={styles.ownerCell}>
|
|
<div className={styles.ownerName}>{b.ownerNameEn || '—'}</div>
|
|
<div className={styles.ownerPhone}>
|
|
{b.ownerCellNumber ? formatCellForDisplay(b.ownerCellNumber) : '—'}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className={`${styles.td} ${styles.tdTheme}`}>
|
|
<PrimaryColorSwatchControl
|
|
value={normalizeBusinessPrimaryColorId(b.primaryColor)}
|
|
disabled={savingColorId === b.id}
|
|
onChange={(primaryColor) =>
|
|
void handlePrimaryColorChange(b, primaryColor)
|
|
}
|
|
/>
|
|
</td>
|
|
<td className={`${styles.td} ${styles.tdLocale}`}>
|
|
<select
|
|
className={styles.localeSelect}
|
|
value={normalizeDefaultLocale(b.defaultLocale)}
|
|
disabled={savingLocaleId === b.id}
|
|
aria-label={`Default language for ${b.name}`}
|
|
onChange={(e) =>
|
|
void handleDefaultLocaleChange(
|
|
b,
|
|
normalizeDefaultLocale(e.target.value),
|
|
)
|
|
}
|
|
>
|
|
<option value="fa">FA</option>
|
|
<option value="en">EN</option>
|
|
</select>
|
|
</td>
|
|
<td className={`${styles.td} ${styles.tdModules}`}>
|
|
<button
|
|
type="button"
|
|
className={styles.modulesCountBtn}
|
|
onClick={() => openModules(b)}
|
|
title="Edit modules"
|
|
aria-label={`Edit modules for ${b.name}`}
|
|
>
|
|
{typeof b.moduleCount === 'number'
|
|
? b.moduleCount
|
|
: normalizeEnabledBusinessModules(
|
|
b.enabledModules ?? DEFAULT_ENABLED_BUSINESS_MODULES,
|
|
).length}
|
|
</button>
|
|
</td>
|
|
<td className={`${styles.td} ${styles.tdActions}`}>
|
|
<div className={styles.rowActions}>
|
|
<span className={styles.toggleInActions}>
|
|
<ToggleSwitch
|
|
checked={b.isActive}
|
|
disabled={togglingId === b.id}
|
|
ariaLabel={`${b.isActive ? 'Disable' : 'Enable'} ${b.name}`}
|
|
onChange={(isActive) => void handleToggleActive(b, isActive)}
|
|
/>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className={styles.controlBtn}
|
|
onClick={() => navigate(`/businesses/${b.id}/invoices`)}
|
|
title="Invoices"
|
|
aria-label="View invoices"
|
|
>
|
|
<FileText size={16} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={styles.controlBtn}
|
|
onClick={() => goToBusinessUsers(b, 'staff')}
|
|
title="Staff"
|
|
aria-label="View staff"
|
|
>
|
|
<UserCog size={16} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={styles.controlBtn}
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
openMigrate(b)
|
|
}}
|
|
title="Migrate from old CMS"
|
|
aria-label="Migrate from old CMS"
|
|
>
|
|
<Database size={16} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={styles.controlBtn}
|
|
onClick={() => openEdit(b)}
|
|
title="Edit"
|
|
aria-label="Edit"
|
|
>
|
|
<Pencil size={16} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={styles.controlBtn}
|
|
onClick={() => openDomainEdit(b)}
|
|
title="Edit domain"
|
|
aria-label="Edit domain"
|
|
>
|
|
<Globe size={16} />
|
|
</button>
|
|
<BusinessActionsOverflowMenu
|
|
businessName={b.name}
|
|
onAllUsers={() => goToBusinessUsers(b, 'all')}
|
|
onWipeData={() => openPurge(b)}
|
|
onRemove={() => setRemoveTarget(b)}
|
|
/>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className={styles.pagination}>
|
|
<div>
|
|
Page {page} / {totalPages} · {pageSize} per page · {data?.total ?? 0} total
|
|
</div>
|
|
<Pagination
|
|
currentPage={page}
|
|
totalPages={totalPages}
|
|
onPageChange={setPage}
|
|
disabled={loading}
|
|
variant="inline"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className={styles.fab}
|
|
onClick={openCreate}
|
|
aria-label="Add business"
|
|
title="Add business"
|
|
>
|
|
<Plus size={24} />
|
|
</button>
|
|
|
|
<Modal
|
|
open={editOpen}
|
|
title="Edit business"
|
|
onClose={() => {
|
|
setEditOpen(false)
|
|
setEditBusiness(null)
|
|
setEditError('')
|
|
}}
|
|
>
|
|
{editError ? (
|
|
<p className={styles.alertError} role="alert">
|
|
{editError}
|
|
</p>
|
|
) : null}
|
|
<div className={styles.field}>
|
|
<label htmlFor="edit-name">Business name</label>
|
|
<input
|
|
id="edit-name"
|
|
value={editName}
|
|
onChange={(e) => setEditName(e.target.value)}
|
|
disabled={editSubmitting}
|
|
/>
|
|
</div>
|
|
<div className={styles.field}>
|
|
<label>Dashboard primary color</label>
|
|
{editLoadingSettings ? (
|
|
<p className={styles.helperText}>Loading theme...</p>
|
|
) : (
|
|
<PrimaryColorPicker
|
|
value={editPrimaryColor}
|
|
onChange={setEditPrimaryColor}
|
|
disabled={editSubmitting}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className={styles.field}>
|
|
<label htmlFor="edit-default-locale">Default dashboard language</label>
|
|
{editLoadingSettings ? (
|
|
<p className={styles.helperText}>Loading language...</p>
|
|
) : (
|
|
<select
|
|
id="edit-default-locale"
|
|
className={styles.localeSelect}
|
|
value={editDefaultLocale}
|
|
disabled={editSubmitting}
|
|
onChange={(e) => setEditDefaultLocale(normalizeDefaultLocale(e.target.value))}
|
|
>
|
|
<option value="fa">Farsi (FA)</option>
|
|
<option value="en">English (EN)</option>
|
|
</select>
|
|
)}
|
|
<p className={styles.helperText}>
|
|
Business and customer dashboards open in this language.
|
|
</p>
|
|
</div>
|
|
<div style={{ height: 12 }} />
|
|
<div className={styles.actionsRow}>
|
|
<button
|
|
type="button"
|
|
className={`${styles.btn} ${styles.btnGhost}`}
|
|
onClick={() => setEditOpen(false)}
|
|
disabled={editSubmitting}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${styles.btn} ${styles.btnPrimary}`}
|
|
onClick={() => void submitEdit()}
|
|
disabled={editSubmitting || !editName.trim() || editLoadingSettings}
|
|
>
|
|
Save
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
open={domainOpen}
|
|
title={domainId ? 'Edit domain' : 'Add domain'}
|
|
onClose={() => {
|
|
setDomainOpen(false)
|
|
setDomainBusiness(null)
|
|
setDomainId(null)
|
|
setDomainGitRepoUrl('')
|
|
setDomainError('')
|
|
}}
|
|
>
|
|
{domainError ? (
|
|
<p className={styles.alertError} role="alert">
|
|
{domainError}
|
|
</p>
|
|
) : null}
|
|
<div className={styles.field}>
|
|
<label htmlFor="domain-host">Domain</label>
|
|
<input
|
|
id="domain-host"
|
|
value={domainHost}
|
|
onChange={(e) => setDomainHost(e.target.value)}
|
|
placeholder="oaktasty.com"
|
|
disabled={domainSubmitting}
|
|
/>
|
|
</div>
|
|
{!domainId ? (
|
|
<>
|
|
<div style={{ height: 12 }} />
|
|
<div className={styles.field}>
|
|
<label htmlFor="domain-git-repo">Git repo URL (optional)</label>
|
|
<input
|
|
id="domain-git-repo"
|
|
value={domainGitRepoUrl}
|
|
onChange={(e) => setDomainGitRepoUrl(e.target.value)}
|
|
placeholder="git@git.meshkee.com:Meshkee-Websites/oaktasty.git"
|
|
disabled={domainSubmitting}
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
/>
|
|
<p className={styles.subText} style={{ marginTop: 6 }}>
|
|
Fill to enable storefront Deploy on Websites. Leave empty for domain-only
|
|
(dashboards).
|
|
</p>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
<div style={{ height: 12 }} />
|
|
<div className={styles.actionsRow}>
|
|
<button
|
|
type="button"
|
|
className={`${styles.btn} ${styles.btnGhost}`}
|
|
onClick={() => setDomainOpen(false)}
|
|
disabled={domainSubmitting}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${styles.btn} ${styles.btnPrimary}`}
|
|
onClick={() => void submitDomain()}
|
|
disabled={domainSubmitting || !domainHost.trim()}
|
|
>
|
|
Save
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
open={modulesOpen}
|
|
title={
|
|
modulesBusiness
|
|
? `Modules — ${modulesBusiness.name}`
|
|
: 'Modules'
|
|
}
|
|
onClose={closeModules}
|
|
>
|
|
{modulesError ? (
|
|
<p className={styles.alertError} role="alert">
|
|
{modulesError}
|
|
</p>
|
|
) : null}
|
|
<p className={styles.helperText}>
|
|
Choose which optional modules this business can use. Customers, website,
|
|
and other general sections stay available for every business.
|
|
</p>
|
|
<div className={styles.modulesChecks}>
|
|
{BUSINESS_MODULE_IDS.map((id) => (
|
|
<label key={id} className={styles.entityCheck}>
|
|
<input
|
|
type="checkbox"
|
|
checked={modulesSelected[id]}
|
|
disabled={modulesSubmitting}
|
|
onChange={(e) =>
|
|
setModulesSelected((prev) => ({
|
|
...prev,
|
|
[id]: e.target.checked,
|
|
}))
|
|
}
|
|
/>
|
|
<span>{BUSINESS_MODULE_LABELS[id]}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
<div className={styles.modulesCharts}>
|
|
<p className={styles.entityGroupLabel}>Home charts</p>
|
|
<div className={styles.field}>
|
|
<label htmlFor="modules-chart-1">Chart 1</label>
|
|
<select
|
|
id="modules-chart-1"
|
|
value={modulesCharts[0]}
|
|
disabled={modulesSubmitting}
|
|
onChange={(e) => {
|
|
const value = e.target.value
|
|
if (!HOME_CHART_IDS.includes(value as HomeChartId)) return
|
|
setModulesCharts([value as HomeChartId, modulesCharts[1]])
|
|
}}
|
|
>
|
|
{HOME_CHART_IDS.map((id) => (
|
|
<option key={id} value={id}>
|
|
{HOME_CHART_LABELS[id]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className={styles.field}>
|
|
<label htmlFor="modules-chart-2">Chart 2</label>
|
|
<select
|
|
id="modules-chart-2"
|
|
value={modulesCharts[1]}
|
|
disabled={modulesSubmitting}
|
|
onChange={(e) => {
|
|
const value = e.target.value
|
|
if (!HOME_CHART_IDS.includes(value as HomeChartId)) return
|
|
setModulesCharts([modulesCharts[0], value as HomeChartId])
|
|
}}
|
|
>
|
|
{HOME_CHART_IDS.map((id) => (
|
|
<option key={id} value={id}>
|
|
{HOME_CHART_LABELS[id]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div style={{ height: 12 }} />
|
|
<div className={styles.actionsRow}>
|
|
<button
|
|
type="button"
|
|
className={`${styles.btn} ${styles.btnGhost}`}
|
|
onClick={closeModules}
|
|
disabled={modulesSubmitting}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${styles.btn} ${styles.btnPrimary}`}
|
|
onClick={() => void submitModules()}
|
|
disabled={modulesSubmitting}
|
|
>
|
|
{modulesSubmitting ? 'Saving…' : 'Save modules'}
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
open={migrateOpen}
|
|
title={
|
|
migrateBusiness
|
|
? `Migrate from old CMS — ${migrateBusiness.name}`
|
|
: 'Migrate from old CMS'
|
|
}
|
|
onClose={closeMigrate}
|
|
>
|
|
{migrateSubmitting ? (
|
|
<p className={styles.alertInfo} role="status" aria-live="polite">
|
|
Migrating selected data… This can take a moment.
|
|
</p>
|
|
) : null}
|
|
{migrateError ? (
|
|
<p className={styles.alertError} role="alert">
|
|
{migrateError}
|
|
</p>
|
|
) : null}
|
|
{migrateResult ? (
|
|
<div className={styles.alertSuccess} role="status" aria-live="polite">
|
|
<p className={styles.migrateResultTitle}>{migrateResult.message}</p>
|
|
{migrateResult.results ? (
|
|
<ul className={styles.migrateResultList}>
|
|
{(Object.keys(migrateResult.results) as MigrateFromOldEntity[]).map((key) => {
|
|
const item = migrateResult.results?.[key]
|
|
if (!item) return null
|
|
return (
|
|
<li key={key}>
|
|
<strong>{MIGRATE_ENTITY_LABELS[key]}:</strong>{' '}
|
|
{formatMigrateEntityResult(item)}
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
<p className={styles.helperText}>
|
|
Link this business to a legacy WillaEngine business id, then choose one
|
|
data type to migrate. Selecting blogs also migrates news under a top-level
|
|
News category. Selecting customers also migrates client categories when
|
|
needed.
|
|
</p>
|
|
<div className={styles.field}>
|
|
<label htmlFor="migrate-old-id">Old business id</label>
|
|
<input
|
|
id="migrate-old-id"
|
|
type="number"
|
|
min={1}
|
|
step={1}
|
|
value={String(migrateOldId)}
|
|
onChange={(e) => {
|
|
setMigrateOldId(e.target.value)
|
|
setMigrateResult(null)
|
|
setMigrateError('')
|
|
}}
|
|
placeholder="e.g. 2410"
|
|
disabled={migrateSubmitting}
|
|
/>
|
|
</div>
|
|
<div className={styles.field} style={{ marginTop: 12 }}>
|
|
<span className={styles.entityGroupLabel}>Data to migrate (one)</span>
|
|
<div className={styles.entityChecks}>
|
|
{DATA_ENTITY_GROUPS.map((group) => (
|
|
<div key={group.title} className={styles.entityGroup}>
|
|
<span className={styles.entityGroupTitle}>{group.title}</span>
|
|
<div className={styles.entityNestedChecks}>
|
|
{group.items.map(([value, label]) => (
|
|
<label key={value} className={styles.entityCheck}>
|
|
<input
|
|
type="radio"
|
|
name="migrate-entity"
|
|
checked={migrateEntities[value]}
|
|
onChange={() => selectMigrateEntity(value)}
|
|
disabled={migrateSubmitting}
|
|
/>
|
|
<span>{label}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div style={{ height: 12 }} />
|
|
<div className={styles.actionsRow}>
|
|
<button
|
|
type="button"
|
|
className={`${styles.btn} ${styles.btnGhost}`}
|
|
onClick={closeMigrate}
|
|
disabled={migrateSubmitting}
|
|
>
|
|
{migrateResult ? 'Close' : 'Cancel'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${styles.btn} ${styles.btnPrimary}`}
|
|
onClick={() => void submitMigrate()}
|
|
disabled={migrateSubmitting || !canSubmitMigrate}
|
|
>
|
|
{migrateSubmitting
|
|
? 'Migrating…'
|
|
: migrateResult
|
|
? 'Migrate again'
|
|
: 'Save & migrate'}
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
open={purgeOpen}
|
|
title={
|
|
purgeBusiness ? `Delete data — ${purgeBusiness.name}` : 'Delete data'
|
|
}
|
|
onClose={closePurge}
|
|
>
|
|
{purgeSubmitting ? (
|
|
<p className={styles.alertInfo} role="status" aria-live="polite">
|
|
Deleting selected data… This can take a moment.
|
|
</p>
|
|
) : null}
|
|
{purgeError ? (
|
|
<p className={styles.alertError} role="alert">
|
|
{purgeError}
|
|
</p>
|
|
) : null}
|
|
{purgeResult ? (
|
|
<div className={styles.alertSuccess} role="status" aria-live="polite">
|
|
<p className={styles.migrateResultTitle}>{purgeResult.message}</p>
|
|
{purgeResult.results ? (
|
|
<ul className={styles.migrateResultList}>
|
|
{(Object.keys(purgeResult.results) as PurgeBusinessDataEntity[]).map((key) => {
|
|
const item = purgeResult.results?.[key]
|
|
if (!item) return null
|
|
return (
|
|
<li key={key}>
|
|
<strong>{MIGRATE_ENTITY_LABELS[key]}:</strong>{' '}
|
|
{formatPurgeEntityResult(item)}
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
<p className={styles.helperText}>
|
|
Permanently remove one selected data type from this business so you can
|
|
migrate again. Deleting blogs or portfolios also removes their images
|
|
from storage. Deleting customers removes business links (not global user
|
|
accounts).
|
|
</p>
|
|
<div className={styles.field} style={{ marginTop: 12 }}>
|
|
<span className={styles.entityGroupLabel}>Data to delete (one)</span>
|
|
<div className={styles.entityChecks}>
|
|
{DATA_ENTITY_GROUPS.map((group) => (
|
|
<div key={group.title} className={styles.entityGroup}>
|
|
<span className={styles.entityGroupTitle}>{group.title}</span>
|
|
<div className={styles.entityNestedChecks}>
|
|
{group.items.map(([value, label]) => (
|
|
<label key={value} className={styles.entityCheck}>
|
|
<input
|
|
type="radio"
|
|
name="purge-entity"
|
|
checked={purgeEntities[value]}
|
|
onChange={() => selectPurgeEntity(value)}
|
|
disabled={purgeSubmitting}
|
|
/>
|
|
<span>{label}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div style={{ height: 12 }} />
|
|
<div className={styles.actionsRow}>
|
|
<button
|
|
type="button"
|
|
className={`${styles.btn} ${styles.btnGhost}`}
|
|
onClick={closePurge}
|
|
disabled={purgeSubmitting}
|
|
>
|
|
{purgeResult ? 'Close' : 'Cancel'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${styles.btn} ${styles.btnDanger}`}
|
|
onClick={() => void submitPurge()}
|
|
disabled={purgeSubmitting || !canSubmitPurge}
|
|
>
|
|
{purgeSubmitting
|
|
? 'Deleting…'
|
|
: purgeResult
|
|
? 'Delete again'
|
|
: 'Delete selected data'}
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
open={createOpen}
|
|
title="Add business"
|
|
wide
|
|
onClose={() => {
|
|
setCreateOpen(false)
|
|
resetCreateForm()
|
|
}}
|
|
>
|
|
{createError ? (
|
|
<p className={styles.alertError} role="alert">
|
|
{createError}
|
|
</p>
|
|
) : null}
|
|
<div className={styles.formGrid}>
|
|
<div className={styles.field}>
|
|
<label htmlFor="create-name-en">Name (EN)</label>
|
|
<input
|
|
id="create-name-en"
|
|
value={createName}
|
|
onChange={(e) => setCreateName(e.target.value)}
|
|
placeholder="New Shop"
|
|
disabled={createSubmitting}
|
|
/>
|
|
</div>
|
|
|
|
<div className={styles.field}>
|
|
<label htmlFor="create-name-fa">Name (FA)</label>
|
|
<input
|
|
id="create-name-fa"
|
|
className="faText"
|
|
value={createNameFa}
|
|
onChange={(e) => setCreateNameFa(e.target.value)}
|
|
placeholder="نام فارسی"
|
|
dir="rtl"
|
|
disabled={createSubmitting}
|
|
/>
|
|
</div>
|
|
|
|
<div className={`${styles.field} ${styles.formFull}`}>
|
|
<label htmlFor="create-categories">Categories</label>
|
|
<MultiSelectDropdown
|
|
id="create-categories"
|
|
options={categoryOptions}
|
|
value={createCategoryIds}
|
|
onChange={setCreateCategoryIds}
|
|
placeholder="Select categories"
|
|
disabled={createSubmitting || categories.length === 0}
|
|
/>
|
|
</div>
|
|
|
|
<div className={`${styles.field} ${styles.formFull}`}>
|
|
<div className={styles.sectionLabel}>Owner</div>
|
|
</div>
|
|
|
|
<div className={styles.field}>
|
|
<label htmlFor="create-owner-first-name-en">First name (EN)</label>
|
|
<input
|
|
id="create-owner-first-name-en"
|
|
value={createOwnerFirstNameEn}
|
|
onChange={(e) => setCreateOwnerFirstNameEn(e.target.value)}
|
|
placeholder="First name"
|
|
disabled={createSubmitting}
|
|
/>
|
|
</div>
|
|
|
|
<div className={styles.field}>
|
|
<label htmlFor="create-owner-last-name-en">Last name (EN)</label>
|
|
<input
|
|
id="create-owner-last-name-en"
|
|
value={createOwnerLastNameEn}
|
|
onChange={(e) => setCreateOwnerLastNameEn(e.target.value)}
|
|
placeholder="Last name"
|
|
disabled={createSubmitting}
|
|
/>
|
|
</div>
|
|
|
|
<div className={styles.field}>
|
|
<label htmlFor="create-owner-first-name">First name (FA)</label>
|
|
<input
|
|
id="create-owner-first-name"
|
|
className="faText"
|
|
value={createOwnerFirstName}
|
|
onChange={(e) => setCreateOwnerFirstName(e.target.value)}
|
|
placeholder="نام"
|
|
dir="rtl"
|
|
lang="fa"
|
|
disabled={createSubmitting}
|
|
/>
|
|
</div>
|
|
|
|
<div className={styles.field}>
|
|
<label htmlFor="create-owner-last-name">Last name (FA)</label>
|
|
<input
|
|
id="create-owner-last-name"
|
|
className="faText"
|
|
value={createOwnerLastName}
|
|
onChange={(e) => setCreateOwnerLastName(e.target.value)}
|
|
placeholder="نام خانوادگی"
|
|
dir="rtl"
|
|
lang="fa"
|
|
disabled={createSubmitting}
|
|
/>
|
|
</div>
|
|
|
|
<div className={`${styles.field} ${styles.formFull}`}>
|
|
<label htmlFor="create-owner-cell">Cell number</label>
|
|
<input
|
|
id="create-owner-cell"
|
|
value={createOwnerCell}
|
|
onChange={(e) => setCreateOwnerCell(e.target.value)}
|
|
placeholder="0912..."
|
|
name="owner-cell"
|
|
autoComplete="off"
|
|
disabled={createSubmitting}
|
|
/>
|
|
</div>
|
|
|
|
<div className={`${styles.field} ${styles.formFull}`}>
|
|
<label htmlFor="create-owner-password">Password</label>
|
|
<input
|
|
id="create-owner-password"
|
|
type="password"
|
|
value={createOwnerPassword}
|
|
onChange={(e) => setCreateOwnerPassword(e.target.value)}
|
|
placeholder="Min. 8 characters"
|
|
name="owner-password"
|
|
autoComplete="new-password"
|
|
disabled={createSubmitting}
|
|
/>
|
|
</div>
|
|
|
|
<div className={`${styles.field} ${styles.formFull}`}>
|
|
<label htmlFor="create-about">About business</label>
|
|
<textarea
|
|
id="create-about"
|
|
value={createAbout}
|
|
onChange={(e) => setCreateAbout(e.target.value)}
|
|
placeholder="Short description about the business"
|
|
disabled={createSubmitting}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ height: 12 }} />
|
|
<div className={styles.actionsRow}>
|
|
<button
|
|
type="button"
|
|
className={`${styles.btn} ${styles.btnGhost}`}
|
|
onClick={() => setCreateOpen(false)}
|
|
disabled={createSubmitting}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${styles.btn} ${styles.btnPrimary}`}
|
|
onClick={() => void submitCreate()}
|
|
disabled={createSubmitting || !canSubmitCreate}
|
|
>
|
|
Create
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
|
|
<ConfirmDeleteModal
|
|
open={removeTarget !== null}
|
|
title="Remove business?"
|
|
message={
|
|
removeTarget
|
|
? `Are you sure you want to remove "${removeTarget.name}"? This action cannot be undone.`
|
|
: ''
|
|
}
|
|
onCancel={() => setRemoveTarget(null)}
|
|
onConfirm={() => void confirmRemove()}
|
|
/>
|
|
</main>
|
|
)
|
|
}
|