Add SMS templates UI, prepaid balance settings, and list filter layout.
Business settings manage templates and balance; Super Admin verifies templates and tops up via SMS settings; filters use a separate panel with even field gaps. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
8f0b4cef85
commit
0eb19bbebd
@@ -17,6 +17,7 @@ import { IssueInvoicePage } from './pages/IssueInvoicePage'
|
||||
import { PublicInvoicePage } from './pages/PublicInvoicePage'
|
||||
import { ProfilePage } from './pages/ProfilePage'
|
||||
import { TemplatesPage } from './pages/TemplatesPage'
|
||||
import { SmsTemplatesPage } from './pages/SmsTemplatesPage'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
|
||||
function ProtectedPages() {
|
||||
@@ -61,6 +62,7 @@ function App() {
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="websites" element={<WebsitesPage />} />
|
||||
<Route path="templates" element={<TemplatesPage />} />
|
||||
<Route path="sms-templates" element={<SmsTemplatesPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route
|
||||
path="settings/invoice-templates/new"
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Globe,
|
||||
MessageSquareText,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
UserRound,
|
||||
@@ -24,6 +25,7 @@ interface BusinessActionsOverflowMenuProps {
|
||||
/** Primary apex domain host when set — enables website / dashboard links. */
|
||||
host?: string | null
|
||||
onAiPrompts: () => void
|
||||
onSmsSettings: () => void
|
||||
onAllUsers: () => void
|
||||
onWipeData: () => void
|
||||
onRemove: () => void
|
||||
@@ -49,6 +51,7 @@ export function BusinessActionsOverflowMenu({
|
||||
businessName,
|
||||
host,
|
||||
onAiPrompts,
|
||||
onSmsSettings,
|
||||
onAllUsers,
|
||||
onWipeData,
|
||||
onRemove,
|
||||
@@ -92,7 +95,7 @@ export function BusinessActionsOverflowMenu({
|
||||
|
||||
const size = popoverRef.current
|
||||
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
|
||||
: { width: 220, height: linkItems.length ? 280 : 160 }
|
||||
: { width: 220, height: linkItems.length ? 320 : 200 }
|
||||
|
||||
setPopoverPos(fitFixedPopover(rect, size, 'end'))
|
||||
}
|
||||
@@ -209,6 +212,18 @@ export function BusinessActionsOverflowMenu({
|
||||
<FileText size={16} />
|
||||
<span>AI prompts</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.item}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onSmsSettings()
|
||||
}}
|
||||
>
|
||||
<MessageSquareText size={16} />
|
||||
<span>SMS settings</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.item}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { Home, Building2, Users, Globe, FileText, Settings, HelpCircle, LogOut } from 'lucide-react'
|
||||
import { Home, Building2, Users, Globe, FileText, MessageSquareText, Settings, HelpCircle, LogOut } from 'lucide-react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import meshkeeLogo from '../assets/meshkee-logo.png'
|
||||
import styles from './Sidebar.module.css'
|
||||
@@ -10,6 +10,7 @@ const navItems = [
|
||||
{ icon: Users, label: 'Users', to: '/users' },
|
||||
{ icon: Globe, label: 'Websites', to: '/websites' },
|
||||
{ icon: FileText, label: 'Templates', to: '/templates' },
|
||||
{ icon: MessageSquareText, label: 'SMS Templates', to: '/sms-templates' },
|
||||
{ icon: Settings, label: 'Settings', to: '/settings' },
|
||||
]
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
}
|
||||
|
||||
.filtersGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 12px;
|
||||
align-items: end;
|
||||
margin: 0;
|
||||
@@ -23,19 +23,26 @@
|
||||
}
|
||||
|
||||
.filtersInputs {
|
||||
grid-column: span 10;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Equal width fields; same 12px gap as field→action buttons */
|
||||
.filtersInputs > .field {
|
||||
flex: 1 1 160px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filterActions {
|
||||
grid-column: span 2;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
@@ -1028,13 +1035,8 @@
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.filtersInputs {
|
||||
grid-column: span 12;
|
||||
}
|
||||
|
||||
.filterActions {
|
||||
grid-column: span 12;
|
||||
justify-content: flex-end;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.fieldCol3 {
|
||||
@@ -1044,7 +1046,16 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filtersInputs {
|
||||
grid-template-columns: 1fr;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.filtersInputs > .field {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.filterActions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.fieldCol3 {
|
||||
|
||||
@@ -339,10 +339,16 @@ export function BusinessesPage() {
|
||||
const [editNameFa, setEditNameFa] = useState('')
|
||||
const [editAbout, setEditAbout] = useState('')
|
||||
const [editAnnualRenewalAt, setEditAnnualRenewalAt] = useState('')
|
||||
const [editSmsSenderNumber, setEditSmsSenderNumber] = useState('')
|
||||
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||
const [editError, setEditError] = useState('')
|
||||
|
||||
const [smsSettingsOpen, setSmsSettingsOpen] = useState(false)
|
||||
const [smsSettingsBusiness, setSmsSettingsBusiness] = useState<BusinessListItem | null>(null)
|
||||
const [smsSenderNumber, setSmsSenderNumber] = useState('')
|
||||
const [smsBalance, setSmsBalance] = useState('0')
|
||||
const [smsSettingsSubmitting, setSmsSettingsSubmitting] = useState(false)
|
||||
const [smsSettingsError, setSmsSettingsError] = useState('')
|
||||
|
||||
const [domainOpen, setDomainOpen] = useState(false)
|
||||
const [domainBusiness, setDomainBusiness] = useState<BusinessListItem | null>(null)
|
||||
const [domainId, setDomainId] = useState<number | null>(null)
|
||||
@@ -521,11 +527,18 @@ export function BusinessesPage() {
|
||||
setEditAnnualRenewalAt(
|
||||
toDateInputValue(b.annualRenewalAt || b.createdAt),
|
||||
)
|
||||
setEditSmsSenderNumber(b.smsSenderNumber ?? '')
|
||||
setEditError('')
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
function openSmsSettings(b: BusinessListItem) {
|
||||
setSmsSettingsBusiness(b)
|
||||
setSmsSenderNumber(b.smsSenderNumber ?? '')
|
||||
setSmsBalance(String(b.smsBalance ?? 0))
|
||||
setSmsSettingsError('')
|
||||
setSmsSettingsOpen(true)
|
||||
}
|
||||
|
||||
async function openAiPrompts(b: BusinessListItem) {
|
||||
setPromptsBusiness(b)
|
||||
setPromptsOpen(true)
|
||||
@@ -864,14 +877,12 @@ export function BusinessesPage() {
|
||||
: editBusiness.createdAt
|
||||
? new Date(editBusiness.createdAt).toISOString()
|
||||
: null
|
||||
const smsSenderNumber = editSmsSenderNumber.replace(/\D/g, '')
|
||||
try {
|
||||
await updateBusiness(editBusiness.id, {
|
||||
name,
|
||||
nameFa,
|
||||
about: editAbout.trim(),
|
||||
annualRenewalAt,
|
||||
smsSenderNumber: smsSenderNumber || null,
|
||||
})
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
@@ -885,7 +896,6 @@ export function BusinessesPage() {
|
||||
nameFa,
|
||||
about: editAbout.trim() || null,
|
||||
annualRenewalAt,
|
||||
smsSenderNumber: smsSenderNumber || null,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
@@ -903,6 +913,50 @@ export function BusinessesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSmsSettings() {
|
||||
if (!smsSettingsBusiness) return
|
||||
|
||||
const sender = smsSenderNumber.replace(/\D/g, '')
|
||||
const balanceParsed = Number.parseInt(smsBalance.replace(/\D/g, '') || '0', 10)
|
||||
if (!Number.isFinite(balanceParsed) || balanceParsed < 0) {
|
||||
setSmsSettingsError('SMS balance must be a non-negative integer.')
|
||||
return
|
||||
}
|
||||
|
||||
setSmsSettingsSubmitting(true)
|
||||
setSmsSettingsError('')
|
||||
try {
|
||||
await updateBusiness(smsSettingsBusiness.id, {
|
||||
smsSenderNumber: sender || null,
|
||||
smsBalance: balanceParsed,
|
||||
})
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === smsSettingsBusiness.id
|
||||
? {
|
||||
...item,
|
||||
smsSenderNumber: sender || null,
|
||||
smsBalance: balanceParsed,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
}
|
||||
})
|
||||
setSmsSettingsOpen(false)
|
||||
setSmsSettingsBusiness(null)
|
||||
showToast('SMS settings saved.', 'success')
|
||||
} catch (err) {
|
||||
setSmsSettingsError(
|
||||
err instanceof ApiError ? err.message : 'Unable to save SMS settings.',
|
||||
)
|
||||
} finally {
|
||||
setSmsSettingsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function restartRenewalCountdown(b: BusinessListItem) {
|
||||
const contractAt = b.annualRenewalAt || b.createdAt
|
||||
const annualRenewalAt = addOneYearToDueIso(contractAt)
|
||||
@@ -1406,7 +1460,7 @@ export function BusinessesPage() {
|
||||
|
||||
<form className={styles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
|
||||
<div className={styles.filtersInputs}>
|
||||
<div className={`${styles.field} ${styles.fieldCol3}`}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="filter-name">Name</label>
|
||||
<input
|
||||
id="filter-name"
|
||||
@@ -1416,7 +1470,7 @@ export function BusinessesPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.fieldCol3}`}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="filter-domain">Domain</label>
|
||||
<input
|
||||
id="filter-domain"
|
||||
@@ -1426,7 +1480,7 @@ export function BusinessesPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.fieldCol3}`}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="filter-category">Category</label>
|
||||
<input
|
||||
id="filter-category"
|
||||
@@ -1708,6 +1762,7 @@ export function BusinessesPage() {
|
||||
businessName={b.name}
|
||||
host={b.domain}
|
||||
onAiPrompts={() => void openAiPrompts(b)}
|
||||
onSmsSettings={() => openSmsSettings(b)}
|
||||
onAllUsers={() => goToBusinessUsers(b, 'all')}
|
||||
onWipeData={() => openPurge(b)}
|
||||
onRemove={() => setRemoveTarget(b)}
|
||||
@@ -1966,22 +2021,6 @@ export function BusinessesPage() {
|
||||
placeholder="Select renewal date"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="edit-sms-sender">SMS sender number</label>
|
||||
<input
|
||||
id="edit-sms-sender"
|
||||
inputMode="numeric"
|
||||
value={editSmsSenderNumber}
|
||||
onChange={(e) => setEditSmsSenderNumber(e.target.value.replace(/\D/g, '').slice(0, 16))}
|
||||
placeholder="e.g. 5000123456 (empty = 5000990009)"
|
||||
disabled={editSubmitting}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className={styles.helperText}>
|
||||
Used for customer SMS from this business. Leave empty to use Meshkee advertising line
|
||||
5000990009.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="edit-about">About business</label>
|
||||
<textarea
|
||||
@@ -2013,6 +2052,82 @@ export function BusinessesPage() {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={smsSettingsOpen}
|
||||
title={
|
||||
smsSettingsBusiness
|
||||
? `SMS settings · ${smsSettingsBusiness.name}`
|
||||
: 'SMS settings'
|
||||
}
|
||||
onClose={() => {
|
||||
if (smsSettingsSubmitting) return
|
||||
setSmsSettingsOpen(false)
|
||||
setSmsSettingsBusiness(null)
|
||||
setSmsSettingsError('')
|
||||
}}
|
||||
>
|
||||
{smsSettingsError ? (
|
||||
<p className={styles.alertError} role="alert">
|
||||
{smsSettingsError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="sms-settings-sender">SMS sender number</label>
|
||||
<input
|
||||
id="sms-settings-sender"
|
||||
inputMode="numeric"
|
||||
value={smsSenderNumber}
|
||||
onChange={(e) => setSmsSenderNumber(e.target.value.replace(/\D/g, '').slice(0, 16))}
|
||||
placeholder="e.g. 5000123456 (empty = 9000590009)"
|
||||
disabled={smsSettingsSubmitting}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className={styles.helperText}>
|
||||
Used for free-text customer SMS. Leave empty for Meshkee advertising line
|
||||
9000590009. Verified templates always use 5000110005.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="sms-settings-balance">SMS balance (IRT)</label>
|
||||
<input
|
||||
id="sms-settings-balance"
|
||||
inputMode="numeric"
|
||||
value={smsBalance}
|
||||
onChange={(e) => setSmsBalance(e.target.value.replace(/\D/g, '').slice(0, 12))}
|
||||
placeholder="0"
|
||||
disabled={smsSettingsSubmitting}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className={styles.helperText}>
|
||||
Prepaid SMS balance in IRT. Billing: 400 IRT per 64 characters per recipient
|
||||
(OTP, customer SMS, partner SMS). New businesses start with 500,000 IRT. Values
|
||||
below 200,000 IRT show as low balance in the business dashboard.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ height: 12 }} />
|
||||
<div className={styles.actionsRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.btn} ${styles.btnGhost}`}
|
||||
onClick={() => {
|
||||
setSmsSettingsOpen(false)
|
||||
setSmsSettingsBusiness(null)
|
||||
}}
|
||||
disabled={smsSettingsSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.btn} ${styles.btnPrimary}`}
|
||||
onClick={() => void submitSmsSettings()}
|
||||
disabled={smsSettingsSubmitting}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={domainOpen}
|
||||
title={domainId ? 'Edit domain' : 'Add domain'}
|
||||
@@ -2491,6 +2606,10 @@ export function BusinessesPage() {
|
||||
{createError}
|
||||
</p>
|
||||
) : null}
|
||||
<p className={styles.helperText} style={{ marginTop: 0, marginBottom: 12 }}>
|
||||
New businesses receive an initial SMS balance of 500,000 IRT (400 IRT per 64 characters
|
||||
per recipient).
|
||||
</p>
|
||||
<div className={styles.formGrid}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="create-name-en">Name (EN)</label>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CalendarDays, Building2, Users, Globe, Settings } from 'lucide-react'
|
||||
import { CalendarDays, Building2, Users, Globe, MessageSquareText, 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: MessageSquareText,
|
||||
title: 'SMS Templates',
|
||||
description: 'Verify business SMS templates for the Meshkee service shortcode.',
|
||||
linkText: 'Review templates',
|
||||
href: '/sms-templates',
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
title: 'Settings',
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { CheckCircle2, Pencil, RotateCcw, Search, XCircle } from 'lucide-react'
|
||||
import { EmptyTableRow } from '../components/EmptyState'
|
||||
import { Modal } from '../components/Modal'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
listAllSmsTemplates,
|
||||
unverifySmsTemplate,
|
||||
updateSmsTemplateAsAdmin,
|
||||
verifySmsTemplate,
|
||||
type SmsTemplateAdminItem,
|
||||
type SmsTemplateVerificationStatus,
|
||||
} from '../services/smsTemplateService'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import tableStyles from './BusinessesPage.module.css'
|
||||
import styles from './TemplatesPage.module.css'
|
||||
|
||||
type StatusFilter = 'all' | 'pending' | 'verified'
|
||||
|
||||
function templateStatus(item: SmsTemplateAdminItem): SmsTemplateVerificationStatus {
|
||||
if (
|
||||
item.verificationStatus === 'pending' ||
|
||||
item.verificationStatus === 'verified' ||
|
||||
item.verificationStatus === 'free'
|
||||
) {
|
||||
return item.verificationStatus
|
||||
}
|
||||
return item.isVerified ? 'verified' : 'free'
|
||||
}
|
||||
|
||||
export function SmsTemplatesPage() {
|
||||
const { showToast } = useToast()
|
||||
const [items, setItems] = useState<SmsTemplateAdminItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [draftName, setDraftName] = useState('')
|
||||
const [draftBusiness, setDraftBusiness] = useState('')
|
||||
const [draftStatus, setDraftStatus] = useState<StatusFilter>('all')
|
||||
const [appliedFilters, setAppliedFilters] = useState({
|
||||
name: '',
|
||||
business: '',
|
||||
status: 'all' as StatusFilter,
|
||||
})
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<SmsTemplateAdminItem | null>(null)
|
||||
const [draftNameEdit, setDraftNameEdit] = useState('')
|
||||
const [draftBody, setDraftBody] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
|
||||
async function reload(signal?: AbortSignal) {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await listAllSmsTemplates({ status: 'all', signal })
|
||||
setItems(res.items)
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load SMS templates.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void reload(controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
const variablesHint = useMemo(
|
||||
() => '{{user.name}} · {{user.lastname}} · {{user.title}}',
|
||||
[],
|
||||
)
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const nameQ = appliedFilters.name.trim().toLowerCase()
|
||||
const businessQ = appliedFilters.business.trim().toLowerCase()
|
||||
return items.filter((item) => {
|
||||
const status = templateStatus(item)
|
||||
if (appliedFilters.status === 'pending' && status !== 'pending') return false
|
||||
if (appliedFilters.status === 'verified' && status !== 'verified') return false
|
||||
if (nameQ && !item.name.toLowerCase().includes(nameQ)) return false
|
||||
if (businessQ) {
|
||||
const hay = `${item.business.name} ${item.business.slug}`.toLowerCase()
|
||||
if (!hay.includes(businessQ)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [items, appliedFilters])
|
||||
|
||||
function applyFilters() {
|
||||
setAppliedFilters({
|
||||
name: draftName,
|
||||
business: draftBusiness,
|
||||
status: draftStatus,
|
||||
})
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
setDraftName('')
|
||||
setDraftBusiness('')
|
||||
setDraftStatus('all')
|
||||
setAppliedFilters({ name: '', business: '', status: 'all' })
|
||||
}
|
||||
|
||||
function applyTemplateUpdate(template: SmsTemplateAdminItem) {
|
||||
setItems((prev) => {
|
||||
const status = templateStatus(template)
|
||||
// Free templates leave the super-admin queue after unverify / edit-clear.
|
||||
if (status === 'free') {
|
||||
return prev.filter((row) => row.id !== template.id)
|
||||
}
|
||||
const exists = prev.some((row) => row.id === template.id)
|
||||
if (!exists) return [template, ...prev]
|
||||
return prev.map((row) => (row.id === template.id ? template : row))
|
||||
})
|
||||
}
|
||||
|
||||
function openEdit(item: SmsTemplateAdminItem) {
|
||||
setEditing(item)
|
||||
setDraftNameEdit(item.name)
|
||||
setDraftBody(item.body)
|
||||
setFormError('')
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!editing) return
|
||||
const name = draftNameEdit.trim()
|
||||
const body = draftBody.trim()
|
||||
if (!name) {
|
||||
setFormError('Name is required.')
|
||||
return
|
||||
}
|
||||
if (!body) {
|
||||
setFormError('Message body is required.')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
setFormError('')
|
||||
try {
|
||||
const wasVerified = templateStatus(editing) === 'verified'
|
||||
const res = await updateSmsTemplateAsAdmin(editing.id, { name, body })
|
||||
applyTemplateUpdate(res.template)
|
||||
setEditorOpen(false)
|
||||
showToast(
|
||||
wasVerified && templateStatus(res.template) !== 'verified'
|
||||
? 'Template updated and removed from this list (back to free). Business can resubmit.'
|
||||
: 'Template updated.',
|
||||
'success',
|
||||
)
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : 'Unable to save template.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerify(item: SmsTemplateAdminItem) {
|
||||
setBusyId(item.id)
|
||||
try {
|
||||
const res = await verifySmsTemplate(item.id)
|
||||
showToast('Template verified — sends on 5000110005.', 'success')
|
||||
applyTemplateUpdate(res.template)
|
||||
} catch (err) {
|
||||
showToast(err instanceof ApiError ? err.message : 'Unable to verify.', 'error')
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnverify(item: SmsTemplateAdminItem) {
|
||||
setBusyId(item.id)
|
||||
try {
|
||||
const res = await unverifySmsTemplate(item.id)
|
||||
showToast('Verification removed — template is free again.', 'success')
|
||||
applyTemplateUpdate(res.template)
|
||||
} catch (err) {
|
||||
showToast(err instanceof ApiError ? err.message : 'Unable to unverify.', 'error')
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>SMS Templates</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
Review templates submitted for verification. Verified → 5000110005. Pending /
|
||||
free → 9000590009. Variables: {variablesHint}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className={styles.alertError}>{error}</p> : null}
|
||||
|
||||
<div className={tableStyles.filtersPanel}>
|
||||
<div className={tableStyles.filtersTitle}>Filters</div>
|
||||
<form
|
||||
className={tableStyles.filtersGrid}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
applyFilters()
|
||||
}}
|
||||
>
|
||||
<div className={tableStyles.filtersInputs}>
|
||||
<div className={tableStyles.field}>
|
||||
<label htmlFor="sms-template-name">Name</label>
|
||||
<input
|
||||
id="sms-template-name"
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
placeholder="Search by name"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className={tableStyles.field}>
|
||||
<label htmlFor="sms-template-business">Business</label>
|
||||
<input
|
||||
id="sms-template-business"
|
||||
value={draftBusiness}
|
||||
onChange={(e) => setDraftBusiness(e.target.value)}
|
||||
placeholder="Search by business"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className={tableStyles.field}>
|
||||
<label htmlFor="sms-template-status">Status</label>
|
||||
<select
|
||||
id="sms-template-status"
|
||||
value={draftStatus}
|
||||
onChange={(e) => setDraftStatus(e.target.value as StatusFilter)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="all">All (pending + verified)</option>
|
||||
<option value="pending">Waiting for verification</option>
|
||||
<option value="verified">Verified</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className={tableStyles.filterActions}>
|
||||
<button
|
||||
type="submit"
|
||||
className={`${tableStyles.iconActionBtn} ${tableStyles.iconActionBtnPrimary}`}
|
||||
disabled={loading}
|
||||
aria-label="Search"
|
||||
title="Search"
|
||||
>
|
||||
<Search size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.iconActionBtn} ${tableStyles.iconActionBtnGhost}`}
|
||||
onClick={clearFilters}
|
||||
disabled={loading}
|
||||
aria-label="Clear filters"
|
||||
title="Clear filters"
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className={tableStyles.tablePanel}>
|
||||
<div className={tableStyles.tableWrap}>
|
||||
<div className={tableStyles.tableHeader}>
|
||||
<div className={tableStyles.tableHeaderTitle}>Templates</div>
|
||||
<div className={tableStyles.meta}>
|
||||
{loading
|
||||
? 'Loading…'
|
||||
: `${filteredItems.length} template${filteredItems.length === 1 ? '' : 's'}`}
|
||||
</div>
|
||||
</div>
|
||||
<table className={tableStyles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={tableStyles.th}>Business</th>
|
||||
<th className={tableStyles.th}>Name</th>
|
||||
<th className={tableStyles.th}>Body</th>
|
||||
<th className={tableStyles.th} style={{ width: 160 }}>
|
||||
Status
|
||||
</th>
|
||||
<th className={tableStyles.th} style={{ width: 140 }}>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && items.length === 0 ? (
|
||||
<EmptyTableRow colSpan={5} message="Loading SMS templates…" />
|
||||
) : null}
|
||||
{!loading && filteredItems.length === 0 ? (
|
||||
<EmptyTableRow colSpan={5} message="No SMS templates match these filters." />
|
||||
) : null}
|
||||
{filteredItems.map((item) => {
|
||||
const status = templateStatus(item)
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td className={tableStyles.td}>
|
||||
<div className={styles.hostCell}>
|
||||
<strong>{item.business.name}</strong>
|
||||
<span className={styles.businessMeta}>{item.business.slug}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className={tableStyles.td}>
|
||||
<span>{item.name}</span>
|
||||
</td>
|
||||
<td className={tableStyles.td}>
|
||||
<span className={styles.preview} title={item.body}>
|
||||
{item.body}
|
||||
</span>
|
||||
</td>
|
||||
<td className={tableStyles.td}>
|
||||
{status === 'verified' ? (
|
||||
<span className={styles.badgeVerified}>Verified</span>
|
||||
) : (
|
||||
<span className={styles.badgePending}>Waiting for verification</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={tableStyles.td}>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.controlBtn}
|
||||
onClick={() => openEdit(item)}
|
||||
disabled={busyId === item.id || submitting}
|
||||
aria-label="Edit template"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
{status === 'verified' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.controlBtn}
|
||||
onClick={() => void handleUnverify(item)}
|
||||
disabled={busyId === item.id}
|
||||
aria-label="Remove verification"
|
||||
title="Unverify"
|
||||
>
|
||||
<XCircle size={15} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.controlBtn}
|
||||
onClick={() => void handleVerify(item)}
|
||||
disabled={busyId === item.id}
|
||||
aria-label="Verify template"
|
||||
title="Verify"
|
||||
>
|
||||
<CheckCircle2 size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={editorOpen}
|
||||
title="Edit SMS template"
|
||||
wide
|
||||
onClose={() => {
|
||||
if (submitting) return
|
||||
setEditorOpen(false)
|
||||
}}
|
||||
>
|
||||
{editing ? (
|
||||
<p className={styles.helperText}>
|
||||
{editing.business.name} · {editing.business.slug}
|
||||
</p>
|
||||
) : null}
|
||||
{editing && templateStatus(editing) === 'verified' ? (
|
||||
<p className={styles.helperText}>
|
||||
Saving changes clears verification and removes this template from the admin queue
|
||||
until the business submits it again.
|
||||
</p>
|
||||
) : null}
|
||||
{formError ? (
|
||||
<p className={styles.alertError} role="alert">
|
||||
{formError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className={tableStyles.field}>
|
||||
<label htmlFor="sms-edit-name">Name</label>
|
||||
<input
|
||||
id="sms-edit-name"
|
||||
value={draftNameEdit}
|
||||
onChange={(e) => setDraftNameEdit(e.target.value)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
<div className={tableStyles.field}>
|
||||
<label htmlFor="sms-edit-body">Message</label>
|
||||
<textarea
|
||||
id="sms-edit-body"
|
||||
className={styles.promptArea}
|
||||
value={draftBody}
|
||||
onChange={(e) => setDraftBody(e.target.value.slice(0, 700))}
|
||||
disabled={submitting}
|
||||
rows={10}
|
||||
/>
|
||||
<p className={styles.helperText}>Variables: {variablesHint}</p>
|
||||
</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}
|
||||
>
|
||||
{submitting ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -12,10 +12,6 @@
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.filterField {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: block;
|
||||
max-width: 520px;
|
||||
@@ -124,3 +120,23 @@
|
||||
.helperText a {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.badgeVerified,
|
||||
.badgePending {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badgeVerified {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.badgePending {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
@@ -579,7 +579,7 @@ export function UsersPage() {
|
||||
<div className={tableStyles.filtersTitle}>Filters</div>
|
||||
<form className={tableStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
|
||||
<div className={tableStyles.filtersInputs}>
|
||||
<div className={`${tableStyles.field} ${tableStyles.fieldCol3}`}>
|
||||
<div className={tableStyles.field}>
|
||||
<label htmlFor="filter-user-name">Name</label>
|
||||
<input
|
||||
id="filter-user-name"
|
||||
@@ -588,7 +588,7 @@ export function UsersPage() {
|
||||
placeholder="Search by name"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${tableStyles.field} ${tableStyles.fieldCol3}`}>
|
||||
<div className={tableStyles.field}>
|
||||
<label htmlFor="filter-user-cell">Cell number</label>
|
||||
<input
|
||||
id="filter-user-cell"
|
||||
@@ -598,7 +598,7 @@ export function UsersPage() {
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${tableStyles.field} ${tableStyles.fieldCol3}`}>
|
||||
<div className={tableStyles.field}>
|
||||
<label htmlFor="filter-user-role">Role</label>
|
||||
<select
|
||||
id="filter-user-role"
|
||||
|
||||
@@ -518,7 +518,7 @@ export function WebsitesPage() {
|
||||
<div className={tableStyles.filtersTitle}>Filters</div>
|
||||
<form className={tableStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
|
||||
<div className={tableStyles.filtersInputs}>
|
||||
<div className={`${tableStyles.field} ${tableStyles.fieldCol3}`}>
|
||||
<div className={tableStyles.field}>
|
||||
<label htmlFor="filter-domain-name">Name</label>
|
||||
<input
|
||||
id="filter-domain-name"
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface UpdateBusinessPayload {
|
||||
oldBusinessId?: number | null
|
||||
annualRenewalAt?: string | null
|
||||
smsSenderNumber?: string | null
|
||||
smsBalance?: number
|
||||
}
|
||||
|
||||
export interface AddDomainPayload {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { apiRequest } from '../lib/api'
|
||||
|
||||
export type SmsTemplateVerificationStatus = 'free' | 'pending' | 'verified'
|
||||
|
||||
export interface SmsTemplateAdminItem {
|
||||
id: string
|
||||
businessId: string
|
||||
name: string
|
||||
body: string
|
||||
verificationStatus: SmsTemplateVerificationStatus
|
||||
isVerified: boolean
|
||||
verifiedAt: string | null
|
||||
verifiedBy: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
business: {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAllSmsTemplates(
|
||||
options?: { status?: 'pending' | 'verified' | 'all'; signal?: AbortSignal },
|
||||
) {
|
||||
const params = new URLSearchParams()
|
||||
if (options?.status && options.status !== 'all') {
|
||||
params.set('status', options.status)
|
||||
} else {
|
||||
params.set('status', 'all')
|
||||
}
|
||||
const query = params.toString()
|
||||
return apiRequest<{ items: SmsTemplateAdminItem[]; variables: string[] }>(
|
||||
`/sms-templates${query ? `?${query}` : ''}`,
|
||||
{ auth: true, signal: options?.signal },
|
||||
)
|
||||
}
|
||||
|
||||
export async function updateSmsTemplateAsAdmin(
|
||||
templateId: string,
|
||||
payload: { name?: string; body?: string },
|
||||
) {
|
||||
return apiRequest<{ template: SmsTemplateAdminItem }>(
|
||||
`/sms-templates/${templateId}`,
|
||||
{ method: 'PATCH', auth: true, body: payload },
|
||||
)
|
||||
}
|
||||
|
||||
export async function verifySmsTemplate(templateId: string) {
|
||||
return apiRequest<{ template: SmsTemplateAdminItem }>(
|
||||
`/sms-templates/${templateId}/verify`,
|
||||
{ method: 'POST', auth: true },
|
||||
)
|
||||
}
|
||||
|
||||
export async function unverifySmsTemplate(templateId: string) {
|
||||
return apiRequest<{ template: SmsTemplateAdminItem }>(
|
||||
`/sms-templates/${templateId}/unverify`,
|
||||
{ method: 'POST', auth: true },
|
||||
)
|
||||
}
|
||||
@@ -40,6 +40,8 @@ export interface BusinessListItem {
|
||||
oldBusinessId: string | null
|
||||
/** Gama shortcode for customer SMS; null → Meshkee advertising number. */
|
||||
smsSenderNumber: string | null
|
||||
/** Prepaid SMS balance in IRT (400 IRT per 64 chars / recipient). */
|
||||
smsBalance: number
|
||||
}
|
||||
|
||||
export type MigrateFromOldEntity =
|
||||
|
||||
Reference in New Issue
Block a user