Add parked-domain aliases, Websites AI prompts, and frosted overflow menus.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
e9400c2281
commit
0d62a4ca79
@@ -29,16 +29,21 @@
|
||||
z-index: 1200;
|
||||
min-width: 220px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(80px) saturate(1.7);
|
||||
-webkit-backdrop-filter: blur(80px) saturate(1.7);
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--glass-border);
|
||||
background:
|
||||
linear-gradient(
|
||||
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:
|
||||
0 12px 40px rgba(15, 23, 42, 0.18),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||
isolation: isolate;
|
||||
animation: menuIn 0.14s ease;
|
||||
0 18px 48px rgba(15, 23, 42, 0.2),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.95);
|
||||
animation: menuIn 0.16s ease;
|
||||
}
|
||||
|
||||
.item {
|
||||
@@ -97,10 +102,8 @@
|
||||
@keyframes menuIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
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 {
|
||||
Building2,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Globe,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
@@ -18,6 +19,7 @@ type PopoverPosition = {
|
||||
|
||||
interface DomainLinksOverflowMenuProps {
|
||||
host: string
|
||||
onAiPrompts: () => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
@@ -37,7 +39,11 @@ function openHost(host: string) {
|
||||
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 [popoverPos, setPopoverPos] = useState<PopoverPosition | null>(null)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
@@ -55,7 +61,7 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
|
||||
|
||||
const size = popoverRef.current
|
||||
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
|
||||
: { width: 220, height: 220 }
|
||||
: { width: 220, height: 280 }
|
||||
|
||||
setPopoverPos(fitFixedPopover(rect, size, 'end'))
|
||||
}
|
||||
@@ -184,6 +190,18 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
|
||||
</button>
|
||||
))}
|
||||
<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
|
||||
type="button"
|
||||
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'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>
|
||||
)
|
||||
}
|
||||
@@ -140,11 +140,13 @@ function nextAnnualRenewal(contractAt: string, now = new Date()) {
|
||||
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(
|
||||
from.getFullYear() + 1,
|
||||
from.getMonth(),
|
||||
from.getDate(),
|
||||
current.next.getFullYear() + 1,
|
||||
current.next.getMonth(),
|
||||
current.next.getDate(),
|
||||
12,
|
||||
0,
|
||||
0,
|
||||
@@ -894,7 +896,12 @@ export function BusinessesPage() {
|
||||
}
|
||||
|
||||
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)
|
||||
try {
|
||||
await updateBusiness(b.id, { annualRenewalAt })
|
||||
@@ -1525,8 +1532,8 @@ export function BusinessesPage() {
|
||||
className={styles.renewalRefreshBtn}
|
||||
onClick={() => void restartRenewalCountdown(b)}
|
||||
disabled={savingRenewalId === b.id}
|
||||
aria-label={`Restart renewal countdown for ${b.name}`}
|
||||
title="Restart countdown"
|
||||
aria-label={`Advance renewal one year from the contract date for ${b.name}`}
|
||||
title="Advance one year from the contract date"
|
||||
>
|
||||
<RefreshCw
|
||||
size={12}
|
||||
|
||||
@@ -131,3 +131,16 @@
|
||||
.listErrorWrap {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -10,10 +10,13 @@ import {
|
||||
RotateCcw,
|
||||
Search,
|
||||
ShieldPlus,
|
||||
Link2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { DomainAiPromptsModal } from '../components/DomainAiPromptsModal'
|
||||
import { DomainLinksOverflowMenu } from '../components/DomainLinksOverflowMenu'
|
||||
import { ParkedDomainsModal } from '../components/ParkedDomainsModal'
|
||||
import { Modal } from '../components/Modal'
|
||||
import { ToggleSwitch } from '../components/ToggleSwitch'
|
||||
import type { DomainListItem, DomainsListResponse } from '../types/domain'
|
||||
@@ -101,11 +104,14 @@ export function WebsitesPage() {
|
||||
const [editDomain, setEditDomain] = useState<DomainListItem | null>(null)
|
||||
const [editHost, setEditHost] = useState('')
|
||||
const [editExpiresAt, setEditExpiresAt] = useState('')
|
||||
const [editGitRepoUrl, setEditGitRepoUrl] = useState('')
|
||||
const [editUpdateDns, setEditUpdateDns] = useState(false)
|
||||
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||
const [editError, setEditError] = useState('')
|
||||
|
||||
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 [deployingId, setDeployingId] = useState<DomainListItem['id'] | null>(null)
|
||||
const [deployError, setDeployError] = useState('')
|
||||
@@ -171,6 +177,7 @@ export function WebsitesPage() {
|
||||
setEditDomain(domain)
|
||||
setEditHost(domain.host)
|
||||
setEditExpiresAt(domain.expiresAt ? domain.expiresAt.slice(0, 10) : '')
|
||||
setEditGitRepoUrl(domain.gitRepoUrl ?? '')
|
||||
setEditUpdateDns(false)
|
||||
setEditError('')
|
||||
setEditOpen(true)
|
||||
@@ -178,6 +185,7 @@ export function WebsitesPage() {
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editDomain) return
|
||||
const gitRepoUrl = editGitRepoUrl.trim()
|
||||
setEditSubmitting(true)
|
||||
setEditError('')
|
||||
try {
|
||||
@@ -185,14 +193,25 @@ export function WebsitesPage() {
|
||||
host: editHost.trim(),
|
||||
updateDns: editUpdateDns,
|
||||
...(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)
|
||||
setEditDomain(null)
|
||||
setEditGitRepoUrl('')
|
||||
setEditUpdateDns(false)
|
||||
if (updated.provisionError) {
|
||||
showToast(`"${editHost.trim()}" was updated, but storefront setup failed.`, 'error')
|
||||
showToast(updated.provisionError, 'error')
|
||||
}
|
||||
if (updated.dnsError) {
|
||||
showToast(`"${editHost.trim()}" was updated, but Arvan DNS failed.`, 'error')
|
||||
showToast(updated.dnsError, 'error')
|
||||
} else {
|
||||
} else if (!updated.provisionError) {
|
||||
showToast(
|
||||
editUpdateDns
|
||||
? `"${editHost.trim()}" has been updated and DNS records synced.`
|
||||
@@ -210,6 +229,12 @@ export function WebsitesPage() {
|
||||
...item,
|
||||
host: editHost.trim(),
|
||||
expiresAt: editExpiresAt ? new Date(editExpiresAt).toISOString() : item.expiresAt,
|
||||
...(gitRepoUrl && !updated.provisionError
|
||||
? {
|
||||
gitRepoUrl: updated.gitRepoUrl ?? gitRepoUrl,
|
||||
deploySlug: updated.deploySlug ?? item.deploySlug,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
@@ -596,7 +621,16 @@ export function WebsitesPage() {
|
||||
key={domain.id}
|
||||
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}>
|
||||
<span className={daysLeftClass(domain.expiresAt)}>
|
||||
@@ -727,8 +761,18 @@ export function WebsitesPage() {
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</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
|
||||
host={domain.host}
|
||||
onAiPrompts={() => setPromptsDomain(domain)}
|
||||
onRemove={() => setRemoveTarget(domain)}
|
||||
/>
|
||||
</div>
|
||||
@@ -760,6 +804,7 @@ export function WebsitesPage() {
|
||||
onClose={() => {
|
||||
setEditOpen(false)
|
||||
setEditDomain(null)
|
||||
setEditGitRepoUrl('')
|
||||
setEditUpdateDns(false)
|
||||
setEditError('')
|
||||
}}
|
||||
@@ -769,6 +814,13 @@ export function WebsitesPage() {
|
||||
{editError}
|
||||
</p>
|
||||
) : 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}>
|
||||
<label htmlFor="edit-domain-host">Domain name</label>
|
||||
<input
|
||||
@@ -790,6 +842,23 @@ export function WebsitesPage() {
|
||||
/>
|
||||
</div>
|
||||
<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">
|
||||
<input
|
||||
id="edit-domain-update-dns"
|
||||
@@ -825,6 +894,48 @@ export function WebsitesPage() {
|
||||
</div>
|
||||
</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
|
||||
open={removeTarget !== null}
|
||||
title="Remove domain?"
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
IssueDomainSslResponse,
|
||||
IssueWebsiteSslResponse,
|
||||
SyncSslResponse,
|
||||
UpdateParkedHostsResponse,
|
||||
} from '../types/domain'
|
||||
|
||||
export interface ListDomainsParams {
|
||||
@@ -18,6 +19,8 @@ export interface UpdateDomainPayload {
|
||||
expiresAt?: string
|
||||
/** When true, upsert Meshkee tenant records in Arvan DNS. */
|
||||
updateDns?: boolean
|
||||
/** When set, provisions storefront deploy on the websites VM. */
|
||||
gitRepoUrl?: string
|
||||
}
|
||||
|
||||
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) {
|
||||
return apiRequest<IssueDomainSslResponse>(`/domains/${domainId}/issue-ssl`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
export type DomainDeployStatus = 'started' | 'success' | 'failed'
|
||||
|
||||
export type ParkedDnsProvider = 'arvan' | 'cloudflare'
|
||||
|
||||
export interface ParkedAlias {
|
||||
host: string
|
||||
dns: ParkedDnsProvider
|
||||
}
|
||||
|
||||
export interface DomainListItem {
|
||||
id: number
|
||||
host: string
|
||||
@@ -13,6 +20,10 @@ export interface DomainListItem {
|
||||
createdAt: string
|
||||
/** Present when this apex has a storefront on the websites VM */
|
||||
deploySlug: string | null
|
||||
gitRepoUrl: string | null
|
||||
/** Alias hosts that 301 to this domain (apex + www only). */
|
||||
parkedHosts: string[]
|
||||
parkedAliases: ParkedAlias[]
|
||||
lastDeployedAt: string | null
|
||||
lastDeployStatus: DomainDeployStatus | null
|
||||
}
|
||||
@@ -66,3 +77,20 @@ export interface IssueDomainSslResponse {
|
||||
failed: Array<{ host: string; error: 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
|
||||
}>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user