Add parked-domain aliases, Websites AI prompts, and frosted overflow menus.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-31 18:09:09 +03:30
co-authored by Cursor
parent e9400c2281
commit 0d62a4ca79
11 changed files with 892 additions and 26 deletions
@@ -29,16 +29,21 @@
z-index: 1200; z-index: 1200;
min-width: 220px; min-width: 220px;
padding: 6px; padding: 6px;
border-radius: 10px; border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.95); border: 1px solid var(--glass-border);
background: rgba(255, 255, 255, 0.9); background:
backdrop-filter: blur(80px) saturate(1.7); linear-gradient(
-webkit-backdrop-filter: blur(80px) saturate(1.7); 180deg,
rgba(255, 255, 255, 0.72) 0%,
var(--glass-bg-strong) 100%
);
backdrop-filter: blur(var(--blur-glass)) saturate(1.85);
-webkit-backdrop-filter: blur(var(--blur-glass)) saturate(1.85);
box-shadow: box-shadow:
0 12px 40px rgba(15, 23, 42, 0.18), 0 18px 48px rgba(15, 23, 42, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.8); 0 0 0 1px rgba(255, 255, 255, 0.45),
isolation: isolate; inset 0 1px 0 rgba(255, 255, 255, 0.95);
animation: menuIn 0.14s ease; animation: menuIn 0.16s ease;
} }
.item { .item {
@@ -97,10 +102,8 @@
@keyframes menuIn { @keyframes menuIn {
from { from {
opacity: 0; opacity: 0;
transform: translateY(-4px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0);
} }
} }
@@ -0,0 +1,350 @@
import { useEffect, useState } from 'react'
import { Copy, FileText, Pencil, Plus, Trash2 } from 'lucide-react'
import { Modal } from './Modal'
import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
import {
createDomainAiPrompt,
deleteDomainAiPrompt,
listAiPromptTemplates,
listDomainAiPrompts,
updateDomainAiPrompt,
} from '../services/aiPromptService'
import type { AiPromptTemplate, DomainAiPrompt } from '../types/aiPrompt'
import styles from '../pages/BusinessesPage.module.css'
interface DomainAiPromptsModalProps {
open: boolean
title: string
domainId: number | null
onClose: () => void
}
export function DomainAiPromptsModal({
open,
title,
domainId,
onClose,
}: DomainAiPromptsModalProps) {
const { showToast } = useToast()
const [domainPrompts, setDomainPrompts] = useState<DomainAiPrompt[]>([])
const [allPromptTemplates, setAllPromptTemplates] = useState<AiPromptTemplate[]>([])
const [promptsLoading, setPromptsLoading] = useState(false)
const [promptsError, setPromptsError] = useState('')
const [promptEditorOpen, setPromptEditorOpen] = useState(false)
const [editingDomainPrompt, setEditingDomainPrompt] = useState<DomainAiPrompt | null>(null)
const [promptDraftTemplateId, setPromptDraftTemplateId] = useState('')
const [promptDraftName, setPromptDraftName] = useState('')
const [promptDraftBody, setPromptDraftBody] = useState('')
const [promptEditorSubmitting, setPromptEditorSubmitting] = useState(false)
const [promptEditorError, setPromptEditorError] = useState('')
const [promptBusyId, setPromptBusyId] = useState<string | null>(null)
useEffect(() => {
if (!open) return
setPromptsError('')
setDomainPrompts([])
setAllPromptTemplates([])
setPromptEditorOpen(false)
setEditingDomainPrompt(null)
if (domainId == null) {
setPromptsError('Add a website/domain before assigning AI prompts.')
return
}
const controller = new AbortController()
setPromptsLoading(true)
void (async () => {
try {
const [allTemplates, assigned] = await Promise.all([
listAiPromptTemplates(controller.signal),
listDomainAiPrompts(domainId, controller.signal),
])
if (controller.signal.aborted) return
setAllPromptTemplates(allTemplates.items)
setDomainPrompts(assigned.items)
} catch (err) {
if (controller.signal.aborted) return
setPromptsError(
err instanceof ApiError ? err.message : 'Unable to load AI prompts.',
)
} finally {
if (!controller.signal.aborted) setPromptsLoading(false)
}
})()
return () => {
controller.abort()
}
}, [open, domainId])
function openAddDomainPrompt() {
setEditingDomainPrompt(null)
setPromptDraftTemplateId('')
setPromptDraftName('')
setPromptDraftBody('')
setPromptEditorError('')
setPromptEditorOpen(true)
}
function openEditDomainPrompt(prompt: DomainAiPrompt) {
setEditingDomainPrompt(prompt)
setPromptDraftTemplateId(prompt.sourceTemplateId ?? '')
setPromptDraftName(prompt.name)
setPromptDraftBody(prompt.body)
setPromptEditorError('')
setPromptEditorOpen(true)
}
function applyTemplateToDraft(templateId: string) {
setPromptDraftTemplateId(templateId)
const template = allPromptTemplates.find((item) => item.id === templateId)
if (!template) return
setPromptDraftName(template.name)
setPromptDraftBody(template.body)
}
async function submitDomainPromptEditor() {
if (domainId == null) return
const name = promptDraftName.trim()
const body = promptDraftBody.trim()
if (!name) {
setPromptEditorError('Name is required.')
return
}
if (!body) {
setPromptEditorError('Prompt text is required.')
return
}
setPromptEditorSubmitting(true)
setPromptEditorError('')
try {
if (editingDomainPrompt) {
const updated = await updateDomainAiPrompt(domainId, editingDomainPrompt.id, {
name,
body,
})
setDomainPrompts((prev) =>
prev.map((item) => (item.id === updated.id ? updated : item)),
)
showToast('Prompt updated.', 'success')
} else {
const created = await createDomainAiPrompt(domainId, {
name,
body,
...(promptDraftTemplateId
? { sourceTemplateId: Number(promptDraftTemplateId) }
: {}),
})
setDomainPrompts((prev) => [...prev, created])
showToast('Prompt added.', 'success')
}
setPromptEditorOpen(false)
setEditingDomainPrompt(null)
} catch (err) {
setPromptEditorError(
err instanceof ApiError ? err.message : 'Unable to save prompt.',
)
} finally {
setPromptEditorSubmitting(false)
}
}
async function removeDomainPromptItem(prompt: DomainAiPrompt) {
if (domainId == null) return
setPromptBusyId(prompt.id)
try {
await deleteDomainAiPrompt(domainId, prompt.id)
setDomainPrompts((prev) => prev.filter((item) => item.id !== prompt.id))
showToast('Prompt removed.', 'success')
} catch (err) {
showToast(
err instanceof ApiError ? err.message : 'Unable to remove prompt.',
'error',
)
} finally {
setPromptBusyId(null)
}
}
async function copyDomainPromptLink(url: string) {
try {
await navigator.clipboard.writeText(url)
showToast('Public link copied.', 'success')
} catch {
showToast('Unable to copy link.', 'error')
}
}
function closeList() {
if (promptEditorSubmitting) return
setPromptEditorOpen(false)
onClose()
}
return (
<>
<Modal open={open} title={title} wide onClose={closeList}>
{promptsError ? (
<p className={styles.alertError} role="alert">
{promptsError}
</p>
) : null}
{promptsLoading ? (
<p className={styles.subText}>Loading…</p>
) : domainId != null ? (
domainPrompts.length === 0 ? (
<div className={styles.promptsEmpty}>
<FileText size={20} aria-hidden="true" />
<p>No prompts yet. Use Add to create one from a template.</p>
</div>
) : (
<ul className={styles.promptsList}>
{domainPrompts.map((prompt) => (
<li key={prompt.id} className={styles.promptsRow}>
<div className={styles.promptsRowText}>
<span className={styles.promptsRowName}>{prompt.name}</span>
<span className={styles.promptsRowPreview}>{prompt.body}</span>
</div>
<div className={styles.promptsRowActions}>
<button
type="button"
className={styles.controlBtn}
onClick={() => openEditDomainPrompt(prompt)}
disabled={promptBusyId === prompt.id}
aria-label={`Edit ${prompt.name}`}
title="Edit"
>
<Pencil size={15} />
</button>
<button
type="button"
className={styles.controlBtn}
onClick={() => void copyDomainPromptLink(prompt.publicUrl)}
disabled={promptBusyId === prompt.id}
aria-label={`Copy link for ${prompt.name}`}
title="Copy public link"
>
<Copy size={15} />
</button>
<button
type="button"
className={styles.controlBtn}
onClick={() => void removeDomainPromptItem(prompt)}
disabled={promptBusyId === prompt.id}
aria-label={`Remove ${prompt.name}`}
title="Remove"
>
<Trash2 size={15} />
</button>
</div>
</li>
))}
</ul>
)
) : null}
<div style={{ height: 12 }} />
<div className={styles.actionsRow}>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
onClick={closeList}
disabled={promptEditorSubmitting}
>
Close
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={openAddDomainPrompt}
disabled={promptEditorSubmitting || domainId == null || promptsLoading}
>
<Plus size={16} />
Add
</button>
</div>
</Modal>
<Modal
open={promptEditorOpen}
title={editingDomainPrompt ? 'Edit prompt' : 'Add prompt'}
wide
onClose={() => {
if (promptEditorSubmitting) return
setPromptEditorOpen(false)
setEditingDomainPrompt(null)
setPromptEditorError('')
}}
>
{promptEditorError ? (
<p className={styles.alertError} role="alert">
{promptEditorError}
</p>
) : null}
<div className={styles.field}>
<label htmlFor="domain-ai-prompt-template">Template</label>
<select
id="domain-ai-prompt-template"
value={promptDraftTemplateId}
onChange={(e) => applyTemplateToDraft(e.target.value)}
disabled={promptEditorSubmitting || allPromptTemplates.length === 0}
>
<option value="">Select a template (optional)</option>
{allPromptTemplates.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
<div className={styles.field}>
<label htmlFor="domain-ai-prompt-name">Name</label>
<input
id="domain-ai-prompt-name"
value={promptDraftName}
onChange={(e) => setPromptDraftName(e.target.value)}
placeholder="Prompt name"
disabled={promptEditorSubmitting}
/>
</div>
<div className={styles.field}>
<label htmlFor="domain-ai-prompt-body">Prompt</label>
<textarea
id="domain-ai-prompt-body"
className={styles.promptsTextarea}
value={promptDraftBody}
onChange={(e) => setPromptDraftBody(e.target.value)}
placeholder="Prompt text…"
disabled={promptEditorSubmitting}
rows={12}
/>
</div>
<div style={{ height: 12 }} />
<div className={styles.actionsRow}>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
onClick={() => setPromptEditorOpen(false)}
disabled={promptEditorSubmitting}
>
Cancel
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={() => void submitDomainPromptEditor()}
disabled={promptEditorSubmitting}
>
{editingDomainPrompt ? 'Save' : 'Add'}
</button>
</div>
</Modal>
</>
)
}
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
import { import {
Building2, Building2,
ExternalLink, ExternalLink,
FileText,
Globe, Globe,
MoreHorizontal, MoreHorizontal,
Trash2, Trash2,
@@ -18,6 +19,7 @@ type PopoverPosition = {
interface DomainLinksOverflowMenuProps { interface DomainLinksOverflowMenuProps {
host: string host: string
onAiPrompts: () => void
onRemove: () => void onRemove: () => void
} }
@@ -37,7 +39,11 @@ function openHost(host: string) {
window.open(siteUrl(host), '_blank', 'noopener,noreferrer') window.open(siteUrl(host), '_blank', 'noopener,noreferrer')
} }
export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowMenuProps) { export function DomainLinksOverflowMenu({
host,
onAiPrompts,
onRemove,
}: DomainLinksOverflowMenuProps) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [popoverPos, setPopoverPos] = useState<PopoverPosition | null>(null) const [popoverPos, setPopoverPos] = useState<PopoverPosition | null>(null)
const wrapRef = useRef<HTMLDivElement>(null) const wrapRef = useRef<HTMLDivElement>(null)
@@ -55,7 +61,7 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
const size = popoverRef.current const size = popoverRef.current
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight } ? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
: { width: 220, height: 220 } : { width: 220, height: 280 }
setPopoverPos(fitFixedPopover(rect, size, 'end')) setPopoverPos(fitFixedPopover(rect, size, 'end'))
} }
@@ -184,6 +190,18 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
</button> </button>
))} ))}
<div className={styles.menuDivider} role="separator" /> <div className={styles.menuDivider} role="separator" />
<button
type="button"
className={styles.item}
role="menuitem"
onClick={() => {
setOpen(false)
onAiPrompts()
}}
>
<FileText size={16} />
<span>AI prompts</span>
</button>
<button <button
type="button" type="button"
className={`${styles.item} ${styles.itemDanger}`} className={`${styles.item} ${styles.itemDanger}`}
@@ -0,0 +1,122 @@
.rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto 32px;
gap: 8px;
align-items: center;
}
.row input {
width: 100%;
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
line-height: 1.4;
font-family: var(--font-ui);
color: var(--text-primary);
background-color: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
box-sizing: border-box;
}
.row input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.row input:disabled {
opacity: 0.65;
}
.removeRowBtn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 8px;
color: var(--text-muted);
transition: background 0.2s, color 0.2s;
}
.removeRowBtn:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.removeRowBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.addValueBtn {
display: inline-flex;
align-items: center;
gap: 6px;
align-self: flex-start;
margin-top: 10px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
color: var(--primary);
border-radius: var(--radius-sm);
transition: background 0.2s;
}
.addValueBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
}
.addValueBtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.hint {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.45;
color: var(--text-secondary);
}
.canonical {
font-weight: 600;
color: var(--text-primary);
}
.dnsSwitch {
display: inline-flex;
flex-shrink: 0;
padding: 2px;
border-radius: 10px;
background: rgba(148, 163, 184, 0.18);
}
.dnsOption {
min-height: 28px;
padding: 0 10px;
border-radius: 8px;
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
transition: background 0.15s, color 0.15s;
}
.dnsOptionOn {
background: #fff;
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12);
}
.dnsOption:disabled {
opacity: 0.55;
cursor: not-allowed;
}
@@ -0,0 +1,201 @@
import { useEffect, useState } from 'react'
import { Plus, X } from 'lucide-react'
import { Modal } from './Modal'
import { ApiError } from '../lib/api'
import { updateParkedHosts } from '../services/domainService'
import type { DomainListItem, ParkedAlias, ParkedDnsProvider } from '../types/domain'
import { useToast } from '../context/ToastContext'
import tableStyles from '../pages/BusinessesPage.module.css'
import styles from './ParkedDomainsModal.module.css'
interface ParkedDomainsModalProps {
domain: DomainListItem | null
onClose: () => void
onSaved: (payload: { parkedHosts: string[]; parkedAliases: ParkedAlias[] }) => void
}
type Row = { host: string; dns: ParkedDnsProvider }
function rowsFromDomain(domain: DomainListItem): Row[] {
if (domain.parkedAliases?.length) {
return domain.parkedAliases.map((item) => ({
host: item.host,
dns: item.dns === 'cloudflare' ? 'cloudflare' : 'arvan',
}))
}
const hosts = domain.parkedHosts?.filter(Boolean) ?? []
if (hosts.length === 0) return [{ host: '', dns: 'arvan' }]
return hosts.map((host) => ({ host, dns: 'arvan' }))
}
export function ParkedDomainsModal({ domain, onClose, onSaved }: ParkedDomainsModalProps) {
const { showToast } = useToast()
const [rows, setRows] = useState<Row[]>([{ host: '', dns: 'arvan' }])
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!domain) {
setRows([{ host: '', dns: 'arvan' }])
setError('')
setSubmitting(false)
return
}
setRows(rowsFromDomain(domain))
setError('')
setSubmitting(false)
}, [domain])
if (!domain) return null
function updateHost(index: number, host: string) {
setRows((prev) => prev.map((row, i) => (i === index ? { ...row, host } : row)))
}
function updateDns(index: number, dns: ParkedDnsProvider) {
setRows((prev) => prev.map((row, i) => (i === index ? { ...row, dns } : row)))
}
function removeRow(index: number) {
setRows((prev) => {
const next = prev.filter((_, i) => i !== index)
return next.length > 0 ? next : [{ host: '', dns: 'arvan' }]
})
}
async function submit() {
setSubmitting(true)
setError('')
try {
const result = await updateParkedHosts(domain!.id, rows)
onSaved({
parkedHosts: result.parkedHosts,
parkedAliases: result.parkedAliases,
})
if (result.status === 'partial') {
setError(result.message)
showToast(result.message, 'error')
setRows(
result.parkedAliases.length > 0
? result.parkedAliases
: [{ host: '', dns: 'arvan' }],
)
return
}
showToast(result.message || 'Parked domains saved.', 'success')
onClose()
} catch (err) {
const message =
err instanceof ApiError ? err.message : 'Unable to save parked domains.'
setError(message)
showToast(message, 'error')
} finally {
setSubmitting(false)
}
}
return (
<Modal
open
title={`Parked domains · ${domain.host}`}
onClose={() => {
if (!submitting) onClose()
}}
>
<p className={styles.hint}>
Aliases 301 to{' '}
<span className={styles.canonical} dir="ltr">
{domain.host}
</span>
. Pick where DNS lives (Arvan or Cloudflare) — we set <strong>@</strong> and{' '}
<strong>www</strong> only, DNS-only / not proxied. SSL is always Let&apos;s
Encrypt on the websites server.
</p>
{error ? (
<p className={tableStyles.alertError} role="alert">
{error}
</p>
) : null}
{submitting ? (
<p className={tableStyles.helperText} role="status">
Updating DNS, nginx redirect, and SSL…
</p>
) : null}
<div className={styles.rows}>
{rows.map((row, index) => (
<div key={index} className={styles.row}>
<input
type="text"
value={row.host}
onChange={(e) => updateHost(index, e.target.value)}
placeholder="samanandish.co"
disabled={submitting}
autoComplete="off"
spellCheck={false}
dir="ltr"
aria-label={`Parked domain ${index + 1}`}
/>
<div className={styles.dnsSwitch} role="group" aria-label="DNS provider">
<button
type="button"
className={`${styles.dnsOption} ${row.dns === 'arvan' ? styles.dnsOptionOn : ''}`}
onClick={() => updateDns(index, 'arvan')}
disabled={submitting}
aria-pressed={row.dns === 'arvan'}
>
Arvan
</button>
<button
type="button"
className={`${styles.dnsOption} ${row.dns === 'cloudflare' ? styles.dnsOptionOn : ''}`}
onClick={() => updateDns(index, 'cloudflare')}
disabled={submitting}
aria-pressed={row.dns === 'cloudflare'}
>
Cloudflare
</button>
</div>
<button
type="button"
className={styles.removeRowBtn}
onClick={() => removeRow(index)}
disabled={submitting || (rows.length === 1 && !row.host.trim())}
title="Remove"
aria-label={`Remove parked domain ${index + 1}`}
>
<X size={16} />
</button>
</div>
))}
</div>
<button
type="button"
className={styles.addValueBtn}
onClick={() => setRows((prev) => [...prev, { host: '', dns: 'arvan' }])}
disabled={submitting}
>
<Plus size={16} />
Add domain
</button>
<div style={{ height: 12 }} />
<div className={tableStyles.actionsRow}>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
onClick={onClose}
disabled={submitting}
>
Cancel
</button>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
onClick={() => void submit()}
disabled={submitting}
>
Save
</button>
</div>
</Modal>
)
}
+14 -7
View File
@@ -140,11 +140,13 @@ function nextAnnualRenewal(contractAt: string, now = new Date()) {
return { next: due, year: due.getFullYear(), daysRemaining } return { next: due, year: due.getFullYear(), daysRemaining }
} }
function oneYearFromTodayIso(from = new Date()) { function addOneYearToDueIso(contractAt: string, now = new Date()) {
const current = nextAnnualRenewal(contractAt, now)
if (!current) return null
const next = new Date( const next = new Date(
from.getFullYear() + 1, current.next.getFullYear() + 1,
from.getMonth(), current.next.getMonth(),
from.getDate(), current.next.getDate(),
12, 12,
0, 0,
0, 0,
@@ -894,7 +896,12 @@ export function BusinessesPage() {
} }
async function restartRenewalCountdown(b: BusinessListItem) { async function restartRenewalCountdown(b: BusinessListItem) {
const annualRenewalAt = oneYearFromTodayIso() const contractAt = b.annualRenewalAt || b.createdAt
const annualRenewalAt = addOneYearToDueIso(contractAt)
if (!annualRenewalAt) {
showToast('Unable to restart renewal countdown.', 'error')
return
}
setSavingRenewalId(b.id) setSavingRenewalId(b.id)
try { try {
await updateBusiness(b.id, { annualRenewalAt }) await updateBusiness(b.id, { annualRenewalAt })
@@ -1525,8 +1532,8 @@ export function BusinessesPage() {
className={styles.renewalRefreshBtn} className={styles.renewalRefreshBtn}
onClick={() => void restartRenewalCountdown(b)} onClick={() => void restartRenewalCountdown(b)}
disabled={savingRenewalId === b.id} disabled={savingRenewalId === b.id}
aria-label={`Restart renewal countdown for ${b.name}`} aria-label={`Advance renewal one year from the contract date for ${b.name}`}
title="Restart countdown" title="Advance one year from the contract date"
> >
<RefreshCw <RefreshCw
size={12} size={12}
@@ -131,3 +131,16 @@
.listErrorWrap { .listErrorWrap {
padding: 0 16px 12px; padding: 0 16px 12px;
} }
.hostCell {
display: flex;
flex-direction: column;
gap: 2px;
}
.parkedMeta {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
word-break: break-all;
}
+114 -3
View File
@@ -10,10 +10,13 @@ import {
RotateCcw, RotateCcw,
Search, Search,
ShieldPlus, ShieldPlus,
Link2,
X, X,
} from 'lucide-react' } from 'lucide-react'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { DomainAiPromptsModal } from '../components/DomainAiPromptsModal'
import { DomainLinksOverflowMenu } from '../components/DomainLinksOverflowMenu' import { DomainLinksOverflowMenu } from '../components/DomainLinksOverflowMenu'
import { ParkedDomainsModal } from '../components/ParkedDomainsModal'
import { Modal } from '../components/Modal' import { Modal } from '../components/Modal'
import { ToggleSwitch } from '../components/ToggleSwitch' import { ToggleSwitch } from '../components/ToggleSwitch'
import type { DomainListItem, DomainsListResponse } from '../types/domain' import type { DomainListItem, DomainsListResponse } from '../types/domain'
@@ -101,11 +104,14 @@ export function WebsitesPage() {
const [editDomain, setEditDomain] = useState<DomainListItem | null>(null) const [editDomain, setEditDomain] = useState<DomainListItem | null>(null)
const [editHost, setEditHost] = useState('') const [editHost, setEditHost] = useState('')
const [editExpiresAt, setEditExpiresAt] = useState('') const [editExpiresAt, setEditExpiresAt] = useState('')
const [editGitRepoUrl, setEditGitRepoUrl] = useState('')
const [editUpdateDns, setEditUpdateDns] = useState(false) const [editUpdateDns, setEditUpdateDns] = useState(false)
const [editSubmitting, setEditSubmitting] = useState(false) const [editSubmitting, setEditSubmitting] = useState(false)
const [editError, setEditError] = useState('') const [editError, setEditError] = useState('')
const [removeTarget, setRemoveTarget] = useState<DomainListItem | null>(null) const [removeTarget, setRemoveTarget] = useState<DomainListItem | null>(null)
const [promptsDomain, setPromptsDomain] = useState<DomainListItem | null>(null)
const [parkedDomain, setParkedDomain] = useState<DomainListItem | null>(null)
const [togglingActiveId, setTogglingActiveId] = useState<number | null>(null) const [togglingActiveId, setTogglingActiveId] = useState<number | null>(null)
const [deployingId, setDeployingId] = useState<DomainListItem['id'] | null>(null) const [deployingId, setDeployingId] = useState<DomainListItem['id'] | null>(null)
const [deployError, setDeployError] = useState('') const [deployError, setDeployError] = useState('')
@@ -171,6 +177,7 @@ export function WebsitesPage() {
setEditDomain(domain) setEditDomain(domain)
setEditHost(domain.host) setEditHost(domain.host)
setEditExpiresAt(domain.expiresAt ? domain.expiresAt.slice(0, 10) : '') setEditExpiresAt(domain.expiresAt ? domain.expiresAt.slice(0, 10) : '')
setEditGitRepoUrl(domain.gitRepoUrl ?? '')
setEditUpdateDns(false) setEditUpdateDns(false)
setEditError('') setEditError('')
setEditOpen(true) setEditOpen(true)
@@ -178,6 +185,7 @@ export function WebsitesPage() {
async function submitEdit() { async function submitEdit() {
if (!editDomain) return if (!editDomain) return
const gitRepoUrl = editGitRepoUrl.trim()
setEditSubmitting(true) setEditSubmitting(true)
setEditError('') setEditError('')
try { try {
@@ -185,14 +193,25 @@ export function WebsitesPage() {
host: editHost.trim(), host: editHost.trim(),
updateDns: editUpdateDns, updateDns: editUpdateDns,
...(editExpiresAt ? { expiresAt: new Date(editExpiresAt).toISOString() } : {}), ...(editExpiresAt ? { expiresAt: new Date(editExpiresAt).toISOString() } : {}),
})) as { dnsError?: string | null } ...(gitRepoUrl ? { gitRepoUrl } : {}),
})) as {
dnsError?: string | null
provisionError?: string | null
gitRepoUrl?: string | null
deploySlug?: string | null
}
setEditOpen(false) setEditOpen(false)
setEditDomain(null) setEditDomain(null)
setEditGitRepoUrl('')
setEditUpdateDns(false) setEditUpdateDns(false)
if (updated.provisionError) {
showToast(`"${editHost.trim()}" was updated, but storefront setup failed.`, 'error')
showToast(updated.provisionError, 'error')
}
if (updated.dnsError) { if (updated.dnsError) {
showToast(`"${editHost.trim()}" was updated, but Arvan DNS failed.`, 'error') showToast(`"${editHost.trim()}" was updated, but Arvan DNS failed.`, 'error')
showToast(updated.dnsError, 'error') showToast(updated.dnsError, 'error')
} else { } else if (!updated.provisionError) {
showToast( showToast(
editUpdateDns editUpdateDns
? `"${editHost.trim()}" has been updated and DNS records synced.` ? `"${editHost.trim()}" has been updated and DNS records synced.`
@@ -210,6 +229,12 @@ export function WebsitesPage() {
...item, ...item,
host: editHost.trim(), host: editHost.trim(),
expiresAt: editExpiresAt ? new Date(editExpiresAt).toISOString() : item.expiresAt, expiresAt: editExpiresAt ? new Date(editExpiresAt).toISOString() : item.expiresAt,
...(gitRepoUrl && !updated.provisionError
? {
gitRepoUrl: updated.gitRepoUrl ?? gitRepoUrl,
deploySlug: updated.deploySlug ?? item.deploySlug,
}
: {}),
} }
: item, : item,
), ),
@@ -596,7 +621,16 @@ export function WebsitesPage() {
key={domain.id} key={domain.id}
className={!domain.isActive ? styles.inactiveRow : undefined} className={!domain.isActive ? styles.inactiveRow : undefined}
> >
<td className={tableStyles.td}>{domain.host}</td> <td className={tableStyles.td}>
<div className={styles.hostCell}>
<span>{domain.host}</span>
{domain.parkedHosts?.length ? (
<span className={styles.parkedMeta} dir="ltr">
Parked: {domain.parkedHosts.join(', ')}
</span>
) : null}
</div>
</td>
<td className={tableStyles.td}>{domain.businessName}</td> <td className={tableStyles.td}>{domain.businessName}</td>
<td className={tableStyles.td}> <td className={tableStyles.td}>
<span className={daysLeftClass(domain.expiresAt)}> <span className={daysLeftClass(domain.expiresAt)}>
@@ -727,8 +761,18 @@ export function WebsitesPage() {
> >
<Pencil size={16} /> <Pencil size={16} />
</button> </button>
<button
type="button"
className={tableStyles.controlBtn}
onClick={() => setParkedDomain(domain)}
title="Parked domains"
aria-label={`Parked domains for ${domain.host}`}
>
<Link2 size={16} />
</button>
<DomainLinksOverflowMenu <DomainLinksOverflowMenu
host={domain.host} host={domain.host}
onAiPrompts={() => setPromptsDomain(domain)}
onRemove={() => setRemoveTarget(domain)} onRemove={() => setRemoveTarget(domain)}
/> />
</div> </div>
@@ -760,6 +804,7 @@ export function WebsitesPage() {
onClose={() => { onClose={() => {
setEditOpen(false) setEditOpen(false)
setEditDomain(null) setEditDomain(null)
setEditGitRepoUrl('')
setEditUpdateDns(false) setEditUpdateDns(false)
setEditError('') setEditError('')
}} }}
@@ -769,6 +814,13 @@ export function WebsitesPage() {
{editError} {editError}
</p> </p>
) : null} ) : null}
{editSubmitting && (editGitRepoUrl.trim() || editUpdateDns) ? (
<p className={tableStyles.helperText} role="status">
{editGitRepoUrl.trim()
? 'Saving domain and setting up storefront on the websites server…'
: 'Saving domain and updating Arvan DNS…'}
</p>
) : null}
<div className={tableStyles.field}> <div className={tableStyles.field}>
<label htmlFor="edit-domain-host">Domain name</label> <label htmlFor="edit-domain-host">Domain name</label>
<input <input
@@ -790,6 +842,23 @@ export function WebsitesPage() {
/> />
</div> </div>
<div style={{ height: 12 }} /> <div style={{ height: 12 }} />
<div className={tableStyles.field}>
<label htmlFor="edit-domain-git-repo">Git repo URL (optional)</label>
<input
id="edit-domain-git-repo"
value={editGitRepoUrl}
onChange={(e) => setEditGitRepoUrl(e.target.value)}
placeholder="https://git.meshkee.com/Meshkee-Websites/oaktasty.git"
disabled={editSubmitting}
autoComplete="off"
spellCheck={false}
/>
<p className={tableStyles.subText} style={{ marginTop: 6 }}>
Fill to enable storefront Deploy on Websites. Leave empty for domain-only
(dashboards).
</p>
</div>
<div style={{ height: 12 }} />
<label className={tableStyles.entityCheck} htmlFor="edit-domain-update-dns"> <label className={tableStyles.entityCheck} htmlFor="edit-domain-update-dns">
<input <input
id="edit-domain-update-dns" id="edit-domain-update-dns"
@@ -825,6 +894,48 @@ export function WebsitesPage() {
</div> </div>
</Modal> </Modal>
<ParkedDomainsModal
domain={parkedDomain}
onClose={() => setParkedDomain(null)}
onSaved={(payload) => {
setData((prev) => {
if (!prev || !parkedDomain) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === parkedDomain.id
? {
...item,
parkedHosts: payload.parkedHosts,
parkedAliases: payload.parkedAliases,
}
: item,
),
}
})
setParkedDomain((current) =>
current
? {
...current,
parkedHosts: payload.parkedHosts,
parkedAliases: payload.parkedAliases,
}
: current,
)
}}
/>
<DomainAiPromptsModal
open={promptsDomain !== null}
title={
promptsDomain
? `AI prompts · ${promptsDomain.businessName || promptsDomain.host}`
: 'AI prompts'
}
domainId={promptsDomain?.id ?? null}
onClose={() => setPromptsDomain(null)}
/>
<ConfirmDeleteModal <ConfirmDeleteModal
open={removeTarget !== null} open={removeTarget !== null}
title="Remove domain?" title="Remove domain?"
+14 -1
View File
@@ -5,6 +5,7 @@ import type {
IssueDomainSslResponse, IssueDomainSslResponse,
IssueWebsiteSslResponse, IssueWebsiteSslResponse,
SyncSslResponse, SyncSslResponse,
UpdateParkedHostsResponse,
} from '../types/domain' } from '../types/domain'
export interface ListDomainsParams { export interface ListDomainsParams {
@@ -18,6 +19,8 @@ export interface UpdateDomainPayload {
expiresAt?: string expiresAt?: string
/** When true, upsert Meshkee tenant records in Arvan DNS. */ /** When true, upsert Meshkee tenant records in Arvan DNS. */
updateDns?: boolean updateDns?: boolean
/** When set, provisions storefront deploy on the websites VM. */
gitRepoUrl?: string
} }
export async function listDomains(params: ListDomainsParams, signal?: AbortSignal) { export async function listDomains(params: ListDomainsParams, signal?: AbortSignal) {
@@ -84,7 +87,17 @@ export async function issueWebsiteSsl() {
}) })
} }
/** Ensure SSL for apex + business.* + customer.* (probe, skip if valid, else issue). */ export async function updateParkedHosts(
domainId: number | string,
aliases: Array<{ host: string; dns: 'arvan' | 'cloudflare' }>,
) {
return apiRequest<UpdateParkedHostsResponse>(`/domains/${domainId}/parked-hosts`, {
method: 'PUT',
auth: true,
body: { aliases },
})
}
export async function issueDomainSsl(domainId: number | string) { export async function issueDomainSsl(domainId: number | string) {
return apiRequest<IssueDomainSslResponse>(`/domains/${domainId}/issue-ssl`, { return apiRequest<IssueDomainSslResponse>(`/domains/${domainId}/issue-ssl`, {
method: 'POST', method: 'POST',
+28
View File
@@ -1,5 +1,12 @@
export type DomainDeployStatus = 'started' | 'success' | 'failed' export type DomainDeployStatus = 'started' | 'success' | 'failed'
export type ParkedDnsProvider = 'arvan' | 'cloudflare'
export interface ParkedAlias {
host: string
dns: ParkedDnsProvider
}
export interface DomainListItem { export interface DomainListItem {
id: number id: number
host: string host: string
@@ -13,6 +20,10 @@ export interface DomainListItem {
createdAt: string createdAt: string
/** Present when this apex has a storefront on the websites VM */ /** Present when this apex has a storefront on the websites VM */
deploySlug: string | null deploySlug: string | null
gitRepoUrl: string | null
/** Alias hosts that 301 to this domain (apex + www only). */
parkedHosts: string[]
parkedAliases: ParkedAlias[]
lastDeployedAt: string | null lastDeployedAt: string | null
lastDeployStatus: DomainDeployStatus | null lastDeployStatus: DomainDeployStatus | null
} }
@@ -66,3 +77,20 @@ export interface IssueDomainSslResponse {
failed: Array<{ host: string; error: string }> failed: Array<{ host: string; error: string }>
message: string message: string
} }
export interface UpdateParkedHostsResponse {
host: string
parkedHosts: string[]
parkedAliases: ParkedAlias[]
status: 'ok' | 'partial'
message: string
results: Array<{
host: string
action: 'add' | 'remove' | 'keep'
ok: boolean
dns?: string | null
redirect?: string | null
ssl?: string | null
error?: string
}>
}
+2 -2
View File
@@ -142,8 +142,8 @@ Add to `/etc/hosts` (one line per tenant):
| `/businesses/:businessId/invoices` | Business invoices list | | `/businesses/:businessId/invoices` | Business invoices list |
| `/businesses/:businessId/invoices/new` | Issue invoice (full page) | | `/businesses/:businessId/invoices/new` | Issue invoice (full page) |
| `/users` | Users (business filter: Customer vs Manager → Admin/Editor/Viewer; Admin assignable by super-admin only; owners locked) | | `/users` | Users (business filter: Customer vs Manager → Admin/Editor/Viewer; Admin assignable by super-admin only; owners locked) |
| `/websites` | Websites / domains (Deploy when `deploy_slug` set). Edit domain can optionally update Arvan DNS. | | `/websites` | Websites / domains (Deploy when `deploy_slug` set). Edit domain can optionally update Arvan DNS. Overflow ⋯ includes website/dashboard links, **AI prompts**, and remove. **Parked domains** button: alias hosts 301 to the main domain. Per alias, choose **Arvan** or **Cloudflare** for DNS (`@` + `www` only, not proxied); SSL is always Let's Encrypt on the websites VM. |
| `/templates` | Global AI prompt templates. Assign from Businesses ⋯ → **AI prompts** (add from library; public pack link). | | `/templates` | Global AI prompt templates. Assign from Businesses or Websites ⋯ → **AI prompts** (add from library; public pack link). |
| `/settings` | Platform settings (invoice templates + item templates) | | `/settings` | Platform settings (invoice templates + item templates) |
| `/settings/invoice-templates/new` | Create invoice template | | `/settings/invoice-templates/new` | Create invoice template |
| `/settings/invoice-templates/:templateId` | Edit invoice template | | `/settings/invoice-templates/:templateId` | Edit invoice template |