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
@@ -16,6 +16,7 @@ import { InvoiceTemplateEditorPage } from './pages/InvoiceTemplateEditorPage'
|
||||
import { IssueInvoicePage } from './pages/IssueInvoicePage'
|
||||
import { PublicInvoicePage } from './pages/PublicInvoicePage'
|
||||
import { ProfilePage } from './pages/ProfilePage'
|
||||
import { TemplatesPage } from './pages/TemplatesPage'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
|
||||
function ProtectedPages() {
|
||||
@@ -59,6 +60,7 @@ function App() {
|
||||
/>
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="websites" element={<WebsitesPage />} />
|
||||
<Route path="templates" element={<TemplatesPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route
|
||||
path="settings/invoice-templates/new"
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Building2,
|
||||
Eraser,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Globe,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
@@ -22,6 +23,7 @@ interface BusinessActionsOverflowMenuProps {
|
||||
businessName: string
|
||||
/** Primary apex domain host when set — enables website / dashboard links. */
|
||||
host?: string | null
|
||||
onAiPrompts: () => void
|
||||
onAllUsers: () => void
|
||||
onWipeData: () => void
|
||||
onRemove: () => void
|
||||
@@ -46,6 +48,7 @@ function openHost(host: string) {
|
||||
export function BusinessActionsOverflowMenu({
|
||||
businessName,
|
||||
host,
|
||||
onAiPrompts,
|
||||
onAllUsers,
|
||||
onWipeData,
|
||||
onRemove,
|
||||
@@ -194,6 +197,18 @@ export function BusinessActionsOverflowMenu({
|
||||
</button>
|
||||
))}
|
||||
{linkItems.length > 0 && <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}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { Home, Building2, Users, Globe, Settings, HelpCircle, LogOut } from 'lucide-react'
|
||||
import { Home, Building2, Users, Globe, FileText, Settings, HelpCircle, LogOut } from 'lucide-react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import meshkeeLogo from '../assets/meshkee-logo.png'
|
||||
import styles from './Sidebar.module.css'
|
||||
@@ -9,6 +9,7 @@ const navItems = [
|
||||
{ icon: Building2, label: 'Businesses', to: '/businesses' },
|
||||
{ icon: Users, label: 'Users', to: '/users' },
|
||||
{ icon: Globe, label: 'Websites', to: '/websites' },
|
||||
{ icon: FileText, label: 'Templates', to: '/templates' },
|
||||
{ icon: Settings, label: 'Settings', to: '/settings' },
|
||||
]
|
||||
|
||||
|
||||
@@ -56,10 +56,12 @@
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: static;
|
||||
margin-top: 6px;
|
||||
position: fixed;
|
||||
z-index: 400;
|
||||
width: 300px;
|
||||
max-width: calc(100vw - 24px);
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { CalendarDays, ChevronLeft } from 'lucide-react'
|
||||
import styles from './StepDatePicker.module.css'
|
||||
|
||||
@@ -12,6 +13,8 @@ interface StepDatePickerProps {
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const PANEL_WIDTH = 300
|
||||
|
||||
const MONTHS = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
@@ -81,8 +84,12 @@ export function StepDatePicker({
|
||||
const autoId = useId()
|
||||
const fieldId = id ?? autoId
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState<Step>('year')
|
||||
const [panelPos, setPanelPos] = useState<{ top: number; left: number } | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
const today = useMemo(() => {
|
||||
const now = new Date()
|
||||
@@ -103,12 +110,41 @@ export function StepDatePicker({
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||
}, [today.year])
|
||||
|
||||
function updatePanelPosition() {
|
||||
const trigger = rootRef.current
|
||||
if (!trigger) return
|
||||
const rect = trigger.getBoundingClientRect()
|
||||
const width = Math.min(PANEL_WIDTH, window.innerWidth - 24)
|
||||
let left = rect.right - width
|
||||
if (left < 12) left = 12
|
||||
if (left + width > window.innerWidth - 12) {
|
||||
left = Math.max(12, window.innerWidth - 12 - width)
|
||||
}
|
||||
setPanelPos({ top: rect.bottom + 4, left })
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
setPanelPos(null)
|
||||
return
|
||||
}
|
||||
updatePanelPosition()
|
||||
const onReposition = () => updatePanelPosition()
|
||||
window.addEventListener('resize', onReposition)
|
||||
window.addEventListener('scroll', onReposition, true)
|
||||
return () => {
|
||||
window.removeEventListener('resize', onReposition)
|
||||
window.removeEventListener('scroll', onReposition, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
const target = event.target as Node
|
||||
if (rootRef.current?.contains(target)) return
|
||||
if (panelRef.current?.contains(target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false)
|
||||
@@ -155,6 +191,139 @@ export function StepDatePicker({
|
||||
? today.day
|
||||
: null
|
||||
|
||||
const panel =
|
||||
open && panelPos
|
||||
? createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={styles.panel}
|
||||
role="dialog"
|
||||
aria-label="Choose date"
|
||||
style={{ top: panelPos.top, left: panelPos.left }}
|
||||
>
|
||||
<div className={styles.panelHeader}>
|
||||
{step !== 'year' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backBtn}
|
||||
onClick={() => setStep(step === 'day' ? 'month' : 'year')}
|
||||
aria-label="Back"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<span className={styles.backSpacer} />
|
||||
)}
|
||||
<div className={styles.steps}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.stepChip} ${step === 'year' ? styles.stepActive : ''}`}
|
||||
onClick={() => setStep('year')}
|
||||
>
|
||||
{draftYear}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.stepChip} ${step === 'month' ? styles.stepActive : ''}`}
|
||||
onClick={() => setStep('month')}
|
||||
>
|
||||
{MONTHS[draftMonth - 1]}
|
||||
</button>
|
||||
<span
|
||||
className={`${styles.stepChip} ${step === 'day' ? styles.stepActive : ''} ${styles.stepStatic}`}
|
||||
>
|
||||
Day
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{step === 'year' ? (
|
||||
<div className={styles.yearList}>
|
||||
{years.map((year) => (
|
||||
<button
|
||||
key={year}
|
||||
type="button"
|
||||
className={`${styles.optionBtn} ${
|
||||
year === draftYear ? styles.optionSelected : ''
|
||||
} ${year === today.year ? styles.optionToday : ''}`}
|
||||
onClick={() => pickYear(year)}
|
||||
>
|
||||
{year}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'month' ? (
|
||||
<div className={styles.monthGrid}>
|
||||
{MONTHS.map((label, index) => {
|
||||
const month = index + 1
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className={`${styles.optionBtn} ${
|
||||
month === draftMonth ? styles.optionSelected : ''
|
||||
} ${
|
||||
draftYear === today.year && month === today.month
|
||||
? styles.optionToday
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => pickMonth(month)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'day' ? (
|
||||
<div className={styles.dayPanel}>
|
||||
<div className={styles.weekdayRow}>
|
||||
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d) => (
|
||||
<span key={d} className={styles.weekday}>
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.dayGrid}>
|
||||
{Array.from({ length: padStart }, (_, i) => (
|
||||
<span key={`pad-${i}`} className={styles.dayEmpty} />
|
||||
))}
|
||||
{Array.from({ length: dayCount }, (_, i) => {
|
||||
const day = i + 1
|
||||
const isSelected =
|
||||
selected?.year === draftYear &&
|
||||
selected?.month === draftMonth &&
|
||||
selected?.day === day
|
||||
const isToday =
|
||||
draftYear === today.year &&
|
||||
draftMonth === today.month &&
|
||||
day === today.day
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
className={`${styles.dayBtn} ${
|
||||
isSelected ? styles.daySelected : ''
|
||||
} ${isToday && !isSelected ? styles.dayToday : ''} ${
|
||||
highlightDay === day && !isSelected ? styles.dayPrefill : ''
|
||||
}`}
|
||||
onClick={() => pickDay(day)}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className={styles.root} ref={rootRef}>
|
||||
<button
|
||||
@@ -171,129 +340,7 @@ export function StepDatePicker({
|
||||
<span className={styles.triggerText}>{display || placeholder}</span>
|
||||
<CalendarDays size={16} className={styles.icon} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className={styles.panel} role="dialog" aria-label="Choose date">
|
||||
<div className={styles.panelHeader}>
|
||||
{step !== 'year' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backBtn}
|
||||
onClick={() => setStep(step === 'day' ? 'month' : 'year')}
|
||||
aria-label="Back"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<span className={styles.backSpacer} />
|
||||
)}
|
||||
<div className={styles.steps}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.stepChip} ${step === 'year' ? styles.stepActive : ''}`}
|
||||
onClick={() => setStep('year')}
|
||||
>
|
||||
{draftYear}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.stepChip} ${step === 'month' ? styles.stepActive : ''}`}
|
||||
onClick={() => setStep('month')}
|
||||
>
|
||||
{MONTHS[draftMonth - 1]}
|
||||
</button>
|
||||
<span
|
||||
className={`${styles.stepChip} ${step === 'day' ? styles.stepActive : ''} ${styles.stepStatic}`}
|
||||
>
|
||||
Day
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{step === 'year' ? (
|
||||
<div className={styles.yearList}>
|
||||
{years.map((year) => (
|
||||
<button
|
||||
key={year}
|
||||
type="button"
|
||||
className={`${styles.optionBtn} ${
|
||||
year === draftYear ? styles.optionSelected : ''
|
||||
} ${year === today.year ? styles.optionToday : ''}`}
|
||||
onClick={() => pickYear(year)}
|
||||
>
|
||||
{year}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'month' ? (
|
||||
<div className={styles.monthGrid}>
|
||||
{MONTHS.map((label, index) => {
|
||||
const month = index + 1
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className={`${styles.optionBtn} ${
|
||||
month === draftMonth ? styles.optionSelected : ''
|
||||
} ${
|
||||
draftYear === today.year && month === today.month
|
||||
? styles.optionToday
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => pickMonth(month)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'day' ? (
|
||||
<div className={styles.dayPanel}>
|
||||
<div className={styles.weekdayRow}>
|
||||
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d) => (
|
||||
<span key={d} className={styles.weekday}>
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.dayGrid}>
|
||||
{Array.from({ length: padStart }, (_, i) => (
|
||||
<span key={`pad-${i}`} className={styles.dayEmpty} />
|
||||
))}
|
||||
{Array.from({ length: dayCount }, (_, i) => {
|
||||
const day = i + 1
|
||||
const isSelected =
|
||||
selected?.year === draftYear &&
|
||||
selected?.month === draftMonth &&
|
||||
selected?.day === day
|
||||
const isToday =
|
||||
draftYear === today.year &&
|
||||
draftMonth === today.month &&
|
||||
day === today.day
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
className={`${styles.dayBtn} ${
|
||||
isSelected ? styles.daySelected : ''
|
||||
} ${isToday && !isSelected ? styles.dayToday : ''} ${
|
||||
highlightDay === day && !isSelected ? styles.dayPrefill : ''
|
||||
}`}
|
||||
onClick={() => pickDay(day)}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{panel}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -608,6 +608,129 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.promptsListHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.promptsListTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.promptsRowActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.promptsTextarea {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
font-size: var(--field-font-size);
|
||||
font-family: var(--font-ui);
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.promptsTextarea:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.promptsEmpty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 28px 16px;
|
||||
border: 1px dashed rgba(148, 163, 184, 0.45);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.promptsEmpty svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.promptsEmpty p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.promptsList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.promptsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.promptsRowText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.promptsRowName {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.promptsRowPreview {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.promptsAddRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.promptsLinkRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.promptsLinkRow input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.domainCell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CalendarDays, Building2, Users, Globe, Settings } from 'lucide-react'
|
||||
import { CalendarDays, Building2, Users, Globe, FileText, Settings } from 'lucide-react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { SectionCard } from '../components/SectionCard'
|
||||
import styles from '../components/PageContent.module.css'
|
||||
@@ -25,6 +25,13 @@ const sections = [
|
||||
linkText: 'View websites',
|
||||
href: '/websites',
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: 'Templates',
|
||||
description: 'AI prompt templates per website with shareable public links.',
|
||||
linkText: 'View templates',
|
||||
href: '/templates',
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
title: 'Settings',
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
.section {
|
||||
padding: 16px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--blur-glass));
|
||||
-webkit-backdrop-filter: blur(var(--blur-glass));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.filterField {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: block;
|
||||
max-width: 520px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.alertError {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.nameCell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nameCell svg {
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hostCell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.businessMeta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.linkCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.publicLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.publicLink span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.publicLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.promptArea {
|
||||
width: 100%;
|
||||
min-height: 220px;
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
font-size: var(--field-font-size);
|
||||
font-family: var(--font-ui);
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.promptArea:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.helperText {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.helperText a {
|
||||
color: var(--primary);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FileText, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { EmptyTableRow } from '../components/EmptyState'
|
||||
import { Modal } from '../components/Modal'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
createAiPromptTemplate,
|
||||
deleteAiPromptTemplate,
|
||||
listAiPromptTemplates,
|
||||
updateAiPromptTemplate,
|
||||
} from '../services/aiPromptService'
|
||||
import type { AiPromptTemplate } from '../types/aiPrompt'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import tableStyles from './BusinessesPage.module.css'
|
||||
import styles from './TemplatesPage.module.css'
|
||||
|
||||
export function TemplatesPage() {
|
||||
const { showToast } = useToast()
|
||||
const [items, setItems] = useState<AiPromptTemplate[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<AiPromptTemplate | null>(null)
|
||||
const [draftName, setDraftName] = useState('')
|
||||
const [draftBody, setDraftBody] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
const [removeTarget, setRemoveTarget] = useState<AiPromptTemplate | null>(null)
|
||||
|
||||
async function reload(signal?: AbortSignal) {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const templates = await listAiPromptTemplates(signal)
|
||||
setItems(templates.items)
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load templates.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void reload(controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null)
|
||||
setDraftName('')
|
||||
setDraftBody('')
|
||||
setFormError('')
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
function openEdit(template: AiPromptTemplate) {
|
||||
setEditing(template)
|
||||
setDraftName(template.name)
|
||||
setDraftBody(template.body)
|
||||
setFormError('')
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const name = draftName.trim()
|
||||
const body = draftBody.trim()
|
||||
if (!name) {
|
||||
setFormError('Name is required.')
|
||||
return
|
||||
}
|
||||
if (!body) {
|
||||
setFormError('Prompt text is required.')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
setFormError('')
|
||||
try {
|
||||
if (editing) {
|
||||
await updateAiPromptTemplate(editing.id, { name, body })
|
||||
showToast('Template updated.', 'success')
|
||||
} else {
|
||||
await createAiPromptTemplate({ name, body })
|
||||
showToast('Template created.', 'success')
|
||||
}
|
||||
setEditorOpen(false)
|
||||
await reload()
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : 'Unable to save template.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
if (!removeTarget) return
|
||||
try {
|
||||
await deleteAiPromptTemplate(removeTarget.id)
|
||||
showToast('Template removed.', 'success')
|
||||
setRemoveTarget(null)
|
||||
await reload()
|
||||
} catch (err) {
|
||||
showToast(err instanceof ApiError ? err.message : 'Unable to remove template.', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>Templates</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
General AI prompt templates. Assign them to websites from the Websites list.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
|
||||
onClick={openCreate}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? <p className={styles.alertError}>{error}</p> : null}
|
||||
|
||||
<section className={styles.section}>
|
||||
<div className={tableStyles.tableWrap}>
|
||||
<div className={tableStyles.tableHeader}>
|
||||
<div className={tableStyles.tableHeaderTitle}>AI prompts</div>
|
||||
<div className={tableStyles.meta}>
|
||||
{loading
|
||||
? 'Loading…'
|
||||
: `${items.length} template${items.length === 1 ? '' : 's'}`}
|
||||
</div>
|
||||
</div>
|
||||
<table className={tableStyles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={tableStyles.th}>Name</th>
|
||||
<th className={tableStyles.th}>Preview</th>
|
||||
<th className={tableStyles.th} style={{ width: 120 }}>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && items.length === 0 ? (
|
||||
<EmptyTableRow colSpan={3} message="Loading templates…" />
|
||||
) : null}
|
||||
{!loading && items.length === 0 ? (
|
||||
<EmptyTableRow colSpan={3} message="No AI prompt templates yet." />
|
||||
) : null}
|
||||
{items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className={tableStyles.td}>
|
||||
<div className={styles.nameCell}>
|
||||
<FileText size={15} aria-hidden="true" />
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className={tableStyles.td}>
|
||||
<span className={styles.preview}>{item.body}</span>
|
||||
</td>
|
||||
<td className={tableStyles.td}>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.controlBtn}
|
||||
onClick={() => openEdit(item)}
|
||||
aria-label="Edit template"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.controlBtn}
|
||||
onClick={() => setRemoveTarget(item)}
|
||||
aria-label="Delete template"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Modal
|
||||
open={editorOpen}
|
||||
title={editing ? 'Edit template' : 'Add template'}
|
||||
wide
|
||||
onClose={() => {
|
||||
if (submitting) return
|
||||
setEditorOpen(false)
|
||||
}}
|
||||
>
|
||||
{formError ? (
|
||||
<p className={styles.alertError} role="alert">
|
||||
{formError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className={tableStyles.field}>
|
||||
<label htmlFor="template-name">Name</label>
|
||||
<input
|
||||
id="template-name"
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
placeholder="Storefront AI brief"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
<div className={tableStyles.field}>
|
||||
<label htmlFor="template-body">Prompt</label>
|
||||
<textarea
|
||||
id="template-body"
|
||||
className={styles.promptArea}
|
||||
value={draftBody}
|
||||
onChange={(e) => setDraftBody(e.target.value)}
|
||||
placeholder="Paste the AI prompt here…"
|
||||
disabled={submitting}
|
||||
rows={14}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ height: 12 }} />
|
||||
<div className={tableStyles.actionsRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
||||
onClick={() => setEditorOpen(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={submitting}
|
||||
>
|
||||
{editing ? 'Save' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={removeTarget !== null}
|
||||
title="Remove template?"
|
||||
message={
|
||||
removeTarget
|
||||
? `Delete “${removeTarget.name}”? Websites using it will lose this prompt.`
|
||||
: ''
|
||||
}
|
||||
onCancel={() => setRemoveTarget(null)}
|
||||
onConfirm={() => void handleRemove()}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { apiRequest } from '../lib/api'
|
||||
import type {
|
||||
AiPromptTemplate,
|
||||
AiPromptTemplatesListResponse,
|
||||
CreateAiPromptTemplatePayload,
|
||||
CreateDomainAiPromptPayload,
|
||||
DomainAiPrompt,
|
||||
DomainAiPromptsListResponse,
|
||||
UpdateAiPromptTemplatePayload,
|
||||
UpdateDomainAiPromptPayload,
|
||||
} from '../types/aiPrompt'
|
||||
|
||||
export async function listAiPromptTemplates(signal?: AbortSignal) {
|
||||
return apiRequest<AiPromptTemplatesListResponse>('/ai-prompt-templates', {
|
||||
auth: true,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createAiPromptTemplate(payload: CreateAiPromptTemplatePayload) {
|
||||
return apiRequest<AiPromptTemplate>('/ai-prompt-templates', {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateAiPromptTemplate(
|
||||
templateId: string,
|
||||
payload: UpdateAiPromptTemplatePayload,
|
||||
) {
|
||||
return apiRequest<AiPromptTemplate>(`/ai-prompt-templates/${templateId}`, {
|
||||
method: 'PATCH',
|
||||
auth: true,
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteAiPromptTemplate(templateId: string) {
|
||||
return apiRequest<{ ok: boolean }>(`/ai-prompt-templates/${templateId}`, {
|
||||
method: 'DELETE',
|
||||
auth: true,
|
||||
})
|
||||
}
|
||||
|
||||
export async function listDomainAiPrompts(
|
||||
domainId: string | number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return apiRequest<DomainAiPromptsListResponse>(
|
||||
`/domains/${domainId}/ai-prompts`,
|
||||
{ auth: true, signal },
|
||||
)
|
||||
}
|
||||
|
||||
export async function createDomainAiPrompt(
|
||||
domainId: string | number,
|
||||
payload: CreateDomainAiPromptPayload,
|
||||
) {
|
||||
return apiRequest<DomainAiPrompt>(`/domains/${domainId}/ai-prompts`, {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateDomainAiPrompt(
|
||||
domainId: string | number,
|
||||
promptId: string,
|
||||
payload: UpdateDomainAiPromptPayload,
|
||||
) {
|
||||
return apiRequest<DomainAiPrompt>(
|
||||
`/domains/${domainId}/ai-prompts/${promptId}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
auth: true,
|
||||
body: payload,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function deleteDomainAiPrompt(
|
||||
domainId: string | number,
|
||||
promptId: string,
|
||||
) {
|
||||
return apiRequest<{ ok: boolean }>(
|
||||
`/domains/${domainId}/ai-prompts/${promptId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
auth: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export interface AiPromptTemplate {
|
||||
id: string
|
||||
name: string
|
||||
body: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AiPromptTemplatesListResponse {
|
||||
items: AiPromptTemplate[]
|
||||
}
|
||||
|
||||
export interface CreateAiPromptTemplatePayload {
|
||||
name: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export interface UpdateAiPromptTemplatePayload {
|
||||
name?: string
|
||||
body?: string
|
||||
}
|
||||
|
||||
export interface DomainAiPrompt {
|
||||
id: string
|
||||
domainId: string
|
||||
sourceTemplateId: string | null
|
||||
name: string
|
||||
body: string
|
||||
publicId: string
|
||||
publicUrl: string
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface DomainAiPromptsListResponse {
|
||||
domainId: string
|
||||
host: string
|
||||
businessName: string
|
||||
items: DomainAiPrompt[]
|
||||
}
|
||||
|
||||
export interface CreateDomainAiPromptPayload {
|
||||
name: string
|
||||
body: string
|
||||
sourceTemplateId?: number
|
||||
}
|
||||
|
||||
export interface UpdateDomainAiPromptPayload {
|
||||
name?: string
|
||||
body?: string
|
||||
}
|
||||
@@ -143,6 +143,7 @@ Add to `/etc/hosts` (one line per tenant):
|
||||
| `/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) |
|
||||
| `/websites` | Websites / domains (Deploy when `deploy_slug` set). Edit domain can optionally update Arvan DNS. |
|
||||
| `/templates` | Global AI prompt templates. Assign from Businesses ⋯ → **AI prompts** (add from library; public pack link). |
|
||||
| `/settings` | Platform settings (invoice templates + item templates) |
|
||||
| `/settings/invoice-templates/new` | Create invoice template |
|
||||
| `/settings/invoice-templates/:templateId` | Edit invoice template |
|
||||
|
||||
Reference in New Issue
Block a user