Files
dashboards/apps/super-admin/src/pages/WebsitesPage.tsx
T
Alireza HassaniandCursor 5e799eddf9 Localize order modals, add dual logos, and improve admin order tools.
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>
2026-08-26 00:21:48 +03:30

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>
)
}