Farsi business order/variation UI, light/dark logo upload on business profile, payment order steps, variation editing, super-admin site links, and checkout/auth fixes. Co-authored-by: Cursor <cursoragent@cursor.com>
828 lines
29 KiB
TypeScript
828 lines
29 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { flushSync } from 'react-dom'
|
|
import {
|
|
AlertTriangle,
|
|
Loader2,
|
|
Lock,
|
|
Pencil,
|
|
RefreshCw,
|
|
Rocket,
|
|
RotateCcw,
|
|
Search,
|
|
ShieldPlus,
|
|
X,
|
|
} from 'lucide-react'
|
|
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
|
import { DomainLinksOverflowMenu } from '../components/DomainLinksOverflowMenu'
|
|
import { Modal } from '../components/Modal'
|
|
import { ToggleSwitch } from '../components/ToggleSwitch'
|
|
import type { DomainListItem, DomainsListResponse } from '../types/domain'
|
|
import type { ListDomainsParams } from '../services/domainService'
|
|
import { ApiError, isAbortError } from '../lib/api'
|
|
import {
|
|
deployDomain,
|
|
issueDomainSsl,
|
|
issueWebsiteSsl,
|
|
listDomains,
|
|
removeDomain,
|
|
setDomainActive,
|
|
syncSsl,
|
|
updateDomain,
|
|
} from '../services/domainService'
|
|
import { useToast } from '../context/ToastContext'
|
|
import { Pagination } from '@meshkee/dashboard-ui'
|
|
import pageStyles from '../components/PageContent.module.css'
|
|
import tableStyles from './BusinessesPage.module.css'
|
|
import styles from './WebsitesPage.module.css'
|
|
|
|
const PAGE_SIZE = 24
|
|
|
|
function daysUntilExpiry(expiresAt: string | null) {
|
|
if (!expiresAt) return null
|
|
const diff = new Date(expiresAt).getTime() - Date.now()
|
|
return Math.ceil(diff / (1000 * 60 * 60 * 24))
|
|
}
|
|
|
|
function formatDaysLeft(expiresAt: string | null) {
|
|
const days = daysUntilExpiry(expiresAt)
|
|
if (days === null) return '—'
|
|
if (days < 0) return 'Expired'
|
|
if (days === 0) return 'Today'
|
|
if (days === 1) return '1 day'
|
|
return `${days} days`
|
|
}
|
|
|
|
function daysLeftClass(expiresAt: string | null) {
|
|
const days = daysUntilExpiry(expiresAt)
|
|
if (days === null) return styles.daysOk
|
|
if (days < 0) return styles.daysExpired
|
|
if (days <= 30) return styles.daysWarn
|
|
return styles.daysOk
|
|
}
|
|
|
|
function formatDeployAt(iso: string | null) {
|
|
if (!iso) return '—'
|
|
const d = new Date(iso)
|
|
if (Number.isNaN(d.getTime())) return '—'
|
|
return d.toLocaleString(undefined, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
function deployStatusLabel(status: DomainListItem['lastDeployStatus']) {
|
|
if (status === 'started') return 'Started'
|
|
if (status === 'success') return 'Success'
|
|
if (status === 'failed') return 'Failed'
|
|
return null
|
|
}
|
|
|
|
function deployStatusClass(status: DomainListItem['lastDeployStatus']) {
|
|
if (status === 'started') return styles.deployStarted
|
|
if (status === 'success') return styles.deployOk
|
|
if (status === 'failed') return styles.deployFail
|
|
return undefined
|
|
}
|
|
|
|
export function WebsitesPage() {
|
|
const { showToast } = useToast()
|
|
const [data, setData] = useState<DomainsListResponse | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState('')
|
|
|
|
const [appliedFilters, setAppliedFilters] = useState<Omit<ListDomainsParams, 'page' | 'pageSize'>>({})
|
|
const [page, setPage] = useState(1)
|
|
const [draftName, setDraftName] = useState('')
|
|
|
|
const [editOpen, setEditOpen] = useState(false)
|
|
const [editDomain, setEditDomain] = useState<DomainListItem | null>(null)
|
|
const [editHost, setEditHost] = useState('')
|
|
const [editExpiresAt, setEditExpiresAt] = useState('')
|
|
const [editUpdateDns, setEditUpdateDns] = useState(false)
|
|
const [editSubmitting, setEditSubmitting] = useState(false)
|
|
const [editError, setEditError] = useState('')
|
|
|
|
const [removeTarget, setRemoveTarget] = useState<DomainListItem | null>(null)
|
|
const [togglingActiveId, setTogglingActiveId] = useState<number | null>(null)
|
|
const [deployingId, setDeployingId] = useState<DomainListItem['id'] | null>(null)
|
|
const [deployError, setDeployError] = useState('')
|
|
const [syncingSsl, setSyncingSsl] = useState(false)
|
|
const [issuingWebsiteSsl, setIssuingWebsiteSsl] = useState(false)
|
|
const [issuingSslId, setIssuingSslId] = useState<DomainListItem['id'] | null>(null)
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController()
|
|
|
|
async function load() {
|
|
setLoading(true)
|
|
setError('')
|
|
try {
|
|
const result = await listDomains(
|
|
{ page, pageSize: PAGE_SIZE, ...appliedFilters },
|
|
controller.signal,
|
|
)
|
|
if (controller.signal.aborted) return
|
|
setData(result)
|
|
} catch (err) {
|
|
if (isAbortError(err) || controller.signal.aborted) return
|
|
setError(err instanceof ApiError ? err.message : 'Unable to load domains.')
|
|
} finally {
|
|
if (!controller.signal.aborted) setLoading(false)
|
|
}
|
|
}
|
|
|
|
void load()
|
|
|
|
return () => {
|
|
controller.abort()
|
|
}
|
|
}, [page, appliedFilters.name])
|
|
|
|
const totalPages = useMemo(() => {
|
|
const total = data?.total ?? 0
|
|
return Math.max(1, Math.ceil(total / PAGE_SIZE))
|
|
}, [data?.total])
|
|
|
|
const showingFrom = useMemo(() => {
|
|
if (!data) return 0
|
|
return (page - 1) * PAGE_SIZE + 1
|
|
}, [data, page])
|
|
|
|
const showingTo = useMemo(() => {
|
|
if (!data) return 0
|
|
return Math.min(data.total, page * PAGE_SIZE)
|
|
}, [data, page])
|
|
|
|
function applyFilters() {
|
|
setPage(1)
|
|
setAppliedFilters(draftName.trim() ? { name: draftName.trim() } : {})
|
|
}
|
|
|
|
function clearFilters() {
|
|
setDraftName('')
|
|
setPage(1)
|
|
setAppliedFilters({})
|
|
}
|
|
|
|
function openEdit(domain: DomainListItem) {
|
|
setEditDomain(domain)
|
|
setEditHost(domain.host)
|
|
setEditExpiresAt(domain.expiresAt ? domain.expiresAt.slice(0, 10) : '')
|
|
setEditUpdateDns(false)
|
|
setEditError('')
|
|
setEditOpen(true)
|
|
}
|
|
|
|
async function submitEdit() {
|
|
if (!editDomain) return
|
|
setEditSubmitting(true)
|
|
setEditError('')
|
|
try {
|
|
const updated = (await updateDomain(editDomain.id, {
|
|
host: editHost.trim(),
|
|
updateDns: editUpdateDns,
|
|
...(editExpiresAt ? { expiresAt: new Date(editExpiresAt).toISOString() } : {}),
|
|
})) as { dnsError?: string | null }
|
|
setEditOpen(false)
|
|
setEditDomain(null)
|
|
setEditUpdateDns(false)
|
|
if (updated.dnsError) {
|
|
showToast(`"${editHost.trim()}" was updated, but Arvan DNS failed.`, 'error')
|
|
showToast(updated.dnsError, 'error')
|
|
} else {
|
|
showToast(
|
|
editUpdateDns
|
|
? `"${editHost.trim()}" has been updated and DNS records synced.`
|
|
: `"${editHost.trim()}" has been updated.`,
|
|
'success',
|
|
)
|
|
}
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) =>
|
|
item.id === editDomain.id
|
|
? {
|
|
...item,
|
|
host: editHost.trim(),
|
|
expiresAt: editExpiresAt ? new Date(editExpiresAt).toISOString() : item.expiresAt,
|
|
}
|
|
: item,
|
|
),
|
|
}
|
|
})
|
|
} catch (err) {
|
|
setEditError(err instanceof ApiError ? err.message : 'Unable to update domain.')
|
|
} finally {
|
|
setEditSubmitting(false)
|
|
}
|
|
}
|
|
|
|
async function handleToggleActive(domain: DomainListItem, isActive: boolean) {
|
|
setTogglingActiveId(domain.id)
|
|
setError('')
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) => (item.id === domain.id ? { ...item, isActive } : item)),
|
|
}
|
|
})
|
|
try {
|
|
await setDomainActive(domain.id, isActive)
|
|
showToast(`"${domain.host}" has been ${isActive ? 'enabled' : 'disabled'}.`, 'success')
|
|
} catch (err) {
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) =>
|
|
item.id === domain.id ? { ...item, isActive: !isActive } : item,
|
|
),
|
|
}
|
|
})
|
|
setError(err instanceof ApiError ? err.message : 'Unable to update domain status.')
|
|
} finally {
|
|
setTogglingActiveId(null)
|
|
}
|
|
}
|
|
|
|
async function handleSyncSsl() {
|
|
if (syncingSsl || issuingWebsiteSsl) return
|
|
|
|
flushSync(() => {
|
|
setSyncingSsl(true)
|
|
})
|
|
setError('')
|
|
showToast('Starting dashboard SSL sync…', 'info')
|
|
|
|
try {
|
|
const result = await syncSsl()
|
|
showToast(result.message || 'Dashboard SSL sync started.', 'success')
|
|
} catch (err) {
|
|
const message = err instanceof ApiError ? err.message : 'Unable to start SSL sync.'
|
|
setError(message)
|
|
showToast(message, 'error')
|
|
} finally {
|
|
setSyncingSsl(false)
|
|
}
|
|
}
|
|
|
|
async function handleIssueWebsiteSsl() {
|
|
if (issuingWebsiteSsl || syncingSsl) return
|
|
|
|
flushSync(() => {
|
|
setIssuingWebsiteSsl(true)
|
|
})
|
|
setError('')
|
|
showToast('Issuing SSL for storefront domains…', 'info')
|
|
|
|
try {
|
|
const result = await issueWebsiteSsl()
|
|
if (result.failed.length > 0) {
|
|
const detail = result.failed
|
|
.map((f) => `${f.host}: ${f.error}`)
|
|
.join(' · ')
|
|
setError(detail)
|
|
showToast(result.message, result.issued.length > 0 ? 'info' : 'error')
|
|
} else {
|
|
showToast(result.message || 'Website SSL updated.', 'success')
|
|
}
|
|
// Re-load so ssl_enabled matches live probes (including corrected false flags).
|
|
const refreshed = await listDomains({ page, pageSize: PAGE_SIZE, ...appliedFilters })
|
|
setData(refreshed)
|
|
} catch (err) {
|
|
const message = err instanceof ApiError ? err.message : 'Unable to issue website SSL.'
|
|
setError(message)
|
|
showToast(message, 'error')
|
|
} finally {
|
|
setIssuingWebsiteSsl(false)
|
|
}
|
|
}
|
|
|
|
async function handleIssueDomainSsl(domain: DomainListItem) {
|
|
if (issuingSslId != null) return
|
|
|
|
flushSync(() => {
|
|
setIssuingSslId(domain.id)
|
|
})
|
|
setError('')
|
|
showToast(
|
|
`Checking SSL for ${domain.host}, www.${domain.host}, business.${domain.host}, customer.${domain.host}…`,
|
|
'info',
|
|
)
|
|
|
|
try {
|
|
const result = await issueDomainSsl(domain.id)
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) =>
|
|
item.id === domain.id ? { ...item, sslEnabled: result.sslEnabled } : item,
|
|
),
|
|
}
|
|
})
|
|
if (result.failed.length > 0) {
|
|
setError(result.message)
|
|
showToast(
|
|
result.failed.map((f) => f.host).join(', ') + ' need attention',
|
|
result.issued.length > 0 ? 'info' : 'error',
|
|
)
|
|
showToast(result.message, 'info')
|
|
} else if (!result.sslEnabled) {
|
|
setError(result.message)
|
|
showToast('Storefront HTTPS is not valid yet (apex/www).', 'error')
|
|
showToast(result.message, 'info')
|
|
} else {
|
|
showToast(result.message || `SSL ok for "${domain.host}".`, 'success')
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof ApiError ? err.message : 'Unable to ensure SSL.'
|
|
setError(message)
|
|
showToast(message, 'error')
|
|
} finally {
|
|
setIssuingSslId(null)
|
|
}
|
|
}
|
|
|
|
async function handleDeploy(domain: DomainListItem) {
|
|
if (!domain.deploySlug) return
|
|
if (deployingId != null) return
|
|
|
|
// Paint spinner + disabled state before the network call starts
|
|
flushSync(() => {
|
|
setDeployingId(domain.id)
|
|
})
|
|
setDeployError('')
|
|
showToast(`Deploying "${domain.host}"…`, 'info')
|
|
|
|
try {
|
|
const result = await deployDomain(domain.id)
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) =>
|
|
item.id === domain.id
|
|
? {
|
|
...item,
|
|
lastDeployedAt: result.lastDeployedAt,
|
|
lastDeployStatus: result.lastDeployStatus,
|
|
}
|
|
: item,
|
|
),
|
|
}
|
|
})
|
|
if (result.lastDeployStatus === 'success') {
|
|
showToast(`Deploy succeeded for "${result.host}".`, 'success')
|
|
return
|
|
}
|
|
const detail =
|
|
result.message?.trim() ||
|
|
`Deploy finished for "${result.host}" with status ${result.lastDeployStatus ?? 'unknown'}.`
|
|
setDeployError(`${result.host}: ${detail}`)
|
|
} catch (err) {
|
|
const message = err instanceof ApiError ? err.message : 'Deploy failed.'
|
|
setDeployError(`${domain.host}: ${message}`)
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) =>
|
|
item.id === domain.id
|
|
? {
|
|
...item,
|
|
lastDeployedAt: new Date().toISOString(),
|
|
lastDeployStatus: 'failed',
|
|
}
|
|
: item,
|
|
),
|
|
}
|
|
})
|
|
} finally {
|
|
setDeployingId(null)
|
|
}
|
|
}
|
|
|
|
async function confirmRemove() {
|
|
if (!removeTarget) return
|
|
setError('')
|
|
try {
|
|
await removeDomain(removeTarget.id)
|
|
setRemoveTarget(null)
|
|
showToast(`"${removeTarget.host}" has been removed.`, 'success')
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
total: Math.max(0, prev.total - 1),
|
|
items: prev.items.filter((item) => item.id !== removeTarget.id),
|
|
}
|
|
})
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : 'Unable to remove domain.')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<main className={pageStyles.content}>
|
|
<div className={pageStyles.pageHeader}>
|
|
<div>
|
|
<h2 className={pageStyles.pageTitle}>Websites</h2>
|
|
<p className={pageStyles.pageSubtitle}>
|
|
Monitor and manage all domains across the platform.
|
|
</p>
|
|
</div>
|
|
<div className={styles.headerActions}>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnGhost} ${
|
|
syncingSsl ? styles.deployBusy : ''
|
|
}`}
|
|
onClick={() => void handleSyncSsl()}
|
|
disabled={syncingSsl || issuingWebsiteSsl}
|
|
aria-busy={syncingSsl}
|
|
title="Expand Let's Encrypt cert for manage / business.* / customer.* dashboards"
|
|
>
|
|
{syncingSsl ? (
|
|
<Loader2 size={16} className={styles.spin} aria-hidden />
|
|
) : (
|
|
<RefreshCw size={16} aria-hidden />
|
|
)}
|
|
{syncingSsl ? 'Syncing dashboard SSL…' : 'Sync dashboard SSL'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnPrimary} ${
|
|
issuingWebsiteSsl ? styles.deployBusy : ''
|
|
}`}
|
|
onClick={() => void handleIssueWebsiteSsl()}
|
|
disabled={issuingWebsiteSsl || syncingSsl}
|
|
aria-busy={issuingWebsiteSsl}
|
|
title="Probe storefront HTTPS and issue Let's Encrypt where the cert is missing or wrong"
|
|
>
|
|
{issuingWebsiteSsl ? (
|
|
<Loader2 size={16} className={styles.spin} aria-hidden />
|
|
) : (
|
|
<ShieldPlus size={16} aria-hidden />
|
|
)}
|
|
{issuingWebsiteSsl ? 'Issuing website SSL…' : 'Issue website SSL'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={tableStyles.filtersPanel}>
|
|
<div className={tableStyles.filtersTitle}>Filters</div>
|
|
<form className={tableStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
|
|
<div className={tableStyles.filtersInputs}>
|
|
<div className={`${tableStyles.field} ${tableStyles.fieldCol3}`}>
|
|
<label htmlFor="filter-domain-name">Name</label>
|
|
<input
|
|
id="filter-domain-name"
|
|
value={draftName}
|
|
onChange={(e) => setDraftName(e.target.value)}
|
|
placeholder="shop-a.local"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className={tableStyles.filterActions}>
|
|
<button
|
|
type="submit"
|
|
className={`${tableStyles.iconActionBtn} ${tableStyles.iconActionBtnPrimary}`}
|
|
disabled={loading}
|
|
aria-label="Search"
|
|
title="Search"
|
|
>
|
|
<Search size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.iconActionBtn} ${tableStyles.iconActionBtnGhost}`}
|
|
onClick={clearFilters}
|
|
disabled={loading}
|
|
aria-label="Clear filters"
|
|
title="Clear filters"
|
|
>
|
|
<RotateCcw size={18} />
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
{deployError ? (
|
|
<div className={styles.deployErrorTip} role="alert">
|
|
<AlertTriangle size={18} className={styles.deployErrorIcon} aria-hidden />
|
|
<div className={styles.deployErrorBody}>
|
|
<p className={styles.deployErrorTitle}>Deploy failed</p>
|
|
<pre className={styles.deployErrorDetail}>{deployError}</pre>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={styles.deployErrorDismiss}
|
|
onClick={() => setDeployError('')}
|
|
aria-label="Dismiss deploy error"
|
|
>
|
|
<X size={16} aria-hidden />
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className={tableStyles.tablePanel}>
|
|
<div className={tableStyles.tableWrap}>
|
|
<div className={tableStyles.tableHeader}>
|
|
<div className={tableStyles.tableHeaderTitle}>Domain list</div>
|
|
<div className={tableStyles.meta}>
|
|
{data ? (
|
|
<>
|
|
Showing {showingFrom} - {showingTo} of {data.total}
|
|
</>
|
|
) : (
|
|
' '
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className={styles.listErrorWrap}>
|
|
<p className={tableStyles.alertError} role="alert">
|
|
{error}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<table className={tableStyles.table}>
|
|
<thead>
|
|
<tr>
|
|
<th className={tableStyles.th}>Name</th>
|
|
<th className={tableStyles.th}>Owner business</th>
|
|
<th className={tableStyles.th}>Days to expire</th>
|
|
<th className={tableStyles.th}>SSL</th>
|
|
<th className={tableStyles.th}>Latest deploy</th>
|
|
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading && (
|
|
<tr>
|
|
<td className={tableStyles.td} colSpan={6}>
|
|
Loading...
|
|
</td>
|
|
</tr>
|
|
)}
|
|
|
|
{!loading && data?.items?.length === 0 && (
|
|
<tr>
|
|
<td className={tableStyles.td} colSpan={6}>
|
|
No results found.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
|
|
{!loading &&
|
|
data?.items?.map((domain) => (
|
|
<tr
|
|
key={domain.id}
|
|
className={!domain.isActive ? styles.inactiveRow : undefined}
|
|
>
|
|
<td className={tableStyles.td}>{domain.host}</td>
|
|
<td className={tableStyles.td}>{domain.businessName}</td>
|
|
<td className={tableStyles.td}>
|
|
<span className={daysLeftClass(domain.expiresAt)}>
|
|
{formatDaysLeft(domain.expiresAt)}
|
|
</span>
|
|
</td>
|
|
<td className={tableStyles.td}>
|
|
<div className={tableStyles.domainCell}>
|
|
{domain.sslEnabled ? (
|
|
<span
|
|
className={tableStyles.sslIcon}
|
|
title="SSL certificate valid"
|
|
aria-label="SSL certificate valid"
|
|
>
|
|
<Lock size={16} className={tableStyles.sslOk} aria-hidden="true" />
|
|
</span>
|
|
) : (
|
|
<span
|
|
className={tableStyles.sslIcon}
|
|
title="SSL certificate missing or invalid"
|
|
aria-label="SSL certificate missing or invalid"
|
|
>
|
|
<AlertTriangle
|
|
size={16}
|
|
className={tableStyles.sslWarn}
|
|
aria-hidden="true"
|
|
/>
|
|
</span>
|
|
)}
|
|
<span className={tableStyles.subText}>
|
|
{domain.sslEnabled ? 'Valid' : 'Invalid'}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className={tableStyles.td}>
|
|
{domain.lastDeployedAt ? (
|
|
<div className={styles.deployCell}>
|
|
<span className={tableStyles.subText}>
|
|
{formatDeployAt(domain.lastDeployedAt)}
|
|
</span>
|
|
{deployStatusLabel(domain.lastDeployStatus) ? (
|
|
<span
|
|
className={`${styles.deployStatus} ${deployStatusClass(domain.lastDeployStatus) ?? ''}`}
|
|
>
|
|
{deployStatusLabel(domain.lastDeployStatus)}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<span className={tableStyles.subText}>—</span>
|
|
)}
|
|
</td>
|
|
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
|
|
<div className={tableStyles.rowActions}>
|
|
<span className={tableStyles.toggleInActions}>
|
|
<ToggleSwitch
|
|
checked={domain.isActive}
|
|
disabled={togglingActiveId === domain.id}
|
|
ariaLabel={`${domain.isActive ? 'Disable' : 'Enable'} ${domain.host}`}
|
|
onChange={(isActive) => void handleToggleActive(domain, isActive)}
|
|
/>
|
|
</span>
|
|
{domain.deploySlug ? (
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.controlBtn} ${
|
|
deployingId === domain.id ? styles.deployBusy : ''
|
|
}`}
|
|
onClick={() => {
|
|
if (deployingId != null) return
|
|
void handleDeploy(domain)
|
|
}}
|
|
disabled={deployingId === domain.id}
|
|
title={
|
|
deployingId === domain.id ? 'Deploying…' : 'Deploy website'
|
|
}
|
|
aria-label={
|
|
deployingId === domain.id ? 'Deploying' : 'Deploy website'
|
|
}
|
|
aria-busy={deployingId === domain.id}
|
|
>
|
|
{deployingId === domain.id ? (
|
|
<Loader2 size={16} className={styles.spin} aria-hidden />
|
|
) : (
|
|
<Rocket size={16} />
|
|
)}
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.controlBtn} ${
|
|
issuingSslId === domain.id ? styles.deployBusy : ''
|
|
}`}
|
|
onClick={() => void handleIssueDomainSsl(domain)}
|
|
disabled={issuingSslId === domain.id || issuingWebsiteSsl || syncingSsl}
|
|
title={
|
|
issuingSslId === domain.id
|
|
? 'Checking / issuing SSL…'
|
|
: 'Check & issue SSL for domain, business.*, customer.*'
|
|
}
|
|
aria-label={
|
|
issuingSslId === domain.id
|
|
? 'Ensuring SSL'
|
|
: 'Check and issue SSL'
|
|
}
|
|
aria-busy={issuingSslId === domain.id}
|
|
>
|
|
{issuingSslId === domain.id ? (
|
|
<Loader2 size={16} className={styles.spin} aria-hidden />
|
|
) : (
|
|
<Lock size={16} />
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={tableStyles.controlBtn}
|
|
onClick={() => openEdit(domain)}
|
|
title="Edit"
|
|
aria-label="Edit"
|
|
>
|
|
<Pencil size={16} />
|
|
</button>
|
|
<DomainLinksOverflowMenu
|
|
host={domain.host}
|
|
onRemove={() => setRemoveTarget(domain)}
|
|
/>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className={tableStyles.pagination}>
|
|
<div>
|
|
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
|
|
</div>
|
|
<Pagination
|
|
currentPage={page}
|
|
totalPages={totalPages}
|
|
onPageChange={setPage}
|
|
disabled={loading}
|
|
variant="inline"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Modal
|
|
open={editOpen}
|
|
title="Edit domain"
|
|
onClose={() => {
|
|
setEditOpen(false)
|
|
setEditDomain(null)
|
|
setEditUpdateDns(false)
|
|
setEditError('')
|
|
}}
|
|
>
|
|
{editError ? (
|
|
<p className={tableStyles.alertError} role="alert">
|
|
{editError}
|
|
</p>
|
|
) : null}
|
|
<div className={tableStyles.field}>
|
|
<label htmlFor="edit-domain-host">Domain name</label>
|
|
<input
|
|
id="edit-domain-host"
|
|
value={editHost}
|
|
onChange={(e) => setEditHost(e.target.value)}
|
|
disabled={editSubmitting}
|
|
/>
|
|
</div>
|
|
<div style={{ height: 10 }} />
|
|
<div className={tableStyles.field}>
|
|
<label htmlFor="edit-domain-expires">Expiry date</label>
|
|
<input
|
|
id="edit-domain-expires"
|
|
type="date"
|
|
value={editExpiresAt}
|
|
onChange={(e) => setEditExpiresAt(e.target.value)}
|
|
disabled={editSubmitting}
|
|
/>
|
|
</div>
|
|
<div style={{ height: 12 }} />
|
|
<label className={tableStyles.entityCheck} htmlFor="edit-domain-update-dns">
|
|
<input
|
|
id="edit-domain-update-dns"
|
|
type="checkbox"
|
|
checked={editUpdateDns}
|
|
onChange={(e) => setEditUpdateDns(e.target.checked)}
|
|
disabled={editSubmitting}
|
|
/>
|
|
<span>Update DNS records</span>
|
|
</label>
|
|
<p className={tableStyles.subText} style={{ marginTop: 6 }}>
|
|
When checked, upserts Arvan records: @ ANAME → ns.meshkee.com, plus www,
|
|
business, customer, and api CNAMEs.
|
|
</p>
|
|
<div style={{ height: 12 }} />
|
|
<div className={tableStyles.actionsRow}>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
|
onClick={() => setEditOpen(false)}
|
|
disabled={editSubmitting}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
|
|
onClick={() => void submitEdit()}
|
|
disabled={editSubmitting || !editHost.trim()}
|
|
>
|
|
Save
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
|
|
<ConfirmDeleteModal
|
|
open={removeTarget !== null}
|
|
title="Remove domain?"
|
|
message={
|
|
removeTarget
|
|
? `Are you sure you want to remove "${removeTarget.host}"? This action cannot be undone.`
|
|
: ''
|
|
}
|
|
onCancel={() => setRemoveTarget(null)}
|
|
onConfirm={() => void confirmRemove()}
|
|
/>
|
|
</main>
|
|
)
|
|
}
|