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(null) const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [appliedFilters, setAppliedFilters] = useState>({}) const [page, setPage] = useState(1) const [draftName, setDraftName] = useState('') const [editOpen, setEditOpen] = useState(false) const [editDomain, setEditDomain] = useState(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(null) const [togglingActiveId, setTogglingActiveId] = useState(null) const [deployingId, setDeployingId] = useState(null) const [deployError, setDeployError] = useState('') const [syncingSsl, setSyncingSsl] = useState(false) const [issuingWebsiteSsl, setIssuingWebsiteSsl] = useState(false) const [issuingSslId, setIssuingSslId] = useState(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 (

Websites

Monitor and manage all domains across the platform.

Filters
{ e.preventDefault(); applyFilters() }}>
setDraftName(e.target.value)} placeholder="shop-a.local" />
{deployError ? (

Deploy failed

{deployError}
) : null}
Domain list
{data ? ( <> Showing {showingFrom} - {showingTo} of {data.total} ) : ( ' ' )}
{error && (

{error}

)}
{loading && ( )} {!loading && data?.items?.length === 0 && ( )} {!loading && data?.items?.map((domain) => ( ))}
Name Owner business Days to expire SSL Latest deploy Actions
Loading...
No results found.
{domain.host} {domain.businessName} {formatDaysLeft(domain.expiresAt)}
{domain.sslEnabled ? ( ) : ( )} {domain.sslEnabled ? 'Valid' : 'Invalid'}
{domain.lastDeployedAt ? (
{formatDeployAt(domain.lastDeployedAt)} {deployStatusLabel(domain.lastDeployStatus) ? ( {deployStatusLabel(domain.lastDeployStatus)} ) : null}
) : ( — )}
void handleToggleActive(domain, isActive)} /> {domain.deploySlug ? ( ) : null} setRemoveTarget(domain)} />
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
{ setEditOpen(false) setEditDomain(null) setEditUpdateDns(false) setEditError('') }} > {editError ? (

{editError}

) : null}
setEditHost(e.target.value)} disabled={editSubmitting} />
setEditExpiresAt(e.target.value)} disabled={editSubmitting} />

When checked, upserts Arvan records: @ ANAME → ns.meshkee.com, plus www, business, customer, and api CNAMEs.

setRemoveTarget(null)} onConfirm={() => void confirmRemove()} />
) }