Files
dashboards/apps/business/src/components/EPaymentSettingsSection.tsx
T
Alireza HassaniandCursor 0eb19bbebd 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>
2026-09-08 16:24:26 +03:30

466 lines
15 KiB
TypeScript

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>
)
}