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
@@ -167,7 +167,7 @@ function App() {
|
||||
<Route path="website/faq" element={<WebsiteFaqPage />} />
|
||||
<Route path="website/badges" element={<WebsiteBadgesPage />} />
|
||||
<Route path="website/settings" element={<WebsiteSettingsPage />} />
|
||||
<Route path="website/e-payment" element={<Navigate to="/settings" replace />} />
|
||||
<Route path="website/e-payment" element={<Navigate to="/website/settings" replace />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
z-index: 1100;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Switch } from './Switch'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { BusinessMessageKey } from '../i18n/messages'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
DEFAULT_EPAYMENT_SETTINGS,
|
||||
getSettings,
|
||||
updateStoreSettings,
|
||||
type EPaymentSettings,
|
||||
type PaymentGatewayId,
|
||||
} from '../services/settingsService'
|
||||
import sharedStyles from '../pages/ProductSettingsPage.module.css'
|
||||
import fieldStyles from '../pages/AddNewProductPage.module.css'
|
||||
import styles from '../pages/SettingsPage.module.css'
|
||||
|
||||
const COMING_SOON_GATEWAYS: {
|
||||
id: Exclude<PaymentGatewayId, 'mellat' | 'zarinpal'>
|
||||
nameKey: BusinessMessageKey
|
||||
descKey: BusinessMessageKey
|
||||
}[] = [
|
||||
{
|
||||
id: 'sep',
|
||||
nameKey: 'website.ePayment.gateway.sep',
|
||||
descKey: 'website.ePayment.gateway.sepDesc',
|
||||
},
|
||||
{
|
||||
id: 'snappay',
|
||||
nameKey: 'website.ePayment.gateway.snappay',
|
||||
descKey: 'website.ePayment.gateway.snappayDesc',
|
||||
},
|
||||
{
|
||||
id: 'digipay',
|
||||
nameKey: 'website.ePayment.gateway.digipay',
|
||||
descKey: 'website.ePayment.gateway.digipayDesc',
|
||||
},
|
||||
]
|
||||
|
||||
function nextDefaultGateway(
|
||||
current: EPaymentSettings,
|
||||
gatewayId: PaymentGatewayId,
|
||||
enabled: boolean,
|
||||
): PaymentGatewayId | null {
|
||||
if (enabled && !current.defaultGateway) return gatewayId
|
||||
if (!enabled && current.defaultGateway === gatewayId) {
|
||||
if (gatewayId !== 'mellat' && current.gateways.mellat.enabled) return 'mellat'
|
||||
if (gatewayId !== 'zarinpal' && current.gateways.zarinpal.enabled) {
|
||||
return 'zarinpal'
|
||||
}
|
||||
return null
|
||||
}
|
||||
return current.defaultGateway
|
||||
}
|
||||
|
||||
export function EPaymentSettingsSection() {
|
||||
const t = useT()
|
||||
const { showToast } = useToast()
|
||||
const [ePayment, setEPayment] = useState<EPaymentSettings>(DEFAULT_EPAYMENT_SETTINGS)
|
||||
const [passwordDraft, setPasswordDraft] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadSettings(controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
async function loadSettings(signal?: AbortSignal) {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await getSettings(signal)
|
||||
setEPayment(data.settings.store.ePayment ?? DEFAULT_EPAYMENT_SETTINGS)
|
||||
setPasswordDraft('')
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('productSettings.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function patchEPayment(
|
||||
saving: string,
|
||||
next: EPaymentSettings,
|
||||
password?: string,
|
||||
) {
|
||||
setSavingKey(saving)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const payload: EPaymentSettings = {
|
||||
enabled: next.enabled,
|
||||
defaultGateway: next.defaultGateway,
|
||||
gateways: {
|
||||
mellat: {
|
||||
enabled: next.gateways.mellat.enabled,
|
||||
terminalId: next.gateways.mellat.terminalId,
|
||||
username: next.gateways.mellat.username,
|
||||
password: password ?? '',
|
||||
},
|
||||
sep: { enabled: next.gateways.sep.enabled },
|
||||
snappay: { enabled: next.gateways.snappay.enabled },
|
||||
digipay: { enabled: next.gateways.digipay.enabled },
|
||||
zarinpal: {
|
||||
enabled: next.gateways.zarinpal.enabled,
|
||||
merchantId: next.gateways.zarinpal.merchantId.trim(),
|
||||
sandbox: next.gateways.zarinpal.sandbox,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const data = await updateStoreSettings({ ePayment: payload })
|
||||
setEPayment(data.settings.store.ePayment ?? DEFAULT_EPAYMENT_SETTINGS)
|
||||
if (password) setPasswordDraft('')
|
||||
showToast(t('productSettings.saved'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('productSettings.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setSavingKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMasterToggle(checked: boolean) {
|
||||
await patchEPayment('master', {
|
||||
...ePayment,
|
||||
enabled: checked,
|
||||
defaultGateway: checked
|
||||
? ePayment.defaultGateway ??
|
||||
(ePayment.gateways.mellat.enabled
|
||||
? 'mellat'
|
||||
: ePayment.gateways.zarinpal.enabled
|
||||
? 'zarinpal'
|
||||
: 'mellat')
|
||||
: ePayment.defaultGateway,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleMellatToggle(checked: boolean) {
|
||||
await patchEPayment('mellat-enabled', {
|
||||
...ePayment,
|
||||
enabled: checked ? true : ePayment.enabled,
|
||||
gateways: {
|
||||
...ePayment.gateways,
|
||||
mellat: { ...ePayment.gateways.mellat, enabled: checked },
|
||||
},
|
||||
defaultGateway: nextDefaultGateway(ePayment, 'mellat', checked),
|
||||
})
|
||||
}
|
||||
|
||||
async function handleZarinpalToggle(checked: boolean) {
|
||||
await patchEPayment('zarinpal-enabled', {
|
||||
...ePayment,
|
||||
enabled: checked ? true : ePayment.enabled,
|
||||
gateways: {
|
||||
...ePayment.gateways,
|
||||
zarinpal: { ...ePayment.gateways.zarinpal, enabled: checked },
|
||||
},
|
||||
defaultGateway: nextDefaultGateway(ePayment, 'zarinpal', checked),
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSaveMellatCredentials(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
const terminalId = ePayment.gateways.mellat.terminalId.trim()
|
||||
const username = ePayment.gateways.mellat.username.trim()
|
||||
|
||||
if (ePayment.gateways.mellat.enabled && (!terminalId || !username)) {
|
||||
setError(t('website.ePayment.error.credentials'))
|
||||
return
|
||||
}
|
||||
|
||||
await patchEPayment(
|
||||
'mellat-credentials',
|
||||
{
|
||||
...ePayment,
|
||||
gateways: {
|
||||
...ePayment.gateways,
|
||||
mellat: {
|
||||
...ePayment.gateways.mellat,
|
||||
terminalId,
|
||||
username,
|
||||
},
|
||||
},
|
||||
},
|
||||
passwordDraft.trim() || undefined,
|
||||
)
|
||||
}
|
||||
|
||||
async function handleSaveZarinpalCredentials(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
const merchantId = ePayment.gateways.zarinpal.merchantId.trim()
|
||||
|
||||
if (ePayment.gateways.zarinpal.enabled && !merchantId) {
|
||||
setError(t('website.ePayment.error.zarinpalMerchant'))
|
||||
return
|
||||
}
|
||||
|
||||
await patchEPayment('zarinpal-credentials', {
|
||||
...ePayment,
|
||||
gateways: {
|
||||
...ePayment.gateways,
|
||||
zarinpal: {
|
||||
...ePayment.gateways.zarinpal,
|
||||
merchantId,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <p className={sharedStyles.status}>{t('productSettings.loading')}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
{error ? <div className={sharedStyles.alertError}>{error}</div> : null}
|
||||
|
||||
<section className={sharedStyles.panel}>
|
||||
<h3 className={sharedStyles.sectionTitle}>{t('title.ePayment')}</h3>
|
||||
<div className={sharedStyles.list}>
|
||||
<div className={sharedStyles.row}>
|
||||
<div className={sharedStyles.rowText}>
|
||||
<label className={sharedStyles.rowLabel} htmlFor="epayment-master">
|
||||
{t('website.ePayment.master')}
|
||||
</label>
|
||||
<p className={sharedStyles.rowDescription}>
|
||||
{t('website.ePayment.masterDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="epayment-master"
|
||||
checked={ePayment.enabled}
|
||||
disabled={savingKey !== null}
|
||||
onChange={(checked) => void handleMasterToggle(checked)}
|
||||
/>
|
||||
</div>
|
||||
{!ePayment.enabled &&
|
||||
(ePayment.gateways.mellat.enabled || ePayment.gateways.zarinpal.enabled) ? (
|
||||
<p className={styles.masterOffHint} role="status">
|
||||
{t('website.ePayment.masterOffHint')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.gatewayCard} data-card-hover>
|
||||
<div className={styles.gatewayHeader}>
|
||||
<div>
|
||||
<h3 className={styles.gatewayTitle}>
|
||||
{t('website.ePayment.gateway.mellat')}
|
||||
</h3>
|
||||
<p className={styles.gatewayDesc}>
|
||||
{t('website.ePayment.gateway.mellatDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="epayment-mellat"
|
||||
checked={ePayment.gateways.mellat.enabled}
|
||||
disabled={savingKey !== null}
|
||||
onChange={(checked) => void handleMellatToggle(checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className={`${styles.credentials} ${
|
||||
!ePayment.gateways.mellat.enabled ? styles.credentialsDisabled : ''
|
||||
}`}
|
||||
onSubmit={handleSaveMellatCredentials}
|
||||
aria-disabled={!ePayment.gateways.mellat.enabled}
|
||||
>
|
||||
<div className={`${fieldStyles.field} ${styles.field}`}>
|
||||
<label htmlFor="mellat-terminal">{t('website.ePayment.terminalId')}</label>
|
||||
<input
|
||||
id="mellat-terminal"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
dir="ltr"
|
||||
value={ePayment.gateways.mellat.terminalId}
|
||||
disabled={savingKey !== null || !ePayment.gateways.mellat.enabled}
|
||||
onChange={(e) =>
|
||||
setEPayment((current) => ({
|
||||
...current,
|
||||
gateways: {
|
||||
...current.gateways,
|
||||
mellat: {
|
||||
...current.gateways.mellat,
|
||||
terminalId: e.target.value,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${fieldStyles.field} ${styles.field}`}>
|
||||
<label htmlFor="mellat-username">{t('website.ePayment.username')}</label>
|
||||
<input
|
||||
id="mellat-username"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
dir="ltr"
|
||||
value={ePayment.gateways.mellat.username}
|
||||
disabled={savingKey !== null || !ePayment.gateways.mellat.enabled}
|
||||
onChange={(e) =>
|
||||
setEPayment((current) => ({
|
||||
...current,
|
||||
gateways: {
|
||||
...current.gateways,
|
||||
mellat: {
|
||||
...current.gateways.mellat,
|
||||
username: e.target.value,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${fieldStyles.field} ${styles.field}`}>
|
||||
<label htmlFor="mellat-password">{t('website.ePayment.password')}</label>
|
||||
<div className={styles.passwordWrap}>
|
||||
<input
|
||||
id="mellat-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
dir="ltr"
|
||||
placeholder={t('website.ePayment.passwordPlaceholderSet')}
|
||||
value={passwordDraft}
|
||||
disabled={savingKey !== null || !ePayment.gateways.mellat.enabled}
|
||||
onChange={(e) => setPasswordDraft(e.target.value)}
|
||||
/>
|
||||
<Tooltip
|
||||
label={
|
||||
showPassword
|
||||
? t('website.ePayment.hidePassword')
|
||||
: t('website.ePayment.showPassword')
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.passwordToggle}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
disabled={savingKey !== null || !ePayment.gateways.mellat.enabled}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t('website.ePayment.hidePassword')
|
||||
: t('website.ePayment.showPassword')
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actionsInline}>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.saveBtn}
|
||||
disabled={savingKey !== null || !ePayment.gateways.mellat.enabled}
|
||||
>
|
||||
{savingKey === 'mellat-credentials'
|
||||
? t('storeSettings.saving')
|
||||
: t('website.ePayment.saveCredentials')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className={styles.gatewayCard} data-card-hover>
|
||||
<div className={styles.gatewayHeader}>
|
||||
<div>
|
||||
<h3 className={styles.gatewayTitle}>
|
||||
{t('website.ePayment.gateway.zarinpal')}
|
||||
</h3>
|
||||
<p className={styles.gatewayDesc}>
|
||||
{t('website.ePayment.gateway.zarinpalDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="epayment-zarinpal"
|
||||
checked={ePayment.gateways.zarinpal.enabled}
|
||||
disabled={savingKey !== null}
|
||||
onChange={(checked) => void handleZarinpalToggle(checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className={`${styles.credentialsInline} ${
|
||||
!ePayment.gateways.zarinpal.enabled ? styles.credentialsDisabled : ''
|
||||
}`}
|
||||
onSubmit={handleSaveZarinpalCredentials}
|
||||
aria-disabled={!ePayment.gateways.zarinpal.enabled}
|
||||
>
|
||||
<label className={styles.inlineLabel} htmlFor="zarinpal-merchant">
|
||||
{t('website.ePayment.merchantId')}
|
||||
</label>
|
||||
<input
|
||||
id="zarinpal-merchant"
|
||||
className={styles.inlineInput}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
dir="ltr"
|
||||
spellCheck={false}
|
||||
placeholder={t('website.ePayment.merchantIdPlaceholder')}
|
||||
value={ePayment.gateways.zarinpal.merchantId}
|
||||
disabled={savingKey !== null || !ePayment.gateways.zarinpal.enabled}
|
||||
onChange={(e) =>
|
||||
setEPayment((current) => ({
|
||||
...current,
|
||||
gateways: {
|
||||
...current.gateways,
|
||||
zarinpal: {
|
||||
...current.gateways.zarinpal,
|
||||
merchantId: e.target.value,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.saveBtn}
|
||||
disabled={savingKey !== null || !ePayment.gateways.zarinpal.enabled}
|
||||
>
|
||||
{savingKey === 'zarinpal-credentials'
|
||||
? t('storeSettings.saving')
|
||||
: t('website.ePayment.saveCredentials')}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div className={styles.comingSoonGrid}>
|
||||
{COMING_SOON_GATEWAYS.map((gateway) => (
|
||||
<section key={gateway.id} className={styles.comingSoonCard}>
|
||||
<div className={styles.gatewayHeader}>
|
||||
<div>
|
||||
<h3 className={styles.gatewayTitle}>{t(gateway.nameKey)}</h3>
|
||||
<p className={styles.gatewayDesc}>{t(gateway.descKey)}</p>
|
||||
</div>
|
||||
<span className={styles.badge}>{t('website.ePayment.comingSoon')}</span>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,13 +3,14 @@ import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||
import {
|
||||
sendCustomerSms,
|
||||
sendCustomersBulkSms,
|
||||
type BusinessCustomerListItem,
|
||||
} from '../services/customerService'
|
||||
import { listSmsTemplates, type SmsTemplate } from '../services/smsTemplateService'
|
||||
import modalStyles from './VariationsModal.module.css'
|
||||
|
||||
export type SmsComposeTarget =
|
||||
@@ -39,6 +40,9 @@ export function SendCustomerSmsModal({
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [templateId, setTemplateId] = useState('')
|
||||
const [templates, setTemplates] = useState<SmsTemplate[]>([])
|
||||
const [templatesLoading, setTemplatesLoading] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
@@ -47,6 +51,7 @@ export function SendCustomerSmsModal({
|
||||
setMounted(true)
|
||||
setClosing(false)
|
||||
setMessage('')
|
||||
setTemplateId('')
|
||||
setError('')
|
||||
} else if (mounted) {
|
||||
setClosing(true)
|
||||
@@ -58,6 +63,24 @@ export function SendCustomerSmsModal({
|
||||
}
|
||||
}, [open, target, mounted])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !target) return
|
||||
const controller = new AbortController()
|
||||
setTemplatesLoading(true)
|
||||
void listSmsTemplates(controller.signal)
|
||||
.then((res) => {
|
||||
if (!controller.signal.aborted) setTemplates(res.items)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isAbortError(err)) return
|
||||
if (!controller.signal.aborted) setTemplates([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setTemplatesLoading(false)
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [open, target])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || closing) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -69,16 +92,25 @@ export function SendCustomerSmsModal({
|
||||
|
||||
if (!mounted || !target) return null
|
||||
|
||||
const selectedTemplate = templates.find((item) => item.id === templateId) ?? null
|
||||
const trimmed = message.trim()
|
||||
const canSend = trimmed.length > 0 && trimmed.length <= MAX_LEN
|
||||
|
||||
function applyTemplate(nextId: string) {
|
||||
setTemplateId(nextId)
|
||||
if (!nextId) return
|
||||
const tpl = templates.find((item) => item.id === nextId)
|
||||
if (tpl) setMessage(tpl.body.slice(0, MAX_LEN))
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!target || !canSend) return
|
||||
setIsSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
const tplId = templateId || null
|
||||
if (target.mode === 'single') {
|
||||
const result = await sendCustomerSms(target.customer.id, trimmed)
|
||||
const result = await sendCustomerSms(target.customer.id, trimmed, tplId)
|
||||
if (!result.enabled) {
|
||||
setError(result.message || t('customers.smsModal.errorDisabled'))
|
||||
return
|
||||
@@ -90,7 +122,7 @@ export function SendCustomerSmsModal({
|
||||
})
|
||||
} else {
|
||||
const userIds = target.mode === 'selected' ? target.userIds : undefined
|
||||
const result = await sendCustomersBulkSms(trimmed, userIds)
|
||||
const result = await sendCustomersBulkSms(trimmed, userIds, tplId)
|
||||
if (!result.enabled) {
|
||||
setError(result.message || t('customers.smsModal.errorDisabled'))
|
||||
return
|
||||
@@ -154,6 +186,34 @@ export function SendCustomerSmsModal({
|
||||
<div className={modalStyles.body}>
|
||||
{error ? <p className={modalStyles.errorText}>{error}</p> : null}
|
||||
|
||||
<div className={modalStyles.field}>
|
||||
<label htmlFor="customer-sms-template">{t('customers.smsModal.template')}</label>
|
||||
<select
|
||||
id="customer-sms-template"
|
||||
value={templateId}
|
||||
onChange={(e) => applyTemplate(e.target.value)}
|
||||
disabled={isSubmitting || templatesLoading}
|
||||
>
|
||||
<option value="">{t('customers.smsModal.templateNone')}</option>
|
||||
{templates.map((tpl) => (
|
||||
<option key={tpl.id} value={tpl.id}>
|
||||
{tpl.name}
|
||||
{tpl.isVerified ? ' ✓' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{templatesLoading ? (
|
||||
<p className={modalStyles.modalHint}>{t('customers.smsModal.templateLoading')}</p>
|
||||
) : null}
|
||||
{selectedTemplate ? (
|
||||
<p className={modalStyles.modalHint}>
|
||||
{selectedTemplate.isVerified
|
||||
? t('customers.smsModal.templateHintVerified')
|
||||
: t('customers.smsModal.templateHintPending')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={modalStyles.field}>
|
||||
<label htmlFor="customer-sms-message">{t('customers.smsModal.message')}</label>
|
||||
<textarea
|
||||
@@ -161,9 +221,10 @@ export function SendCustomerSmsModal({
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value.slice(0, MAX_LEN))}
|
||||
rows={5}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || Boolean(selectedTemplate?.isVerified)}
|
||||
placeholder={t('customers.smsModal.placeholder')}
|
||||
dir={isFa ? 'rtl' : 'auto'}
|
||||
readOnly={Boolean(selectedTemplate?.isVerified)}
|
||||
/>
|
||||
<p className={modalStyles.modalHint}>
|
||||
{message.length} / {MAX_LEN}
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MessageSquare, Pencil, Plus, Send, Trash2, X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { ConfirmDeleteModal } from './ConfirmDeleteModal'
|
||||
import { EmptyState } from './EmptyState'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import { formatIrtPrice } from '../utils/irtPrice'
|
||||
import {
|
||||
createSmsTemplate,
|
||||
deleteSmsTemplate,
|
||||
getSmsCredit,
|
||||
listSmsTemplates,
|
||||
requestSmsTemplateVerification,
|
||||
updateSmsTemplate,
|
||||
type SmsTemplate,
|
||||
type SmsTemplateVerificationStatus,
|
||||
} from '../services/smsTemplateService'
|
||||
import controlStyles from './CategoryRow.module.css'
|
||||
import fieldStyles from '../pages/AddNewProductPage.module.css'
|
||||
import sharedStyles from '../pages/ProductSettingsPage.module.css'
|
||||
import settingsStyles from '../pages/SettingsPage.module.css'
|
||||
import modalStyles from './VariationsModal.module.css'
|
||||
|
||||
const SMS_TEMPLATE_VARS = ['{{user.name}}', '{{user.lastname}}', '{{user.title}}'] as const
|
||||
const LOW_BALANCE_IRT = 200_000
|
||||
const ANIMATION_MS = 220
|
||||
const MAX_BODY_LEN = 700
|
||||
|
||||
function templateStatus(template: SmsTemplate): SmsTemplateVerificationStatus {
|
||||
if (template.verificationStatus === 'pending' || template.verificationStatus === 'verified') {
|
||||
return template.verificationStatus
|
||||
}
|
||||
if (template.isVerified) return 'verified'
|
||||
return 'free'
|
||||
}
|
||||
|
||||
export function SmsSettingsSection() {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const { showToast } = useToast()
|
||||
const [balance, setBalance] = useState(0)
|
||||
const [templates, setTemplates] = useState<SmsTemplate[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [listOpen, setListOpen] = useState(false)
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<SmsTemplate | null>(null)
|
||||
const [draftName, setDraftName] = useState('')
|
||||
const [draftBody, setDraftBody] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [editorError, setEditorError] = useState('')
|
||||
const [removeTarget, setRemoveTarget] = useState<SmsTemplate | null>(null)
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
const bodyRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const [listMounted, setListMounted] = useState(false)
|
||||
const [listClosing, setListClosing] = useState(false)
|
||||
const [editorMounted, setEditorMounted] = useState(false)
|
||||
const [editorClosing, setEditorClosing] = useState(false)
|
||||
|
||||
async function reload(signal?: AbortSignal) {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [tpl, creditRes] = await Promise.all([
|
||||
listSmsTemplates(signal),
|
||||
getSmsCredit(signal),
|
||||
])
|
||||
if (signal?.aborted) return
|
||||
setTemplates(tpl.items)
|
||||
setBalance(creditRes.balance ?? creditRes.credit ?? 0)
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : t('settings.sms.errorLoad'))
|
||||
} finally {
|
||||
if (!signal?.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void reload(controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (listOpen) {
|
||||
setListMounted(true)
|
||||
setListClosing(false)
|
||||
} else if (listMounted) {
|
||||
setListClosing(true)
|
||||
const timer = setTimeout(() => {
|
||||
setListMounted(false)
|
||||
setListClosing(false)
|
||||
}, ANIMATION_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [listOpen, listMounted])
|
||||
|
||||
useEffect(() => {
|
||||
if (editorOpen) {
|
||||
setEditorMounted(true)
|
||||
setEditorClosing(false)
|
||||
} else if (editorMounted) {
|
||||
setEditorClosing(true)
|
||||
const timer = setTimeout(() => {
|
||||
setEditorMounted(false)
|
||||
setEditorClosing(false)
|
||||
}, ANIMATION_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [editorOpen, editorMounted])
|
||||
|
||||
useEffect(() => {
|
||||
if (!listMounted && !editorMounted) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape' || submitting) return
|
||||
if (editorOpen) {
|
||||
setEditorOpen(false)
|
||||
return
|
||||
}
|
||||
if (listOpen && removeTarget === null) setListOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [listMounted, editorMounted, editorOpen, listOpen, submitting, removeTarget])
|
||||
|
||||
const verifiedCount = templates.filter((item) => item.isVerified).length
|
||||
const isLowBalance = balance < LOW_BALANCE_IRT
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null)
|
||||
setDraftName('')
|
||||
setDraftBody('')
|
||||
setEditorError('')
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
function openEdit(template: SmsTemplate) {
|
||||
setEditing(template)
|
||||
setDraftName(template.name)
|
||||
setDraftBody(template.body)
|
||||
setEditorError('')
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
function insertVariable(token: string) {
|
||||
const el = bodyRef.current
|
||||
const start = el?.selectionStart ?? draftBody.length
|
||||
const end = el?.selectionEnd ?? draftBody.length
|
||||
const next = `${draftBody.slice(0, start)}${token}${draftBody.slice(end)}`.slice(
|
||||
0,
|
||||
MAX_BODY_LEN,
|
||||
)
|
||||
const inserted = Math.min(token.length, MAX_BODY_LEN - draftBody.slice(0, start).length)
|
||||
const cursor = start + Math.max(0, inserted)
|
||||
setDraftBody(next)
|
||||
requestAnimationFrame(() => {
|
||||
el?.focus()
|
||||
el?.setSelectionRange(cursor, cursor)
|
||||
})
|
||||
}
|
||||
|
||||
async function saveTemplate(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
const name = draftName.trim()
|
||||
const body = draftBody.trim()
|
||||
if (!name || !body) {
|
||||
setEditorError(t('settings.sms.templateRequired'))
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
setEditorError('')
|
||||
try {
|
||||
if (editing) {
|
||||
const wasVerified = editing.isVerified
|
||||
await updateSmsTemplate(editing.id, { name, body })
|
||||
showToast(
|
||||
wasVerified
|
||||
? t('settings.sms.templateUpdatedUnverified')
|
||||
: t('settings.sms.templateUpdated'),
|
||||
'success',
|
||||
)
|
||||
} else {
|
||||
await createSmsTemplate({ name, body })
|
||||
showToast(t('settings.sms.templateCreated'), 'success')
|
||||
}
|
||||
setEditorOpen(false)
|
||||
await reload()
|
||||
} catch (err) {
|
||||
setEditorError(err instanceof ApiError ? err.message : t('settings.sms.errorSave'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRemove() {
|
||||
if (!removeTarget) return
|
||||
try {
|
||||
await deleteSmsTemplate(removeTarget.id)
|
||||
showToast(t('settings.sms.templateRemoved'), 'success')
|
||||
setRemoveTarget(null)
|
||||
await reload()
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : t('settings.sms.errorSave'))
|
||||
setRemoveTarget(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRequestVerification(template: SmsTemplate) {
|
||||
setBusyId(template.id)
|
||||
try {
|
||||
await requestSmsTemplateVerification(template.id)
|
||||
showToast(t('settings.sms.requestVerificationDone'), 'success')
|
||||
await reload()
|
||||
} catch (err) {
|
||||
showToast(
|
||||
err instanceof ApiError ? err.message : t('settings.sms.requestVerificationError'),
|
||||
'error',
|
||||
)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(status: SmsTemplateVerificationStatus) {
|
||||
if (status === 'verified') {
|
||||
return {
|
||||
className: settingsStyles.smsBadgeVerified,
|
||||
label: t('settings.sms.badgeVerified'),
|
||||
}
|
||||
}
|
||||
if (status === 'pending') {
|
||||
return {
|
||||
className: settingsStyles.smsBadgePending,
|
||||
label: t('settings.sms.badgePending'),
|
||||
}
|
||||
}
|
||||
return {
|
||||
className: settingsStyles.smsBadgeFree,
|
||||
label: t('settings.sms.badgeFree'),
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={settingsStyles.gatewayCard}>
|
||||
<div className={settingsStyles.gatewayHeader}>
|
||||
<div>
|
||||
<h3 className={settingsStyles.gatewayTitle}>{t('settings.sms.title')}</h3>
|
||||
<p className={settingsStyles.gatewayDesc}>{t('settings.sms.desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className={sharedStyles.errorText}>{error}</p> : null}
|
||||
|
||||
<div
|
||||
className={`${settingsStyles.smsCreditRow} ${
|
||||
isLowBalance ? settingsStyles.smsCreditRowLow : settingsStyles.smsCreditRowOk
|
||||
}`}
|
||||
>
|
||||
<span className={settingsStyles.smsCreditLabel}>{t('settings.sms.balance')}</span>
|
||||
<strong className={settingsStyles.smsCreditValue}>
|
||||
{loading ? '…' : formatIrtPrice(balance)}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<div className={settingsStyles.smsTemplatesSummary}>
|
||||
<div className={settingsStyles.smsTemplatesSummaryText}>
|
||||
<h4 className={settingsStyles.smsTemplatesTitle}>{t('settings.sms.templates')}</h4>
|
||||
<p className={settingsStyles.smsTemplatesCounts}>
|
||||
{loading
|
||||
? t('settings.sms.loading')
|
||||
: t('settings.sms.templatesCounts', {
|
||||
total: templates.length,
|
||||
verified: verifiedCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={settingsStyles.saveBtn}
|
||||
onClick={() => setListOpen(true)}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('settings.sms.manageTemplates')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{listMounted
|
||||
? createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${
|
||||
listClosing ? modalStyles.overlayOut : modalStyles.overlayIn
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!submitting && !editorOpen) setListOpen(false)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`${modalStyles.modal} ${settingsStyles.smsTemplatesModal} ${
|
||||
listClosing ? modalStyles.modalOut : modalStyles.modalIn
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="sms-templates-list-title"
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<div className={modalStyles.header}>
|
||||
<div>
|
||||
<h3 id="sms-templates-list-title" className={modalStyles.title}>
|
||||
{t('settings.sms.templates')}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.closeBtn}
|
||||
onClick={() => setListOpen(false)}
|
||||
disabled={submitting}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={modalStyles.body}>
|
||||
{templates.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={MessageSquare}
|
||||
message={t('settings.sms.templatesEmpty')}
|
||||
actionLabel={t('settings.sms.addTemplate')}
|
||||
onAction={openCreate}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={settingsStyles.smsModalToolbar}>
|
||||
<p className={modalStyles.modalHint}>
|
||||
{t('settings.sms.templatesCounts', {
|
||||
total: templates.length,
|
||||
verified: verifiedCount,
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={settingsStyles.saveBtn}
|
||||
onClick={openCreate}
|
||||
disabled={submitting}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t('settings.sms.addTemplate')}
|
||||
</button>
|
||||
</div>
|
||||
<ul className={settingsStyles.smsTemplateList}>
|
||||
{templates.map((template) => {
|
||||
const status = templateStatus(template)
|
||||
const badge = statusBadge(status)
|
||||
return (
|
||||
<li key={template.id} className={settingsStyles.smsTemplateRow}>
|
||||
<div className={settingsStyles.smsTemplateMeta}>
|
||||
<div className={settingsStyles.smsTemplateTop}>
|
||||
<strong className={settingsStyles.smsTemplateName}>
|
||||
{template.name}
|
||||
</strong>
|
||||
<span className={badge.className}>{badge.label}</span>
|
||||
</div>
|
||||
<p className={settingsStyles.smsTemplateBody} title={template.body}>
|
||||
{template.body}
|
||||
</p>
|
||||
</div>
|
||||
<div className={settingsStyles.smsTemplateActions}>
|
||||
{status === 'free' ? (
|
||||
<Tooltip label={t('settings.sms.requestVerification')}>
|
||||
<button
|
||||
type="button"
|
||||
className={controlStyles.controlBtn}
|
||||
onClick={() => void handleRequestVerification(template)}
|
||||
disabled={busyId === template.id || submitting}
|
||||
aria-label={t('settings.sms.requestVerification')}
|
||||
>
|
||||
<Send size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip label={t('settings.sms.editTemplate')}>
|
||||
<button
|
||||
type="button"
|
||||
className={controlStyles.controlBtn}
|
||||
onClick={() => openEdit(template)}
|
||||
aria-label={t('settings.sms.editTemplate')}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('settings.sms.deleteTemplate')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${controlStyles.controlBtn} ${controlStyles.danger}`}
|
||||
onClick={() => setRemoveTarget(template)}
|
||||
aria-label={t('settings.sms.deleteTemplate')}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
{editorMounted
|
||||
? createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${modalStyles.overlayNested} ${
|
||||
editorClosing ? modalStyles.overlayOut : modalStyles.overlayIn
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!submitting) setEditorOpen(false)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`${modalStyles.modal} ${modalStyles.modalFormWide} ${
|
||||
editorClosing ? modalStyles.modalOut : modalStyles.modalIn
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="sms-template-editor-title"
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<div className={modalStyles.header}>
|
||||
<div>
|
||||
<h3 id="sms-template-editor-title" className={modalStyles.title}>
|
||||
{editing
|
||||
? t('settings.sms.editTemplate')
|
||||
: t('settings.sms.addTemplate')}
|
||||
</h3>
|
||||
{editing?.isVerified ? (
|
||||
<p className={modalStyles.subtitle}>
|
||||
{t('settings.sms.editClearsVerification')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.closeBtn}
|
||||
onClick={() => setEditorOpen(false)}
|
||||
disabled={submitting}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className={modalStyles.body} onSubmit={(e) => void saveTemplate(e)}>
|
||||
{editorError ? <p className={modalStyles.errorText}>{editorError}</p> : null}
|
||||
<label className={fieldStyles.field}>
|
||||
{t('settings.sms.templateName')}
|
||||
<input
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</label>
|
||||
<div className={`${fieldStyles.field} ${settingsStyles.smsEditorField}`}>
|
||||
<span>{t('settings.sms.templateBody')}</span>
|
||||
<div className={settingsStyles.smsVarChips} role="group" aria-label={t('settings.sms.variablesHint')}>
|
||||
{SMS_TEMPLATE_VARS.map((token) => (
|
||||
<button
|
||||
key={token}
|
||||
type="button"
|
||||
className={settingsStyles.smsVarChip}
|
||||
onClick={() => insertVariable(token)}
|
||||
disabled={submitting || draftBody.length >= MAX_BODY_LEN}
|
||||
dir="ltr"
|
||||
>
|
||||
{token}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
ref={bodyRef}
|
||||
value={draftBody}
|
||||
onChange={(e) => setDraftBody(e.target.value.slice(0, MAX_BODY_LEN))}
|
||||
rows={5}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
<div className={modalStyles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.cancelBtn}
|
||||
onClick={() => setEditorOpen(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" className={modalStyles.submitBtn} disabled={submitting}>
|
||||
{submitting ? t('settings.sms.saving') : t('settings.sms.saveTemplate')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={removeTarget !== null}
|
||||
title={t('settings.sms.deleteTitle')}
|
||||
message={
|
||||
removeTarget
|
||||
? t('settings.sms.deleteMessage', { name: removeTarget.name })
|
||||
: ''
|
||||
}
|
||||
onCancel={() => setRemoveTarget(null)}
|
||||
onConfirm={() => void confirmRemove()}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1132,6 +1132,13 @@ const en = {
|
||||
'customers.smsModal.sending': 'Sending…',
|
||||
'customers.smsModal.error': 'Unable to send SMS.',
|
||||
'customers.smsModal.errorDisabled': 'SMS is disabled. Message was not sent.',
|
||||
'customers.smsModal.template': 'Template',
|
||||
'customers.smsModal.templateNone': 'Custom message (no template)',
|
||||
'customers.smsModal.templateHintVerified':
|
||||
'Verified template — sent from Meshkee service line 5000110005 (sender cannot be changed).',
|
||||
'customers.smsModal.templateHintPending':
|
||||
'Unverified template — sent from advertising line 9000590009. Use as a draft for better UX until approved.',
|
||||
'customers.smsModal.templateLoading': 'Loading templates…',
|
||||
'customers.tickets': 'Tickets',
|
||||
'customers.invoices': 'View invoices',
|
||||
'customers.remove': 'Remove customer',
|
||||
@@ -1683,7 +1690,48 @@ const en = {
|
||||
'website.card.badges.link': 'Manage badges',
|
||||
'website.card.settings.desc': 'Choose how special products are sourced for the website.',
|
||||
'website.card.settings.link': 'Manage settings',
|
||||
'settings.subtitle': 'Configure online payment gateways and checkout payment options.',
|
||||
'settings.subtitle': 'Manage SMS balance and message templates.',
|
||||
'settings.sms.title': 'SMS settings',
|
||||
'settings.sms.desc':
|
||||
'View your prepaid SMS balance and manage message templates. Sender numbers are managed by Meshkee.',
|
||||
'settings.sms.balance': 'SMS balance',
|
||||
'settings.sms.balanceHint':
|
||||
'Balance is in IRT. Each 64 characters costs 400 IRT per recipient (login, orders, customer SMS, etc.). Contact Meshkee to top up.',
|
||||
'settings.sms.credit': 'Account credit',
|
||||
'settings.sms.creditUnavailable': 'Unavailable',
|
||||
'settings.sms.templates': 'Templates',
|
||||
'settings.sms.templatesCounts': '{total} total · {verified} verified',
|
||||
'settings.sms.manageTemplates': 'Manage templates',
|
||||
'settings.sms.addTemplate': 'Add template',
|
||||
'settings.sms.editTemplate': 'Edit template',
|
||||
'settings.sms.deleteTemplate': 'Delete template',
|
||||
'settings.sms.templatesEmpty': 'No templates yet. Create one to reuse when sending SMS.',
|
||||
'settings.sms.variablesHint': 'Variables',
|
||||
'settings.sms.templateName': 'Name',
|
||||
'settings.sms.templateBody': 'Message body',
|
||||
'settings.sms.saveTemplate': 'Save template',
|
||||
'settings.sms.saving': 'Saving…',
|
||||
'settings.sms.loading': 'Loading…',
|
||||
'settings.sms.verified': 'Verified',
|
||||
'settings.sms.pending': 'Pending verification',
|
||||
'settings.sms.badgeFree': 'Free template',
|
||||
'settings.sms.badgePending': 'Waiting for verification',
|
||||
'settings.sms.badgeVerified': 'Verified template',
|
||||
'settings.sms.requestVerification': 'Submit for verification',
|
||||
'settings.sms.requestVerificationDone': 'Submitted for verification.',
|
||||
'settings.sms.requestVerificationError': 'Unable to submit for verification.',
|
||||
'settings.sms.templateRequired': 'Name and body are required.',
|
||||
'settings.sms.templateCreated': 'Template created.',
|
||||
'settings.sms.templateUpdated': 'Template updated.',
|
||||
'settings.sms.templateUpdatedUnverified':
|
||||
'Template updated. Verification was cleared — it must be approved again.',
|
||||
'settings.sms.editClearsVerification':
|
||||
'Saving changes removes verification. The template becomes a free template until Meshkee approves it again.',
|
||||
'settings.sms.templateRemoved': 'Template removed.',
|
||||
'settings.sms.deleteTitle': 'Delete template?',
|
||||
'settings.sms.deleteMessage': 'Delete “{name}”? This cannot be undone.',
|
||||
'settings.sms.errorLoad': 'Unable to load SMS settings.',
|
||||
'settings.sms.errorSave': 'Unable to save.',
|
||||
|
||||
'website.scrollLeft': 'Scroll left',
|
||||
'website.scrollRight': 'Scroll right',
|
||||
@@ -1925,7 +1973,8 @@ const en = {
|
||||
'website.specialItems.pickErrorLoadProducts': 'Unable to load products.',
|
||||
|
||||
'website.settings.title': 'Website settings',
|
||||
'website.settings.subtitle': 'Configure how website content is sourced and displayed.',
|
||||
'website.settings.subtitle':
|
||||
'Configure website content sources, sitemap, and online payment gateways.',
|
||||
'website.settings.loading': 'Loading settings...',
|
||||
'website.settings.errorLoad': 'Unable to load settings.',
|
||||
'website.settings.errorSave': 'Unable to save settings.',
|
||||
@@ -3544,6 +3593,13 @@ const fa: Record<MessageKey, string> = {
|
||||
'customers.smsModal.sending': 'در حال ارسال…',
|
||||
'customers.smsModal.error': 'ارسال پیامک ممکن نشد.',
|
||||
'customers.smsModal.errorDisabled': 'پیامک غیرفعال است. پیام ارسال نشد.',
|
||||
'customers.smsModal.template': 'قالب',
|
||||
'customers.smsModal.templateNone': 'پیام سفارشی (بدون قالب)',
|
||||
'customers.smsModal.templateHintVerified':
|
||||
'قالب تأییدشده — از خط خدماتی مشکی ۵۰۰۰۱۱۰۰۰۵ ارسال میشود (فرستنده قابل تغییر نیست).',
|
||||
'customers.smsModal.templateHintPending':
|
||||
'قالب تأییدنشده — از خط تبلیغاتی ۹۰۰۰۵۹۰۰۰۹ ارسال میشود. تا زمان تأیید، فقط برای راحتی نوشتن استفاده کنید.',
|
||||
'customers.smsModal.templateLoading': 'در حال بارگذاری قالبها…',
|
||||
'customers.tickets': 'تیکتها',
|
||||
'customers.invoices': 'مشاهده فاکتورها',
|
||||
'customers.remove': 'حذف مشتری',
|
||||
@@ -4096,7 +4152,48 @@ const fa: Record<MessageKey, string> = {
|
||||
'website.card.badges.link': 'مدیریت نشانها',
|
||||
'website.card.settings.desc': 'منبع خواندن محصولات ویژه وبسایت را انتخاب کنید.',
|
||||
'website.card.settings.link': 'مدیریت تنظیمات',
|
||||
'settings.subtitle': 'درگاههای پرداخت آنلاین و گزینههای تسویه حساب را پیکربندی کنید.',
|
||||
'settings.subtitle': 'اعتبار پیامک و قالبهای پیام را مدیریت کنید.',
|
||||
'settings.sms.title': 'تنظیمات پیامک',
|
||||
'settings.sms.desc':
|
||||
'اعتبار پیشپرداخت پیامک و قالبهای پیام را ببینید. شماره فرستنده توسط مشکی مدیریت میشود.',
|
||||
'settings.sms.balance': 'اعتبار پیامک',
|
||||
'settings.sms.balanceHint':
|
||||
'اعتبار به تومان (IRT) است. هر ۶۴ کاراکتر برای هر گیرنده ۴۰۰ تومان هزینه دارد (ورود، سفارش، پیامک مشتری و غیره). برای افزایش با مشکی تماس بگیرید.',
|
||||
'settings.sms.credit': 'اعتبار حساب',
|
||||
'settings.sms.creditUnavailable': 'در دسترس نیست',
|
||||
'settings.sms.templates': 'قالبها',
|
||||
'settings.sms.templatesCounts': '{total} قالب · {verified} تأییدشده',
|
||||
'settings.sms.manageTemplates': 'مدیریت قالبها',
|
||||
'settings.sms.addTemplate': 'افزودن قالب',
|
||||
'settings.sms.editTemplate': 'ویرایش قالب',
|
||||
'settings.sms.deleteTemplate': 'حذف قالب',
|
||||
'settings.sms.templatesEmpty': 'هنوز قالبی نیست. برای استفاده هنگام ارسال پیامک یکی بسازید.',
|
||||
'settings.sms.variablesHint': 'متغیرها',
|
||||
'settings.sms.templateName': 'نام',
|
||||
'settings.sms.templateBody': 'متن پیام',
|
||||
'settings.sms.saveTemplate': 'ذخیره قالب',
|
||||
'settings.sms.saving': 'در حال ذخیره…',
|
||||
'settings.sms.loading': 'در حال بارگذاری…',
|
||||
'settings.sms.verified': 'تأییدشده',
|
||||
'settings.sms.pending': 'در انتظار تأیید',
|
||||
'settings.sms.badgeFree': 'قالب آزاد',
|
||||
'settings.sms.badgePending': 'در انتظار تأیید',
|
||||
'settings.sms.badgeVerified': 'قالب تایید شده',
|
||||
'settings.sms.requestVerification': 'ارسال برای تأیید',
|
||||
'settings.sms.requestVerificationDone': 'برای تأیید ارسال شد.',
|
||||
'settings.sms.requestVerificationError': 'ارسال برای تأیید ممکن نشد.',
|
||||
'settings.sms.templateRequired': 'نام و متن الزامی است.',
|
||||
'settings.sms.templateCreated': 'قالب ایجاد شد.',
|
||||
'settings.sms.templateUpdated': 'قالب بهروز شد.',
|
||||
'settings.sms.templateUpdatedUnverified':
|
||||
'قالب بهروز شد. تأیید لغو شد — باید دوباره تأیید شود.',
|
||||
'settings.sms.editClearsVerification':
|
||||
'ذخیره تغییرات، تأیید را لغو میکند. تا تأیید مجدد مشکی، این قالب بهعنوان قالب آزاد ارسال میشود.',
|
||||
'settings.sms.templateRemoved': 'قالب حذف شد.',
|
||||
'settings.sms.deleteTitle': 'حذف قالب؟',
|
||||
'settings.sms.deleteMessage': '«{name}» حذف شود؟ این کار قابل بازگشت نیست.',
|
||||
'settings.sms.errorLoad': 'بارگذاری تنظیمات پیامک ممکن نشد.',
|
||||
'settings.sms.errorSave': 'ذخیره ممکن نشد.',
|
||||
|
||||
'website.scrollLeft': 'اسکرول به چپ',
|
||||
'website.scrollRight': 'اسکرول به راست',
|
||||
@@ -4336,7 +4433,8 @@ const fa: Record<MessageKey, string> = {
|
||||
'website.specialItems.pickErrorLoadProducts': 'بارگذاری محصولات ممکن نشد.',
|
||||
|
||||
'website.settings.title': 'تنظیمات وبسایت',
|
||||
'website.settings.subtitle': 'نحوه منبعگیری و نمایش محتوای وبسایت را پیکربندی کنید.',
|
||||
'website.settings.subtitle':
|
||||
'منبع محتوای وبسایت، نقشه سایت و درگاههای پرداخت آنلاین را پیکربندی کنید.',
|
||||
'website.settings.loading': 'در حال بارگذاری تنظیمات...',
|
||||
'website.settings.errorLoad': 'بارگذاری تنظیمات ممکن نشد.',
|
||||
'website.settings.errorSave': 'ذخیره تنظیمات ممکن نشد.',
|
||||
|
||||
@@ -188,6 +188,315 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
min-height: var(--field-height);
|
||||
padding: 0 16px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--field-font-size);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-ui);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cancelBtn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.smsCreditRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.smsCreditRowOk {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
border-color: rgba(var(--primary-rgb) / 0.28);
|
||||
}
|
||||
|
||||
.smsCreditRowOk .smsCreditLabel,
|
||||
.smsCreditRowOk .smsCreditValue {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.smsCreditRowLow {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.28);
|
||||
}
|
||||
|
||||
.smsCreditRowLow .smsCreditLabel,
|
||||
.smsCreditRowLow .smsCreditValue {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.smsCreditLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.smsCreditValue {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.smsTemplatesSummary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.smsTemplatesSummaryText {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.smsTemplatesCounts {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.smsTemplatesHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.smsTemplatesHeader .saveBtn,
|
||||
.smsTemplatesSummary .saveBtn,
|
||||
.smsModalToolbar .saveBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.smsTemplatesTitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.smsModalToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.smsEditorField {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.smsEditorField > span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.smsVarChips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.smsVarChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(var(--primary-rgb) / 0.28);
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-ui);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.smsVarChip:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-rgb) / 0.16);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.smsVarChip:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.smsTemplatesModal {
|
||||
width: min(960px, calc(100vw - 40px));
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.smsTemplateList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.smsTemplateRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.smsTemplateMeta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.smsTemplateTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.smsTemplateName {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: min(420px, 100%);
|
||||
}
|
||||
|
||||
.smsTemplateBody {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.smsTemplateActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.smsBadgeFree,
|
||||
.smsBadgePending,
|
||||
.smsBadgeVerified {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.smsBadgeFree {
|
||||
background: rgba(var(--primary-rgb) / 0.12);
|
||||
color: var(--primary);
|
||||
border: 1px solid rgba(var(--primary-rgb) / 0.28);
|
||||
}
|
||||
|
||||
.smsBadgePending {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
color: #b45309;
|
||||
border: 1px solid rgba(245, 158, 11, 0.35);
|
||||
}
|
||||
|
||||
.smsBadgeVerified {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: #047857;
|
||||
border: 1px solid rgba(16, 185, 129, 0.32);
|
||||
}
|
||||
|
||||
.smsEditor {
|
||||
margin-top: 14px;
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.smsEditorActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.smsTemplatesSummary {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.smsTemplatesSummary .saveBtn,
|
||||
.smsModalToolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.smsModalToolbar .saveBtn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.smsSenderForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.smsSenderForm .saveBtn {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.comingSoonGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
@@ -1,231 +1,12 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { Switch } from '../components/Switch'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { SmsSettingsSection } from '../components/SmsSettingsSection'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { BusinessMessageKey } from '../i18n/messages'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
DEFAULT_EPAYMENT_SETTINGS,
|
||||
getSettings,
|
||||
updateStoreSettings,
|
||||
type EPaymentSettings,
|
||||
type PaymentGatewayId,
|
||||
} from '../services/settingsService'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import sharedStyles from './ProductSettingsPage.module.css'
|
||||
import fieldStyles from './AddNewProductPage.module.css'
|
||||
import styles from './SettingsPage.module.css'
|
||||
|
||||
const COMING_SOON_GATEWAYS: {
|
||||
id: Exclude<PaymentGatewayId, 'mellat' | 'zarinpal'>
|
||||
nameKey: BusinessMessageKey
|
||||
descKey: BusinessMessageKey
|
||||
}[] = [
|
||||
{
|
||||
id: 'sep',
|
||||
nameKey: 'website.ePayment.gateway.sep',
|
||||
descKey: 'website.ePayment.gateway.sepDesc',
|
||||
},
|
||||
{
|
||||
id: 'snappay',
|
||||
nameKey: 'website.ePayment.gateway.snappay',
|
||||
descKey: 'website.ePayment.gateway.snappayDesc',
|
||||
},
|
||||
{
|
||||
id: 'digipay',
|
||||
nameKey: 'website.ePayment.gateway.digipay',
|
||||
descKey: 'website.ePayment.gateway.digipayDesc',
|
||||
},
|
||||
]
|
||||
|
||||
function nextDefaultGateway(
|
||||
current: EPaymentSettings,
|
||||
gatewayId: PaymentGatewayId,
|
||||
enabled: boolean,
|
||||
): PaymentGatewayId | null {
|
||||
if (enabled && !current.defaultGateway) return gatewayId
|
||||
if (!enabled && current.defaultGateway === gatewayId) {
|
||||
if (gatewayId !== 'mellat' && current.gateways.mellat.enabled) return 'mellat'
|
||||
if (gatewayId !== 'zarinpal' && current.gateways.zarinpal.enabled) {
|
||||
return 'zarinpal'
|
||||
}
|
||||
return null
|
||||
}
|
||||
return current.defaultGateway
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const t = useT()
|
||||
const { showToast } = useToast()
|
||||
const [ePayment, setEPayment] = useState<EPaymentSettings>(DEFAULT_EPAYMENT_SETTINGS)
|
||||
const [passwordDraft, setPasswordDraft] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadSettings(controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
async function loadSettings(signal?: AbortSignal) {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await getSettings(signal)
|
||||
setEPayment(data.settings.store.ePayment ?? DEFAULT_EPAYMENT_SETTINGS)
|
||||
setPasswordDraft('')
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('productSettings.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function patchEPayment(
|
||||
saving: string,
|
||||
next: EPaymentSettings,
|
||||
password?: string,
|
||||
) {
|
||||
setSavingKey(saving)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const payload: EPaymentSettings = {
|
||||
enabled: next.enabled,
|
||||
defaultGateway: next.defaultGateway,
|
||||
gateways: {
|
||||
mellat: {
|
||||
enabled: next.gateways.mellat.enabled,
|
||||
terminalId: next.gateways.mellat.terminalId,
|
||||
username: next.gateways.mellat.username,
|
||||
password: password ?? '',
|
||||
},
|
||||
sep: { enabled: next.gateways.sep.enabled },
|
||||
snappay: { enabled: next.gateways.snappay.enabled },
|
||||
digipay: { enabled: next.gateways.digipay.enabled },
|
||||
zarinpal: {
|
||||
enabled: next.gateways.zarinpal.enabled,
|
||||
merchantId: next.gateways.zarinpal.merchantId.trim(),
|
||||
sandbox: next.gateways.zarinpal.sandbox,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const data = await updateStoreSettings({ ePayment: payload })
|
||||
setEPayment(data.settings.store.ePayment ?? DEFAULT_EPAYMENT_SETTINGS)
|
||||
if (password) setPasswordDraft('')
|
||||
showToast(t('productSettings.saved'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('productSettings.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setSavingKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMasterToggle(checked: boolean) {
|
||||
await patchEPayment('master', {
|
||||
...ePayment,
|
||||
enabled: checked,
|
||||
defaultGateway: checked
|
||||
? ePayment.defaultGateway ??
|
||||
(ePayment.gateways.mellat.enabled
|
||||
? 'mellat'
|
||||
: ePayment.gateways.zarinpal.enabled
|
||||
? 'zarinpal'
|
||||
: 'mellat')
|
||||
: ePayment.defaultGateway,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleMellatToggle(checked: boolean) {
|
||||
await patchEPayment('mellat-enabled', {
|
||||
...ePayment,
|
||||
// Turning a gateway on implies online payments are allowed at checkout.
|
||||
enabled: checked ? true : ePayment.enabled,
|
||||
gateways: {
|
||||
...ePayment.gateways,
|
||||
mellat: { ...ePayment.gateways.mellat, enabled: checked },
|
||||
},
|
||||
defaultGateway: nextDefaultGateway(ePayment, 'mellat', checked),
|
||||
})
|
||||
}
|
||||
|
||||
async function handleZarinpalToggle(checked: boolean) {
|
||||
await patchEPayment('zarinpal-enabled', {
|
||||
...ePayment,
|
||||
enabled: checked ? true : ePayment.enabled,
|
||||
gateways: {
|
||||
...ePayment.gateways,
|
||||
zarinpal: { ...ePayment.gateways.zarinpal, enabled: checked },
|
||||
},
|
||||
defaultGateway: nextDefaultGateway(ePayment, 'zarinpal', checked),
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSaveMellatCredentials(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
const terminalId = ePayment.gateways.mellat.terminalId.trim()
|
||||
const username = ePayment.gateways.mellat.username.trim()
|
||||
|
||||
if (ePayment.gateways.mellat.enabled && (!terminalId || !username)) {
|
||||
setError(t('website.ePayment.error.credentials'))
|
||||
return
|
||||
}
|
||||
|
||||
await patchEPayment(
|
||||
'mellat-credentials',
|
||||
{
|
||||
...ePayment,
|
||||
gateways: {
|
||||
...ePayment.gateways,
|
||||
mellat: {
|
||||
...ePayment.gateways.mellat,
|
||||
terminalId,
|
||||
username,
|
||||
},
|
||||
},
|
||||
},
|
||||
passwordDraft.trim() || undefined,
|
||||
)
|
||||
}
|
||||
|
||||
async function handleSaveZarinpalCredentials(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
const merchantId = ePayment.gateways.zarinpal.merchantId.trim()
|
||||
|
||||
if (ePayment.gateways.zarinpal.enabled && !merchantId) {
|
||||
setError(t('website.ePayment.error.zarinpalMerchant'))
|
||||
return
|
||||
}
|
||||
|
||||
await patchEPayment('zarinpal-credentials', {
|
||||
...ePayment,
|
||||
gateways: {
|
||||
...ePayment.gateways,
|
||||
zarinpal: {
|
||||
...ePayment.gateways.zarinpal,
|
||||
merchantId,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
@@ -243,249 +24,9 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className={sharedStyles.alertError}>{error}</div> : null}
|
||||
|
||||
{isLoading ? (
|
||||
<p className={sharedStyles.status}>{t('productSettings.loading')}</p>
|
||||
) : (
|
||||
<div className={styles.stack}>
|
||||
<section className={sharedStyles.panel}>
|
||||
<h3 className={sharedStyles.sectionTitle}>{t('title.ePayment')}</h3>
|
||||
<div className={sharedStyles.list}>
|
||||
<div className={sharedStyles.row}>
|
||||
<div className={sharedStyles.rowText}>
|
||||
<label className={sharedStyles.rowLabel} htmlFor="epayment-master">
|
||||
{t('website.ePayment.master')}
|
||||
</label>
|
||||
<p className={sharedStyles.rowDescription}>
|
||||
{t('website.ePayment.masterDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="epayment-master"
|
||||
checked={ePayment.enabled}
|
||||
disabled={savingKey !== null}
|
||||
onChange={(checked) => void handleMasterToggle(checked)}
|
||||
/>
|
||||
</div>
|
||||
{!ePayment.enabled &&
|
||||
(ePayment.gateways.mellat.enabled || ePayment.gateways.zarinpal.enabled) ? (
|
||||
<p className={styles.masterOffHint} role="status">
|
||||
{t('website.ePayment.masterOffHint')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.gatewayCard} data-card-hover>
|
||||
<div className={styles.gatewayHeader}>
|
||||
<div>
|
||||
<h3 className={styles.gatewayTitle}>
|
||||
{t('website.ePayment.gateway.mellat')}
|
||||
</h3>
|
||||
<p className={styles.gatewayDesc}>
|
||||
{t('website.ePayment.gateway.mellatDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="epayment-mellat"
|
||||
checked={ePayment.gateways.mellat.enabled}
|
||||
disabled={savingKey !== null}
|
||||
onChange={(checked) => void handleMellatToggle(checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className={`${styles.credentials} ${
|
||||
!ePayment.gateways.mellat.enabled ? styles.credentialsDisabled : ''
|
||||
}`}
|
||||
onSubmit={handleSaveMellatCredentials}
|
||||
aria-disabled={!ePayment.gateways.mellat.enabled}
|
||||
>
|
||||
<div className={`${fieldStyles.field} ${styles.field}`}>
|
||||
<label htmlFor="mellat-terminal">
|
||||
{t('website.ePayment.terminalId')}
|
||||
</label>
|
||||
<input
|
||||
id="mellat-terminal"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
dir="ltr"
|
||||
value={ePayment.gateways.mellat.terminalId}
|
||||
disabled={savingKey !== null || !ePayment.gateways.mellat.enabled}
|
||||
onChange={(e) =>
|
||||
setEPayment((current) => ({
|
||||
...current,
|
||||
gateways: {
|
||||
...current.gateways,
|
||||
mellat: {
|
||||
...current.gateways.mellat,
|
||||
terminalId: e.target.value,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${fieldStyles.field} ${styles.field}`}>
|
||||
<label htmlFor="mellat-username">
|
||||
{t('website.ePayment.username')}
|
||||
</label>
|
||||
<input
|
||||
id="mellat-username"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
dir="ltr"
|
||||
value={ePayment.gateways.mellat.username}
|
||||
disabled={savingKey !== null || !ePayment.gateways.mellat.enabled}
|
||||
onChange={(e) =>
|
||||
setEPayment((current) => ({
|
||||
...current,
|
||||
gateways: {
|
||||
...current.gateways,
|
||||
mellat: {
|
||||
...current.gateways.mellat,
|
||||
username: e.target.value,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${fieldStyles.field} ${styles.field}`}>
|
||||
<label htmlFor="mellat-password">
|
||||
{t('website.ePayment.password')}
|
||||
</label>
|
||||
<div className={styles.passwordWrap}>
|
||||
<input
|
||||
id="mellat-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
dir="ltr"
|
||||
placeholder={t('website.ePayment.passwordPlaceholderSet')}
|
||||
value={passwordDraft}
|
||||
disabled={savingKey !== null || !ePayment.gateways.mellat.enabled}
|
||||
onChange={(e) => setPasswordDraft(e.target.value)}
|
||||
/>
|
||||
<Tooltip
|
||||
label={
|
||||
showPassword
|
||||
? t('website.ePayment.hidePassword')
|
||||
: t('website.ePayment.showPassword')
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.passwordToggle}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
disabled={savingKey !== null || !ePayment.gateways.mellat.enabled}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t('website.ePayment.hidePassword')
|
||||
: t('website.ePayment.showPassword')
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actionsInline}>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.saveBtn}
|
||||
disabled={savingKey !== null || !ePayment.gateways.mellat.enabled}
|
||||
>
|
||||
{savingKey === 'mellat-credentials'
|
||||
? t('storeSettings.saving')
|
||||
: t('website.ePayment.saveCredentials')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className={styles.gatewayCard} data-card-hover>
|
||||
<div className={styles.gatewayHeader}>
|
||||
<div>
|
||||
<h3 className={styles.gatewayTitle}>
|
||||
{t('website.ePayment.gateway.zarinpal')}
|
||||
</h3>
|
||||
<p className={styles.gatewayDesc}>
|
||||
{t('website.ePayment.gateway.zarinpalDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="epayment-zarinpal"
|
||||
checked={ePayment.gateways.zarinpal.enabled}
|
||||
disabled={savingKey !== null}
|
||||
onChange={(checked) => void handleZarinpalToggle(checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className={`${styles.credentialsInline} ${
|
||||
!ePayment.gateways.zarinpal.enabled ? styles.credentialsDisabled : ''
|
||||
}`}
|
||||
onSubmit={handleSaveZarinpalCredentials}
|
||||
aria-disabled={!ePayment.gateways.zarinpal.enabled}
|
||||
>
|
||||
<label className={styles.inlineLabel} htmlFor="zarinpal-merchant">
|
||||
{t('website.ePayment.merchantId')}
|
||||
</label>
|
||||
<input
|
||||
id="zarinpal-merchant"
|
||||
className={styles.inlineInput}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
dir="ltr"
|
||||
spellCheck={false}
|
||||
placeholder={t('website.ePayment.merchantIdPlaceholder')}
|
||||
value={ePayment.gateways.zarinpal.merchantId}
|
||||
disabled={savingKey !== null || !ePayment.gateways.zarinpal.enabled}
|
||||
onChange={(e) =>
|
||||
setEPayment((current) => ({
|
||||
...current,
|
||||
gateways: {
|
||||
...current.gateways,
|
||||
zarinpal: {
|
||||
...current.gateways.zarinpal,
|
||||
merchantId: e.target.value,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.saveBtn}
|
||||
disabled={savingKey !== null || !ePayment.gateways.zarinpal.enabled}
|
||||
>
|
||||
{savingKey === 'zarinpal-credentials'
|
||||
? t('storeSettings.saving')
|
||||
: t('website.ePayment.saveCredentials')}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div className={styles.comingSoonGrid}>
|
||||
{COMING_SOON_GATEWAYS.map((gateway) => (
|
||||
<section key={gateway.id} className={styles.comingSoonCard}>
|
||||
<div className={styles.gatewayHeader}>
|
||||
<div>
|
||||
<h3 className={styles.gatewayTitle}>{t(gateway.nameKey)}</h3>
|
||||
<p className={styles.gatewayDesc}>{t(gateway.descKey)}</p>
|
||||
</div>
|
||||
<span className={styles.badge}>{t('website.ePayment.comingSoon')}</span>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.stack}>
|
||||
<SmsSettingsSection />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -98,6 +98,10 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ePaymentBlock {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.field,
|
||||
.sitemapActions {
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
} from '../services/settingsService'
|
||||
import { syncWebsiteSitemapConfig } from '../services/websiteSitemapService'
|
||||
import { hasBusinessModule, type BusinessModuleId } from '../utils/businessModules'
|
||||
import { EPaymentSettingsSection } from '../components/EPaymentSettingsSection'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import sharedStyles from './ProductSettingsPage.module.css'
|
||||
import settingsStyles from './SettingsPage.module.css'
|
||||
import styles from './WebsiteSettingsPage.module.css'
|
||||
|
||||
const SOURCE_OPTIONS: SpecialProductsSource[] = ['store_item', 'product']
|
||||
@@ -216,6 +218,10 @@ export function WebsiteSettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className={`${styles.ePaymentBlock} ${settingsStyles.stack}`}>
|
||||
<EPaymentSettingsSection />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -193,22 +193,34 @@ export interface SendCustomersBulkSmsResult {
|
||||
source?: string
|
||||
}
|
||||
|
||||
export async function sendCustomerSms(userId: string, message: string) {
|
||||
export async function sendCustomerSms(
|
||||
userId: string,
|
||||
message: string,
|
||||
templateId?: string | null,
|
||||
) {
|
||||
return apiRequest<SendCustomerSmsResult>(businessPath(`/${userId}/sms`), {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: { message },
|
||||
body: {
|
||||
message,
|
||||
...(templateId ? { templateId } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Bulk SMS via Gama SendBulk. Omit userIds (or pass []) to send to all enabled customers. */
|
||||
export async function sendCustomersBulkSms(message: string, userIds?: string[]) {
|
||||
export async function sendCustomersBulkSms(
|
||||
message: string,
|
||||
userIds?: string[],
|
||||
templateId?: string | null,
|
||||
) {
|
||||
return apiRequest<SendCustomersBulkSmsResult>(businessPath('/sms'), {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: {
|
||||
message,
|
||||
...(userIds && userIds.length > 0 ? { userIds } : {}),
|
||||
...(templateId ? { templateId } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -107,12 +107,17 @@ export interface WebsiteSettings {
|
||||
sitemapConfig?: WebsiteSitemapConfig | null
|
||||
}
|
||||
|
||||
export interface SmsSettings {
|
||||
senderNumber: string | null
|
||||
}
|
||||
|
||||
export interface BusinessSettings {
|
||||
branding: BrandingSettings
|
||||
dashboard: DashboardSettings
|
||||
store: StoreSettings
|
||||
modules?: ModulesSettings
|
||||
website?: WebsiteSettings
|
||||
sms?: SmsSettings
|
||||
}
|
||||
|
||||
export interface SettingsResponse {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { apiRequest } from '../lib/api'
|
||||
import { getActiveBusinessId } from '../lib/businessContext'
|
||||
|
||||
export type SmsTemplateVerificationStatus = 'free' | 'pending' | 'verified'
|
||||
|
||||
export interface SmsTemplate {
|
||||
id: string
|
||||
businessId: string
|
||||
name: string
|
||||
body: string
|
||||
verificationStatus: SmsTemplateVerificationStatus
|
||||
isVerified: boolean
|
||||
verifiedAt: string | null
|
||||
verifiedBy: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
function smsPath(suffix = '') {
|
||||
const businessId = getActiveBusinessId()
|
||||
if (!businessId) {
|
||||
throw new Error('No active business selected. Please sign in again.')
|
||||
}
|
||||
return `/businesses/${businessId}/sms${suffix}`
|
||||
}
|
||||
|
||||
export async function listSmsTemplates(signal?: AbortSignal) {
|
||||
return apiRequest<{ items: SmsTemplate[]; variables: string[] }>(
|
||||
smsPath('/templates'),
|
||||
{ auth: true, signal },
|
||||
)
|
||||
}
|
||||
|
||||
export async function createSmsTemplate(payload: { name: string; body: string }) {
|
||||
return apiRequest<{ template: SmsTemplate }>(smsPath('/templates'), {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateSmsTemplate(
|
||||
templateId: string,
|
||||
payload: { name?: string; body?: string },
|
||||
) {
|
||||
return apiRequest<{ template: SmsTemplate }>(smsPath(`/templates/${templateId}`), {
|
||||
method: 'PATCH',
|
||||
auth: true,
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export async function requestSmsTemplateVerification(templateId: string) {
|
||||
return apiRequest<{ template: SmsTemplate }>(
|
||||
smsPath(`/templates/${templateId}/request-verification`),
|
||||
{ method: 'POST', auth: true },
|
||||
)
|
||||
}
|
||||
|
||||
export async function deleteSmsTemplate(templateId: string) {
|
||||
return apiRequest<{ success: boolean }>(smsPath(`/templates/${templateId}`), {
|
||||
method: 'DELETE',
|
||||
auth: true,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSmsCredit(signal?: AbortSignal) {
|
||||
return apiRequest<{
|
||||
balance: number
|
||||
/** @deprecated use balance */
|
||||
credit: number | null
|
||||
currency: string | null
|
||||
message: string | null
|
||||
}>(smsPath('/credit'), { auth: true, signal })
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
+12
-3
@@ -315,9 +315,18 @@ Products accept optional `brandId` on create/update.
|
||||
- `GET /businesses/:businessId/customers/search` — autocomplete for order cart
|
||||
- `GET /businesses/:businessId/customers/export` — Excel (.xlsx) of business customers (`phone`, `first_name`, `last_name`)
|
||||
- `POST /businesses/:businessId/customers/import` — Excel import for SMS lists; only `phone` required; new users get a random password hash (OTP login still works); existing users are linked; already-linked / staff rows skipped
|
||||
- `POST /businesses/:businessId/customers/:userId/sms` — single SMS via Gama **SendQuick**; uses `settings.sms.senderNumber` or advertising shortcode `5000990009`
|
||||
- `POST /businesses/:businessId/customers/sms` — bulk SMS via Gama **SendBulk** (`destinations[]`); body `{ message, userIds? }` — empty/omitted `userIds` → all enabled customers; selected ids → those recipients
|
||||
- Super Admin business edit: optional **SMS sender number** stored in `businesses.settings.sms.senderNumber`
|
||||
- `POST /businesses/:businessId/customers/:userId/sms` — single SMS via Gama **SendQuick**; body `{ message, templateId? }`
|
||||
- `POST /businesses/:businessId/customers/sms` — bulk SMS via Gama **SendBulk** (or per-recipient when template/vars); body `{ message, userIds?, templateId? }` — empty/omitted `userIds` → all enabled customers
|
||||
- Free-text SMS source: `settings.sms.senderNumber` (Super Admin only) or advertising shortcode `9000590009`
|
||||
- Verified SMS template → service shortcode `5000110005` (fixed); unverified template → `9000590009`
|
||||
- Per-business prepaid `businesses.sms_balance` (IRT) — all Meshkee SMS (OTP login/forgot-password, customer SMS, partner/public SMS for linked domains) costs **400 IRT per 64 characters** per recipient (`ceil(len/64)*400`). Insufficient balance blocks send.
|
||||
- New businesses created in Super Admin start with **500,000 IRT** SMS balance
|
||||
- External domains (no Meshkee website) still bill via their business row once the domain is linked
|
||||
- Business Settings → **SMS settings**: balance (read-only) + template CRUD
|
||||
- Super Admin business overflow → **SMS settings** modal: sender number + SMS balance top-up
|
||||
- Super Admin → **SMS Templates** (`/sms-templates`): verify / unverify
|
||||
- Online payment gateways live under **Website → Settings** (not main Settings)
|
||||
- Template vars: `{{user.name}}`, `{{user.lastname}}`, `{{user.title}}`
|
||||
|
||||
### Orders
|
||||
- `GET/POST/PATCH/DELETE /businesses/:businessId/orders`
|
||||
|
||||
Reference in New Issue
Block a user