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:
Alireza Hassani
2026-08-26 15:28:41 +03:30
co-authored by Cursor
parent b50e4fd812
commit 0aab7b5529
13 changed files with 1209 additions and 132 deletions
+2
View File
@@ -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}
+2 -1
View File
@@ -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);
+174 -127
View File
@@ -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"
+8 -1
View File
@@ -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,
},
)
}
+52
View File
@@ -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
}
+1
View File
@@ -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 |