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 { IssueInvoicePage } from './pages/IssueInvoicePage'
|
||||||
import { PublicInvoicePage } from './pages/PublicInvoicePage'
|
import { PublicInvoicePage } from './pages/PublicInvoicePage'
|
||||||
import { ProfilePage } from './pages/ProfilePage'
|
import { ProfilePage } from './pages/ProfilePage'
|
||||||
|
import { TemplatesPage } from './pages/TemplatesPage'
|
||||||
import { LoginPage } from './pages/LoginPage'
|
import { LoginPage } from './pages/LoginPage'
|
||||||
|
|
||||||
function ProtectedPages() {
|
function ProtectedPages() {
|
||||||
@@ -59,6 +60,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
<Route path="users" element={<UsersPage />} />
|
<Route path="users" element={<UsersPage />} />
|
||||||
<Route path="websites" element={<WebsitesPage />} />
|
<Route path="websites" element={<WebsitesPage />} />
|
||||||
|
<Route path="templates" element={<TemplatesPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="settings/invoice-templates/new"
|
path="settings/invoice-templates/new"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
Eraser,
|
Eraser,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
FileText,
|
||||||
Globe,
|
Globe,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -22,6 +23,7 @@ interface BusinessActionsOverflowMenuProps {
|
|||||||
businessName: string
|
businessName: string
|
||||||
/** Primary apex domain host when set — enables website / dashboard links. */
|
/** Primary apex domain host when set — enables website / dashboard links. */
|
||||||
host?: string | null
|
host?: string | null
|
||||||
|
onAiPrompts: () => void
|
||||||
onAllUsers: () => void
|
onAllUsers: () => void
|
||||||
onWipeData: () => void
|
onWipeData: () => void
|
||||||
onRemove: () => void
|
onRemove: () => void
|
||||||
@@ -46,6 +48,7 @@ function openHost(host: string) {
|
|||||||
export function BusinessActionsOverflowMenu({
|
export function BusinessActionsOverflowMenu({
|
||||||
businessName,
|
businessName,
|
||||||
host,
|
host,
|
||||||
|
onAiPrompts,
|
||||||
onAllUsers,
|
onAllUsers,
|
||||||
onWipeData,
|
onWipeData,
|
||||||
onRemove,
|
onRemove,
|
||||||
@@ -194,6 +197,18 @@ export function BusinessActionsOverflowMenu({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{linkItems.length > 0 && <div className={styles.menuDivider} role="separator" />}
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.item}
|
className={styles.item}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NavLink, useNavigate } from 'react-router-dom'
|
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 { useAuth } from '../context/AuthContext'
|
||||||
import meshkeeLogo from '../assets/meshkee-logo.png'
|
import meshkeeLogo from '../assets/meshkee-logo.png'
|
||||||
import styles from './Sidebar.module.css'
|
import styles from './Sidebar.module.css'
|
||||||
@@ -9,6 +9,7 @@ const navItems = [
|
|||||||
{ icon: Building2, label: 'Businesses', to: '/businesses' },
|
{ icon: Building2, label: 'Businesses', to: '/businesses' },
|
||||||
{ icon: Users, label: 'Users', to: '/users' },
|
{ icon: Users, label: 'Users', to: '/users' },
|
||||||
{ icon: Globe, label: 'Websites', to: '/websites' },
|
{ icon: Globe, label: 'Websites', to: '/websites' },
|
||||||
|
{ icon: FileText, label: 'Templates', to: '/templates' },
|
||||||
{ icon: Settings, label: 'Settings', to: '/settings' },
|
{ icon: Settings, label: 'Settings', to: '/settings' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -56,10 +56,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
position: static;
|
position: fixed;
|
||||||
margin-top: 6px;
|
z-index: 400;
|
||||||
|
width: 300px;
|
||||||
|
max-width: calc(100vw - 24px);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: rgba(255, 255, 255, 0.97);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
-webkit-backdrop-filter: blur(16px);
|
-webkit-backdrop-filter: blur(16px);
|
||||||
border: 1px solid var(--glass-border);
|
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 { CalendarDays, ChevronLeft } from 'lucide-react'
|
||||||
import styles from './StepDatePicker.module.css'
|
import styles from './StepDatePicker.module.css'
|
||||||
|
|
||||||
@@ -12,6 +13,8 @@ interface StepDatePickerProps {
|
|||||||
placeholder?: string
|
placeholder?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PANEL_WIDTH = 300
|
||||||
|
|
||||||
const MONTHS = [
|
const MONTHS = [
|
||||||
'Jan',
|
'Jan',
|
||||||
'Feb',
|
'Feb',
|
||||||
@@ -81,8 +84,12 @@ export function StepDatePicker({
|
|||||||
const autoId = useId()
|
const autoId = useId()
|
||||||
const fieldId = id ?? autoId
|
const fieldId = id ?? autoId
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [step, setStep] = useState<Step>('year')
|
const [step, setStep] = useState<Step>('year')
|
||||||
|
const [panelPos, setPanelPos] = useState<{ top: number; left: number } | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
const today = useMemo(() => {
|
const today = useMemo(() => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@@ -103,12 +110,41 @@ export function StepDatePicker({
|
|||||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||||
}, [today.year])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const onPointerDown = (event: MouseEvent) => {
|
const onPointerDown = (event: MouseEvent) => {
|
||||||
if (!rootRef.current?.contains(event.target as Node)) {
|
const target = event.target as Node
|
||||||
setOpen(false)
|
if (rootRef.current?.contains(target)) return
|
||||||
}
|
if (panelRef.current?.contains(target)) return
|
||||||
|
setOpen(false)
|
||||||
}
|
}
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') setOpen(false)
|
if (event.key === 'Escape') setOpen(false)
|
||||||
@@ -155,6 +191,139 @@ export function StepDatePicker({
|
|||||||
? today.day
|
? today.day
|
||||||
: null
|
: 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 (
|
return (
|
||||||
<div className={styles.root} ref={rootRef}>
|
<div className={styles.root} ref={rootRef}>
|
||||||
<button
|
<button
|
||||||
@@ -171,129 +340,7 @@ export function StepDatePicker({
|
|||||||
<span className={styles.triggerText}>{display || placeholder}</span>
|
<span className={styles.triggerText}>{display || placeholder}</span>
|
||||||
<CalendarDays size={16} className={styles.icon} aria-hidden="true" />
|
<CalendarDays size={16} className={styles.icon} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
{panel}
|
||||||
{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}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -608,6 +608,129 @@
|
|||||||
font-size: 12px;
|
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 {
|
.domainCell {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Copy,
|
||||||
Database,
|
Database,
|
||||||
FileText,
|
FileText,
|
||||||
Globe,
|
Globe,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Search,
|
Search,
|
||||||
|
Trash2,
|
||||||
UserCog,
|
UserCog,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||||
@@ -46,6 +48,14 @@ import {
|
|||||||
} from '../services/businessService'
|
} from '../services/businessService'
|
||||||
import { listBusinessCategories } from '../services/categoryService'
|
import { listBusinessCategories } from '../services/categoryService'
|
||||||
import { listUserTitles, type UserTitleOption } from '../services/userTitleService'
|
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 { Modal } from '../components/Modal'
|
||||||
import { PrimaryColorSwatchControl } from '../components/PrimaryColorSwatchControl'
|
import { PrimaryColorSwatchControl } from '../components/PrimaryColorSwatchControl'
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
@@ -315,6 +325,21 @@ export function BusinessesPage() {
|
|||||||
const [domainSubmitting, setDomainSubmitting] = useState(false)
|
const [domainSubmitting, setDomainSubmitting] = useState(false)
|
||||||
const [domainError, setDomainError] = useState('')
|
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 [createOpen, setCreateOpen] = useState(false)
|
||||||
const [createName, setCreateName] = useState('')
|
const [createName, setCreateName] = useState('')
|
||||||
const [createNameFa, setCreateNameFa] = useState('')
|
const [createNameFa, setCreateNameFa] = useState('')
|
||||||
@@ -471,6 +496,137 @@ export function BusinessesPage() {
|
|||||||
setEditOpen(true)
|
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') {
|
function goToBusinessUsers(b: BusinessListItem, membership: 'staff' | 'all') {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
businessId: String(b.id),
|
businessId: String(b.id),
|
||||||
@@ -1460,6 +1616,7 @@ export function BusinessesPage() {
|
|||||||
<BusinessActionsOverflowMenu
|
<BusinessActionsOverflowMenu
|
||||||
businessName={b.name}
|
businessName={b.name}
|
||||||
host={b.domain}
|
host={b.domain}
|
||||||
|
onAiPrompts={() => void openAiPrompts(b)}
|
||||||
onAllUsers={() => goToBusinessUsers(b, 'all')}
|
onAllUsers={() => goToBusinessUsers(b, 'all')}
|
||||||
onWipeData={() => openPurge(b)}
|
onWipeData={() => openPurge(b)}
|
||||||
onRemove={() => setRemoveTarget(b)}
|
onRemove={() => setRemoveTarget(b)}
|
||||||
@@ -1498,6 +1655,187 @@ export function BusinessesPage() {
|
|||||||
<Plus size={24} />
|
<Plus size={24} />
|
||||||
</button>
|
</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
|
<Modal
|
||||||
open={editOpen}
|
open={editOpen}
|
||||||
title="Edit business"
|
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 { useAuth } from '../context/AuthContext'
|
||||||
import { SectionCard } from '../components/SectionCard'
|
import { SectionCard } from '../components/SectionCard'
|
||||||
import styles from '../components/PageContent.module.css'
|
import styles from '../components/PageContent.module.css'
|
||||||
@@ -25,6 +25,13 @@ const sections = [
|
|||||||
linkText: 'View websites',
|
linkText: 'View websites',
|
||||||
href: '/websites',
|
href: '/websites',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: FileText,
|
||||||
|
title: 'Templates',
|
||||||
|
description: 'AI prompt templates per website with shareable public links.',
|
||||||
|
linkText: 'View templates',
|
||||||
|
href: '/templates',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
title: '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) |
|
| `/businesses/:businessId/invoices/new` | Issue invoice (full page) |
|
||||||
| `/users` | Users (business filter: Customer vs Manager → Admin/Editor/Viewer; Admin assignable by super-admin only; owners locked) |
|
| `/users` | Users (business filter: Customer vs Manager → Admin/Editor/Viewer; Admin assignable by super-admin only; owners locked) |
|
||||||
| `/websites` | Websites / domains (Deploy when `deploy_slug` set). Edit domain can optionally update Arvan DNS. |
|
| `/websites` | Websites / domains (Deploy when `deploy_slug` set). Edit domain can optionally update Arvan DNS. |
|
||||||
|
| `/templates` | Global AI prompt templates. Assign from Businesses ⋯ → **AI prompts** (add from library; public pack link). |
|
||||||
| `/settings` | Platform settings (invoice templates + item templates) |
|
| `/settings` | Platform settings (invoice templates + item templates) |
|
||||||
| `/settings/invoice-templates/new` | Create invoice template |
|
| `/settings/invoice-templates/new` | Create invoice template |
|
||||||
| `/settings/invoice-templates/:templateId` | Edit invoice template |
|
| `/settings/invoice-templates/:templateId` | Edit invoice template |
|
||||||
|
|||||||
Reference in New Issue
Block a user