Add SMS templates UI, prepaid balance settings, and list filter layout.

Business settings manage templates and balance; Super Admin verifies templates and tops up via SMS settings; filters use a separate panel with even field gaps.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-08 16:24:26 +03:30
co-authored by Cursor
parent 8f0b4cef85
commit 0eb19bbebd
27 changed files with 2324 additions and 527 deletions
+1 -1
View File
@@ -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>
)
}
+102 -4
View File
@@ -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));
+4 -463
View File
@@ -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>
)
}
+15 -3
View File
@@ -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 })
}