Move e-payment to Settings and add ZarinPal credentials.

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>
This commit is contained in:
Alireza Hassani
2026-08-16 01:45:41 +03:30
co-authored by Cursor
parent 9a59bc8f65
commit e79211e3cd
35 changed files with 614 additions and 212 deletions
+4
View File
@@ -40,6 +40,8 @@ const sections: {
countLabelKey?: BusinessMessageKey
href: string
countKey?: CountKey
/** Double-width tile (4 of 6 home columns). */
wide?: boolean
/** When set, card is shown only if this optional module is enabled. */
moduleId?: BusinessModuleId
/** When set, card is shown if any listed module is enabled. */
@@ -63,6 +65,7 @@ const sections: {
countLabelKey: 'home.card.store.count',
href: '/store',
countKey: 'store',
wide: true,
moduleId: 'store',
},
{
@@ -185,6 +188,7 @@ export function HomePage() {
description={t(section.descKey)}
linkText={t(section.linkKey)}
href={section.href}
className={section.wide ? styles.span4 : undefined}
count={section.countKey ? counts[section.countKey] : undefined}
countLabel={section.countLabelKey ? t(section.countLabelKey) : undefined}
/>
@@ -42,7 +42,7 @@
.credentials {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 12px 16px;
margin-top: 18px;
padding-top: 18px;
@@ -50,10 +50,11 @@
}
.field {
grid-column: span 3;
min-width: 0;
}
.field:last-of-type {
.fullRow {
grid-column: 1 / -1;
}
@@ -142,6 +143,10 @@
grid-template-columns: 1fr;
}
.field {
grid-column: 1 / -1;
}
.gatewayHeader {
flex-direction: column;
}
@@ -1,6 +1,7 @@
import { useEffect, useState, type FormEvent } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import { WebsiteSectionShell } from '../components/WebsiteSectionShell'
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'
@@ -14,12 +15,13 @@ import {
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 './WebsiteEPaymentPage.module.css'
import styles from './SettingsPage.module.css'
const COMING_SOON_GATEWAYS: {
id: Exclude<PaymentGatewayId, 'mellat'>
id: Exclude<PaymentGatewayId, 'mellat' | 'zarinpal'>
nameKey: BusinessMessageKey
descKey: BusinessMessageKey
}[] = [
@@ -38,14 +40,25 @@ const COMING_SOON_GATEWAYS: {
nameKey: 'website.ePayment.gateway.digipay',
descKey: 'website.ePayment.gateway.digipayDesc',
},
{
id: 'zarinpal',
nameKey: 'website.ePayment.gateway.zarinpal',
descKey: 'website.ePayment.gateway.zarinpalDesc',
},
]
export function WebsiteEPaymentPage() {
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)
@@ -98,6 +111,10 @@ export function WebsiteEPaymentPage() {
...next.gateways.mellat,
password: password ?? '',
},
zarinpal: {
...next.gateways.zarinpal,
merchantId: next.gateways.zarinpal.merchantId.trim(),
},
},
}
@@ -121,7 +138,12 @@ export function WebsiteEPaymentPage() {
...ePayment,
enabled: checked,
defaultGateway: checked
? ePayment.defaultGateway ?? 'mellat'
? ePayment.defaultGateway ??
(ePayment.gateways.mellat.enabled
? 'mellat'
: ePayment.gateways.zarinpal.enabled
? 'zarinpal'
: 'mellat')
: ePayment.defaultGateway,
})
}
@@ -133,8 +155,18 @@ export function WebsiteEPaymentPage() {
...ePayment.gateways,
mellat: { ...ePayment.gateways.mellat, enabled: checked },
},
defaultGateway:
checked && !ePayment.defaultGateway ? 'mellat' : ePayment.defaultGateway,
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),
})
}
@@ -174,36 +206,70 @@ export function WebsiteEPaymentPage() {
)
}
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 (
<WebsiteSectionShell
sectionLabel="E-Payment"
title={t('title.ePayment')}
subtitle={t('website.ePayment.subtitle')}
>
<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}>
<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>
<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>
<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}>
@@ -339,6 +405,95 @@ export function WebsiteEPaymentPage() {
</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}>
@@ -354,6 +509,6 @@ export function WebsiteEPaymentPage() {
</div>
</div>
)}
</WebsiteSectionShell>
</main>
)
}
@@ -468,6 +468,7 @@ function StoreItemsPageContent() {
<StoreItemDiscountModal
open={!!discountTarget}
productTitle={discountTarget?.productTitle ?? ''}
productNameFa={discountTarget?.productNameFa ?? ''}
items={productItems}
onClose={() => setDiscountTarget(null)}
onSaved={mergeUpdatedItems}
@@ -44,7 +44,11 @@ const DEFAULT_STORE_SETTINGS: StoreSettings = {
sep: { enabled: false },
snappay: { enabled: false },
digipay: { enabled: false },
zarinpal: { enabled: false },
zarinpal: {
enabled: false,
merchantId: '',
sandbox: false,
},
},
},
}
+13 -11
View File
@@ -158,11 +158,9 @@ export function StoreSpecialsPage() {
setCreateOpen(false)
showToast(t('website.specialItems.toastCreated'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('website.specialItems.errorCreate'))
}
const message = err instanceof ApiError ? err.message : t('website.specialItems.errorCreate')
setError(message)
showToast(message, 'error')
} finally {
setIsSaving(false)
}
@@ -183,11 +181,9 @@ export function StoreSpecialsPage() {
setEditSpecialTarget(null)
showToast(t('website.specialItems.toastUpdated'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('website.specialItems.errorUpdate'))
}
const message = err instanceof ApiError ? err.message : t('website.specialItems.errorUpdate')
setError(message)
showToast(message, 'error')
} finally {
setIsSaving(false)
}
@@ -362,7 +358,10 @@ export function StoreSpecialsPage() {
<button
type="button"
className={fabStyles.addFab}
onClick={() => setCreateOpen(true)}
onClick={() => {
setError('')
setCreateOpen(true)
}}
aria-label={t('website.specialItems.addFab')}
>
<Plus size={24} />
@@ -376,6 +375,7 @@ export function StoreSpecialsPage() {
title={t('website.specialItems.createTitle')}
submitLabel={t('website.create')}
isSubmitting={isSaving}
error={createOpen ? error : undefined}
/>
<StoreSpecialModal
@@ -387,6 +387,7 @@ export function StoreSpecialsPage() {
title={t('website.specialItems.editTitle')}
submitLabel={t('website.save')}
isSubmitting={isSaving}
error={editSpecialTarget ? error : undefined}
/>
<PickStoreSpecialItemsModal
@@ -410,6 +411,7 @@ export function StoreSpecialsPage() {
<StoreItemDiscountModal
open={!!discountTarget}
productTitle={discountTarget?.productTitle ?? ''}
productNameFa={discountTarget?.productNameFa ?? ''}
items={productItems}
onClose={() => setDiscountTarget(null)}
onSaved={mergeUpdatedItems}
-8
View File
@@ -3,7 +3,6 @@ import {
BellRing,
CircleHelp,
Award,
CreditCard,
Images,
LayoutGrid,
Building2,
@@ -79,13 +78,6 @@ const websiteSections: {
linkKey: 'website.card.badges.link',
href: '/website/badges',
},
{
icon: CreditCard,
titleKey: 'nav.website.ePayment',
descKey: 'website.card.ePayment.desc',
linkKey: 'website.card.ePayment.link',
href: '/website/e-payment',
},
]
export function WebsitePage() {