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