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:
Alireza Hassani
2026-09-08 16:24:26 +03:30
co-authored by Cursor
parent 8f0b4cef85
commit 0eb19bbebd
27 changed files with 2324 additions and 527 deletions
+2
View File
@@ -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}
+2 -1
View File
@@ -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 {
+143 -24
View File
@@ -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>
+8 -1
View File
@@ -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;
}
+3 -3
View File
@@ -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"
+1 -1
View File
@@ -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 },
)
}
+2
View File
@@ -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 =