Gateway config now lives on the general Settings page, with Mellat fields in a 3-column layout and ZarinPal available at checkout. Co-authored-by: Cursor <cursoragent@cursor.com>
515 lines
17 KiB
TypeScript
515 lines
17 KiB
TypeScript
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 { 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 = {
|
|
...next,
|
|
gateways: {
|
|
...next.gateways,
|
|
mellat: {
|
|
...next.gateways.mellat,
|
|
password: password ?? '',
|
|
},
|
|
zarinpal: {
|
|
...next.gateways.zarinpal,
|
|
merchantId: next.gateways.zarinpal.merchantId.trim(),
|
|
},
|
|
},
|
|
}
|
|
|
|
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,
|
|
gateways: {
|
|
...ePayment.gateways,
|
|
mellat: { ...ePayment.gateways.mellat, enabled: checked },
|
|
},
|
|
defaultGateway: nextDefaultGateway(ePayment, 'mellat', checked),
|
|
})
|
|
}
|
|
|
|
async function handleZarinpalToggle(checked: boolean) {
|
|
await patchEPayment('zarinpal-enabled', {
|
|
...ePayment,
|
|
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
|
|
}
|
|
|
|
if (
|
|
ePayment.gateways.mellat.enabled &&
|
|
!ePayment.gateways.mellat.passwordSet &&
|
|
!passwordDraft.trim()
|
|
) {
|
|
setError(t('website.ePayment.error.passwordRequired'))
|
|
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}>
|
|
<Breadcrumbs
|
|
items={[
|
|
{ label: 'Dashboard', href: '/' },
|
|
{ label: 'Settings' },
|
|
]}
|
|
/>
|
|
|
|
<div className={pageStyles.pageHeader}>
|
|
<div>
|
|
<PageTitle en="SETTINGS">{t('title.settings')}</PageTitle>
|
|
<p className={pageStyles.pageSubtitle}>{t('settings.subtitle')}</p>
|
|
</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>
|
|
</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 || !ePayment.enabled}
|
|
onChange={(checked) => void handleMellatToggle(checked)}
|
|
/>
|
|
</div>
|
|
|
|
<form className={styles.credentials} onSubmit={handleSaveMellatCredentials}>
|
|
<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}
|
|
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}
|
|
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')}
|
|
{ePayment.gateways.mellat.passwordSet ? (
|
|
<span className={styles.hint}>
|
|
{' '}
|
|
({t('website.ePayment.passwordSet')})
|
|
</span>
|
|
) : null}
|
|
</label>
|
|
<div className={styles.passwordWrap}>
|
|
<input
|
|
id="mellat-password"
|
|
type={showPassword ? 'text' : 'password'}
|
|
autoComplete="new-password"
|
|
dir="ltr"
|
|
placeholder={
|
|
ePayment.gateways.mellat.passwordSet
|
|
? t('website.ePayment.passwordPlaceholderSet')
|
|
: t('website.ePayment.passwordPlaceholder')
|
|
}
|
|
value={passwordDraft}
|
|
disabled={savingKey !== null}
|
|
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)}
|
|
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.actions}>
|
|
<button
|
|
type="submit"
|
|
className={styles.saveBtn}
|
|
disabled={savingKey !== null}
|
|
>
|
|
{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 || !ePayment.enabled}
|
|
onChange={(checked) => void handleZarinpalToggle(checked)}
|
|
/>
|
|
</div>
|
|
|
|
<form className={styles.credentials} onSubmit={handleSaveZarinpalCredentials}>
|
|
<div className={`${fieldStyles.field} ${styles.field}`}>
|
|
<label htmlFor="zarinpal-merchant">
|
|
{t('website.ePayment.merchantId')}
|
|
</label>
|
|
<input
|
|
id="zarinpal-merchant"
|
|
type="text"
|
|
autoComplete="off"
|
|
dir="ltr"
|
|
spellCheck={false}
|
|
placeholder={t('website.ePayment.merchantIdPlaceholder')}
|
|
value={ePayment.gateways.zarinpal.merchantId}
|
|
disabled={savingKey !== null}
|
|
onChange={(e) =>
|
|
setEPayment((current) => ({
|
|
...current,
|
|
gateways: {
|
|
...current.gateways,
|
|
zarinpal: {
|
|
...current.gateways.zarinpal,
|
|
merchantId: e.target.value,
|
|
},
|
|
},
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className={`${sharedStyles.row} ${styles.fullRow}`}>
|
|
<div className={sharedStyles.rowText}>
|
|
<label className={sharedStyles.rowLabel} htmlFor="zarinpal-sandbox">
|
|
{t('website.ePayment.sandbox')}
|
|
</label>
|
|
<p className={sharedStyles.rowDescription}>
|
|
{t('website.ePayment.sandboxDesc')}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="zarinpal-sandbox"
|
|
checked={ePayment.gateways.zarinpal.sandbox}
|
|
disabled={savingKey !== null}
|
|
onChange={(checked) =>
|
|
setEPayment((current) => ({
|
|
...current,
|
|
gateways: {
|
|
...current.gateways,
|
|
zarinpal: {
|
|
...current.gateways.zarinpal,
|
|
sandbox: checked,
|
|
},
|
|
},
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className={styles.actions}>
|
|
<button
|
|
type="submit"
|
|
className={styles.saveBtn}
|
|
disabled={savingKey !== null}
|
|
>
|
|
{savingKey === 'zarinpal-credentials'
|
|
? t('storeSettings.saving')
|
|
: t('website.ePayment.saveCredentials')}
|
|
</button>
|
|
</div>
|
|
</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>
|
|
)}
|
|
</main>
|
|
)
|
|
}
|