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
@@ -81,18 +81,16 @@
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
inset-inline: 0;
max-height: 260px;
overflow-y: auto;
position: static;
margin-top: 6px;
max-height: none;
overflow: visible;
background: var(--elevated-surface);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow);
z-index: 30;
list-style: none;
padding: 6px;
margin: 0;
@@ -81,18 +81,16 @@
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
inset-inline: 0;
max-height: 220px;
overflow-y: auto;
position: static;
margin-top: 6px;
max-height: none;
overflow: visible;
background: var(--elevated-surface);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow);
z-index: 20;
list-style: none;
padding: 6px;
margin: 0;
+5
View File
@@ -77,3 +77,8 @@ html[data-theme='dark'] body::before {
radial-gradient(ellipse 520px 520px at 72% 18%, rgba(255, 255, 255, 0.04), transparent 72%),
radial-gradient(ellipse 420px 420px at 22% 82%, rgba(255, 255, 255, 0.03), transparent 72%);
}
body:has([aria-modal='true']) #root {
filter: blur(var(--modal-overlay-blur, 16px));
pointer-events: none;
}
@@ -7,9 +7,11 @@ import { ApiError, isAbortError } from '../../lib/api'
import {
checkoutCart,
getCart,
getPaymentMethods,
submitPaymentRedirect,
type CheckoutPaymentType,
type OnlineGatewayType,
type PublicPaymentGateway,
} from '../../services/cartService'
import { syncGuestCartToServer } from '../../services/syncGuestCart'
import { formatIrtPrice } from '../../utils/irtPrice'
@@ -17,11 +19,6 @@ import { CheckoutActionBar } from '../../components/checkout/CheckoutActionBar'
import { CheckoutBackButton } from '../../components/checkout/CheckoutBackButton'
import styles from './CheckoutSteps.module.css'
const ONLINE_GATEWAYS: { id: OnlineGatewayType; label: string; ready: boolean }[] = [
{ id: 'mellat', label: 'درگاه پرداخت بانک ملت - به‌پرداخت', ready: true },
{ id: 'sep', label: 'درگاه پرداخت بانک سامان - سامان کیش', ready: false },
]
export function CheckoutPaymentStep() {
const navigate = useNavigate()
const { showToast } = useToast()
@@ -31,6 +28,8 @@ export function CheckoutPaymentStep() {
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const [onlineGateways, setOnlineGateways] = useState<PublicPaymentGateway[]>([])
const [onlinePaymentsEnabled, setOnlinePaymentsEnabled] = useState(false)
const [paymentType, setPaymentType] = useState<CheckoutPaymentType>('e_payment_gate')
const [gatewayType, setGatewayType] = useState<OnlineGatewayType>('mellat')
const [transferAccount, setTransferAccount] = useState('')
@@ -43,9 +42,13 @@ export function CheckoutPaymentStep() {
async function load() {
setLoading(true)
try {
let data = await getCart(controller.signal)
const [cartResult, methodsResult] = await Promise.all([
getCart(controller.signal),
getPaymentMethods(controller.signal).catch(() => null),
])
if (controller.signal.aborted) return
let data = cartResult
if (data.cart.items.length === 0) {
try {
const synced = await syncGuestCartToServer(controller.signal)
@@ -59,6 +62,23 @@ export function CheckoutPaymentStep() {
setSubtotal(data.cart.subtotal)
if (data.cart.items.length === 0) {
navigate('/checkout/cart', { replace: true })
return
}
const gateways = methodsResult?.gateways ?? []
const enabled = Boolean(methodsResult?.enabled && gateways.length > 0)
setOnlineGateways(gateways)
setOnlinePaymentsEnabled(enabled)
if (enabled) {
const preferred =
(methodsResult?.defaultGateway &&
gateways.find((g) => g.id === methodsResult.defaultGateway)?.id) ||
gateways[0]?.id
if (preferred) setGatewayType(preferred)
setPaymentType('e_payment_gate')
} else {
setPaymentType('transfer')
}
} catch (err) {
if (isAbortError(err)) return
@@ -102,9 +122,8 @@ export function CheckoutPaymentStep() {
}
if (paymentType === 'e_payment_gate') {
const gateway = ONLINE_GATEWAYS.find((item) => item.id === gatewayType)
if (!gateway?.ready) {
setError('این درگاه هنوز فعال نیست. لطفاً ملت را انتخاب کنید.')
if (!onlinePaymentsEnabled || !onlineGateways.some((g) => g.id === gatewayType)) {
setError('درگاه پرداخت آنلاین در دسترس نیست. لطفاً روش دیگری انتخاب کنید.')
return
}
}
@@ -227,27 +246,29 @@ export function CheckoutPaymentStep() {
<div className={styles.field}>
<label>روش پرداخت</label>
<div className={styles.paymentOptions}>
<button
type="button"
className={[
styles.paymentOption,
paymentType === 'e_payment_gate' ? styles.paymentOptionActive : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => setPaymentType('e_payment_gate')}
disabled={submitting}
>
<CreditCard size={20} />
<span className={styles.paymentOptionBody}>
<span className={styles.paymentOptionTitle}>پرداخت آنلاین</span>
<span className={styles.paymentOptionHint}>پرداخت امن از طریق درگاه</span>
</span>
</button>
{onlinePaymentsEnabled ? (
<button
type="button"
className={[
styles.paymentOption,
paymentType === 'e_payment_gate' ? styles.paymentOptionActive : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => setPaymentType('e_payment_gate')}
disabled={submitting}
>
<CreditCard size={20} />
<span className={styles.paymentOptionBody}>
<span className={styles.paymentOptionTitle}>پرداخت آنلاین</span>
<span className={styles.paymentOptionHint}>پرداخت امن از طریق درگاه</span>
</span>
</button>
) : null}
{paymentType === 'e_payment_gate' && (
{onlinePaymentsEnabled && paymentType === 'e_payment_gate' && (
<div className={styles.paymentSubOptions} role="radiogroup" aria-label="انتخاب درگاه">
{ONLINE_GATEWAYS.map((gateway) => {
{onlineGateways.map((gateway) => {
const active = gatewayType === gateway.id
return (
<button
@@ -262,15 +283,12 @@ export function CheckoutPaymentStep() {
.filter(Boolean)
.join(' ')}
onClick={() => setGatewayType(gateway.id)}
disabled={submitting || !gateway.ready}
disabled={submitting}
>
<span className={styles.paymentSubRadio}>
{active ? <span className={styles.paymentSubRadioInner} /> : null}
</span>
<span>
{gateway.label}
{!gateway.ready ? ' (به‌زودی)' : ''}
</span>
<span>{gateway.labelFa || gateway.label}</span>
</button>
)
})}
+27
View File
@@ -83,6 +83,18 @@ export interface CheckoutResponse {
payment?: CheckoutPaymentResult
}
export interface PublicPaymentGateway {
id: OnlineGatewayType
label: string
labelFa: string
}
export interface PublicPaymentMethods {
enabled: boolean
defaultGateway: OnlineGatewayType | null
gateways: PublicPaymentGateway[]
}
function cartPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
@@ -91,6 +103,14 @@ function cartPath(suffix = '') {
return `/businesses/${businessId}/cart${suffix}`
}
function paymentsMethodsPath() {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/payments/methods`
}
export async function getCart(signal?: AbortSignal) {
return apiRequest<{ cart: Cart }>(cartPath(), { auth: true, signal })
}
@@ -126,6 +146,13 @@ export async function checkoutCart(input: CheckoutInput) {
})
}
export async function getPaymentMethods(signal?: AbortSignal) {
return apiRequest<PublicPaymentMethods>(paymentsMethodsPath(), {
auth: false,
signal,
})
}
/** Submit a gateway redirect (POST form or GET navigation). */
export function submitPaymentRedirect(redirect: PaymentRedirect) {
if (redirect.method === 'GET') {