Initial commit: Meshkee dashboards monorepo.
Includes business, customer, and super-admin apps with shared packages and production deploy scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,527 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { AlertTriangle, Lock, Pencil, RotateCcw, Search, Trash2, Unlock } from 'lucide-react'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
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 {
|
||||
listDomains,
|
||||
removeDomain,
|
||||
setDomainActive,
|
||||
setDomainSsl,
|
||||
updateDomain,
|
||||
} from '../services/domainService'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import tableStyles from './BusinessesPage.module.css'
|
||||
import styles from './WebsitesPage.module.css'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
function buildPageNumbers(page: number, totalPages: number) {
|
||||
const start = Math.max(1, page - 2)
|
||||
const end = Math.min(totalPages, page + 2)
|
||||
const numbers: number[] = []
|
||||
for (let i = start; i <= end; i++) numbers.push(i)
|
||||
return numbers
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 [editSubmitting, setEditSubmitting] = useState(false)
|
||||
|
||||
const [removeTarget, setRemoveTarget] = useState<DomainListItem | null>(null)
|
||||
const [togglingActiveId, setTogglingActiveId] = useState<number | null>(null)
|
||||
const [togglingSslId, setTogglingSslId] = useState<number | 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 pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
|
||||
|
||||
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) : '')
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editDomain) return
|
||||
setEditSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
await updateDomain(editDomain.id, {
|
||||
host: editHost.trim(),
|
||||
...(editExpiresAt ? { expiresAt: new Date(editExpiresAt).toISOString() } : {}),
|
||||
})
|
||||
setEditOpen(false)
|
||||
setEditDomain(null)
|
||||
showToast(`"${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) {
|
||||
setError(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 handleToggleSsl(domain: DomainListItem, sslEnabled: boolean) {
|
||||
setTogglingSslId(domain.id)
|
||||
setError('')
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((item) => (item.id === domain.id ? { ...item, sslEnabled } : item)),
|
||||
}
|
||||
})
|
||||
try {
|
||||
await setDomainSsl(domain.id, sslEnabled)
|
||||
showToast(
|
||||
`SSL ${sslEnabled ? 'enabled' : 'disabled'} for "${domain.host}".`,
|
||||
'success',
|
||||
)
|
||||
} catch (err) {
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === domain.id ? { ...item, sslEnabled: !sslEnabled } : item,
|
||||
),
|
||||
}
|
||||
})
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to update SSL status.')
|
||||
} finally {
|
||||
setTogglingSslId(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>
|
||||
|
||||
<div className={tableStyles.filtersPanel}>
|
||||
<div className={tableStyles.filtersTitle}>Filters</div>
|
||||
<div className={tableStyles.filtersGrid}>
|
||||
<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="button"
|
||||
className={`${tableStyles.iconActionBtn} ${tableStyles.iconActionBtnPrimary}`}
|
||||
onClick={applyFilters}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 style={{ padding: 16 }}>
|
||||
<div className={tableStyles.meta} style={{ color: '#b91c1c' }}>
|
||||
{error}
|
||||
</div>
|
||||
</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} ${tableStyles.thActions}`}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr>
|
||||
<td className={tableStyles.td} colSpan={5}>
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!loading && data?.items?.length === 0 && (
|
||||
<tr>
|
||||
<td className={tableStyles.td} colSpan={5}>
|
||||
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 ? (
|
||||
<Lock size={16} className={tableStyles.sslOk} aria-label="SSL enabled" />
|
||||
) : (
|
||||
<AlertTriangle
|
||||
size={16}
|
||||
className={tableStyles.sslWarn}
|
||||
aria-label="SSL not enabled"
|
||||
/>
|
||||
)}
|
||||
<span className={tableStyles.subText}>
|
||||
{domain.sslEnabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.controlBtn}
|
||||
onClick={() => void handleToggleSsl(domain, !domain.sslEnabled)}
|
||||
disabled={togglingSslId === domain.id}
|
||||
title={domain.sslEnabled ? 'Disable SSL' : 'Enable SSL'}
|
||||
aria-label={domain.sslEnabled ? 'Disable SSL' : 'Enable SSL'}
|
||||
>
|
||||
{domain.sslEnabled ? <Lock size={16} /> : <Unlock size={16} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.controlBtn}
|
||||
onClick={() => openEdit(domain)}
|
||||
title="Edit"
|
||||
aria-label="Edit"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.controlBtn} ${tableStyles.controlBtnDanger}`}
|
||||
onClick={() => setRemoveTarget(domain)}
|
||||
title="Remove"
|
||||
aria-label="Remove"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className={tableStyles.pagination}>
|
||||
<div>
|
||||
Page {page} / {totalPages}
|
||||
</div>
|
||||
<div className={tableStyles.pagerBtns}>
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
{pageNumbers.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`${tableStyles.pageBtn} ${n === page ? tableStyles.pageBtnActive : ''}`}
|
||||
onClick={() => setPage(n)}
|
||||
disabled={loading}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={editOpen}
|
||||
title="Edit domain"
|
||||
onClose={() => {
|
||||
setEditOpen(false)
|
||||
setEditDomain(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 }} />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user