Add Super Admin AI prompt templates and business assignment UI.
Templates library plus per-business prompt list with edit, copy public link, and remove. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
b50e4fd812
commit
0aab7b5529
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
AlertTriangle,
|
||||
Copy,
|
||||
Database,
|
||||
FileText,
|
||||
Globe,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Trash2,
|
||||
UserCog,
|
||||
} from 'lucide-react'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
@@ -46,6 +48,14 @@ import {
|
||||
} from '../services/businessService'
|
||||
import { listBusinessCategories } from '../services/categoryService'
|
||||
import { listUserTitles, type UserTitleOption } from '../services/userTitleService'
|
||||
import {
|
||||
createDomainAiPrompt,
|
||||
deleteDomainAiPrompt,
|
||||
listAiPromptTemplates,
|
||||
listDomainAiPrompts,
|
||||
updateDomainAiPrompt,
|
||||
} from '../services/aiPromptService'
|
||||
import type { AiPromptTemplate, DomainAiPrompt } from '../types/aiPrompt'
|
||||
import { Modal } from '../components/Modal'
|
||||
import { PrimaryColorSwatchControl } from '../components/PrimaryColorSwatchControl'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
@@ -315,6 +325,21 @@ export function BusinessesPage() {
|
||||
const [domainSubmitting, setDomainSubmitting] = useState(false)
|
||||
const [domainError, setDomainError] = useState('')
|
||||
|
||||
const [promptsOpen, setPromptsOpen] = useState(false)
|
||||
const [promptsBusiness, setPromptsBusiness] = useState<BusinessListItem | null>(null)
|
||||
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)
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createName, setCreateName] = useState('')
|
||||
const [createNameFa, setCreateNameFa] = useState('')
|
||||
@@ -471,6 +496,137 @@ export function BusinessesPage() {
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
async function openAiPrompts(b: BusinessListItem) {
|
||||
setPromptsBusiness(b)
|
||||
setPromptsOpen(true)
|
||||
setPromptsError('')
|
||||
setDomainPrompts([])
|
||||
setAllPromptTemplates([])
|
||||
setPromptEditorOpen(false)
|
||||
setEditingDomainPrompt(null)
|
||||
|
||||
if (!b.domainId) {
|
||||
setPromptsError('Add a website/domain for this business before assigning AI prompts.')
|
||||
return
|
||||
}
|
||||
|
||||
setPromptsLoading(true)
|
||||
try {
|
||||
const [allTemplates, assigned] = await Promise.all([
|
||||
listAiPromptTemplates(),
|
||||
listDomainAiPrompts(b.domainId),
|
||||
])
|
||||
setAllPromptTemplates(allTemplates.items)
|
||||
setDomainPrompts(assigned.items)
|
||||
} catch (err) {
|
||||
setPromptsError(
|
||||
err instanceof ApiError ? err.message : 'Unable to load AI prompts.',
|
||||
)
|
||||
} finally {
|
||||
setPromptsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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 (!promptsBusiness?.domainId) 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(
|
||||
promptsBusiness.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(promptsBusiness.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 (!promptsBusiness?.domainId) return
|
||||
setPromptBusyId(prompt.id)
|
||||
try {
|
||||
await deleteDomainAiPrompt(promptsBusiness.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 goToBusinessUsers(b: BusinessListItem, membership: 'staff' | 'all') {
|
||||
const params = new URLSearchParams({
|
||||
businessId: String(b.id),
|
||||
@@ -1460,6 +1616,7 @@ export function BusinessesPage() {
|
||||
<BusinessActionsOverflowMenu
|
||||
businessName={b.name}
|
||||
host={b.domain}
|
||||
onAiPrompts={() => void openAiPrompts(b)}
|
||||
onAllUsers={() => goToBusinessUsers(b, 'all')}
|
||||
onWipeData={() => openPurge(b)}
|
||||
onRemove={() => setRemoveTarget(b)}
|
||||
@@ -1498,6 +1655,187 @@ export function BusinessesPage() {
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
|
||||
<Modal
|
||||
open={promptsOpen}
|
||||
title={
|
||||
promptsBusiness
|
||||
? `AI prompts · ${promptsBusiness.name}`
|
||||
: 'AI prompts'
|
||||
}
|
||||
wide
|
||||
onClose={() => {
|
||||
if (promptEditorSubmitting) return
|
||||
setPromptsOpen(false)
|
||||
setPromptsBusiness(null)
|
||||
setPromptsError('')
|
||||
setPromptEditorOpen(false)
|
||||
}}
|
||||
>
|
||||
{promptsError ? (
|
||||
<p className={styles.alertError} role="alert">
|
||||
{promptsError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{promptsLoading ? (
|
||||
<p className={styles.subText}>Loading…</p>
|
||||
) : promptsBusiness?.domainId ? (
|
||||
<>
|
||||
<div className={styles.promptsListHeader}>
|
||||
<span className={styles.promptsListTitle}>
|
||||
{domainPrompts.length} prompt{domainPrompts.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.btn} ${styles.btnPrimary}`}
|
||||
onClick={openAddDomainPrompt}
|
||||
disabled={promptEditorSubmitting}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{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={() => setPromptsOpen(false)}
|
||||
disabled={promptEditorSubmitting}
|
||||
>
|
||||
Close
|
||||
</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-prompt-template">Template</label>
|
||||
<select
|
||||
id="domain-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-prompt-name">Name</label>
|
||||
<input
|
||||
id="domain-prompt-name"
|
||||
value={promptDraftName}
|
||||
onChange={(e) => setPromptDraftName(e.target.value)}
|
||||
placeholder="Prompt name"
|
||||
disabled={promptEditorSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="domain-prompt-body">Prompt</label>
|
||||
<textarea
|
||||
id="domain-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>
|
||||
|
||||
<Modal
|
||||
open={editOpen}
|
||||
title="Edit business"
|
||||
|
||||
Reference in New Issue
Block a user