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;
|
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'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 }
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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?"
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
Reference in New Issue
Block a user