Add e-payment UI, checkout payment result, and legacy migrate domain lookup.

Wire Mellat settings in business dashboard, customer bank return page with payment details, and super-admin migrate modal domain confirmation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-13 09:51:22 +03:30
co-authored by Cursor
parent a11f9643bb
commit cc94b929e0
54 changed files with 6415 additions and 96 deletions
+2
View File
@@ -26,6 +26,7 @@ import { CheckoutDeliveryStep } from './pages/checkout/CheckoutDeliveryStep'
import { CheckoutPaymentStep } from './pages/checkout/CheckoutPaymentStep'
import { CheckoutSuccessStep } from './pages/checkout/CheckoutSuccessStep'
import { CheckoutFailedStep } from './pages/checkout/CheckoutFailedStep'
import { CheckoutResultStep } from './pages/checkout/CheckoutResultStep'
function App() {
return (
@@ -44,6 +45,7 @@ function App() {
<Route path="cart" element={<CheckoutCartStep />} />
<Route path="delivery" element={<CheckoutDeliveryStep />} />
<Route path="payment" element={<CheckoutPaymentStep />} />
<Route path="result" element={<CheckoutResultStep />} />
<Route path="success" element={<CheckoutSuccessStep />} />
<Route path="failed" element={<CheckoutFailedStep />} />
<Route index element={<CheckoutCartStep />} />
+1
View File
@@ -8,6 +8,7 @@ export const customerRouteTitleRules: RouteTitleRule[] = [
{ match: '/checkout/cart', labels: ['Checkout', 'Shopping Cart'] },
{ match: '/checkout/delivery', labels: ['Checkout', 'Delivery'] },
{ match: '/checkout/payment', labels: ['Checkout', 'Payment'] },
{ match: '/checkout/result', labels: ['Checkout', 'Payment result'] },
{ match: '/checkout/success', labels: ['Checkout', 'Success'] },
{ match: '/checkout/failed', labels: ['Checkout', 'Failed'] },
{ match: '/checkout', labels: ['Checkout'] },
@@ -1,12 +1,40 @@
import { Link, useLocation, useNavigate } from 'react-router-dom'
import orderFailedImg from '../../assets/order-failed.png'
import type { Order } from '../../services/orderService'
import { formatIrtPrice } from '../../utils/irtPrice'
import {
gatewayLabelFa,
transactionStatusLabelFa,
type CheckoutPaymentInfo,
} from '../../utils/checkoutPayment'
import styles from './CheckoutSteps.module.css'
export function CheckoutFailedStep() {
type FailedLocationState = {
message?: string
payment?: CheckoutPaymentInfo
order?: Order
}
type CheckoutFailedStepProps = {
message?: string
payment?: CheckoutPaymentInfo | null
order?: Order | null
}
export function CheckoutFailedStep({
message: messageProp,
payment: paymentProp,
order: orderProp,
}: CheckoutFailedStepProps = {}) {
const navigate = useNavigate()
const location = useLocation()
const navState = (location.state as FailedLocationState | null) ?? null
const payment = paymentProp ?? navState?.payment ?? null
const order = orderProp ?? navState?.order ?? null
const message =
(location.state as { message?: string } | null)?.message ||
messageProp ||
navState?.message ||
payment?.message ||
'ثبت سفارش ممکن نشد. لطفاً دوباره تلاش کنید.'
return (
@@ -20,10 +48,57 @@ export function CheckoutFailedStep() {
</div>
<div className={styles.successHeader}>
<h1 className={styles.stepTitle}>سفارش ثبت نشد</h1>
<h1 className={styles.stepTitle}>
{payment ? 'پرداخت ناموفق' : 'سفارش ثبت نشد'}
</h1>
<p className={styles.stepDesc}>{message}</p>
</div>
{(order || payment) && (
<dl className={styles.successMeta}>
{order ? (
<div className={styles.successMetaRow}>
<dt>شماره سفارش</dt>
<dd className={styles.successMetaValue}>{order.orderNumber}</dd>
</div>
) : null}
{payment?.amount != null ? (
<div className={styles.successMetaRow}>
<dt>مبلغ</dt>
<dd className={styles.successMetaValue}>
{formatIrtPrice(payment.amount)}
</dd>
</div>
) : null}
{payment ? (
<>
<div className={styles.successMetaRow}>
<dt>وضعیت پرداخت</dt>
<dd>{transactionStatusLabelFa('failed')}</dd>
</div>
{payment.gatewayType ? (
<div className={styles.successMetaRow}>
<dt>درگاه</dt>
<dd>{gatewayLabelFa(payment.gatewayType)}</dd>
</div>
) : null}
{payment.trackId ? (
<div className={styles.successMetaRow}>
<dt>کد پیگیری</dt>
<dd className={styles.successMetaValue}>{payment.trackId}</dd>
</div>
) : null}
{payment.transactionId ? (
<div className={styles.successMetaRow}>
<dt>شناسه تراکنش</dt>
<dd className={styles.successMetaValue}>{payment.transactionId}</dd>
</div>
) : null}
</>
) : null}
</dl>
)}
<div className={styles.successActions}>
<button
type="button"
@@ -4,14 +4,20 @@ import { useAuth } from '../../context/AuthContext'
import { CheckoutStepper, type CheckoutStepId } from '../../components/checkout/CheckoutStepper'
import { loginPathWithRedirect, safeRedirectPath } from '../../lib/redirect'
/** Cart and checkout login are public. Delivery, payment, and success require auth. */
/** Cart and checkout login are public. Delivery, payment, and result require auth. */
const PUBLIC_STEPS = new Set<CheckoutStepId>(['cart', 'login'])
function stepFromPath(pathname: string): CheckoutStepId {
if (pathname.endsWith('/login')) return 'login'
if (pathname.endsWith('/delivery')) return 'delivery'
if (pathname.endsWith('/payment')) return 'payment'
if (pathname.endsWith('/success') || pathname.endsWith('/failed')) return 'payment'
if (
pathname.endsWith('/success') ||
pathname.endsWith('/failed') ||
pathname.endsWith('/result')
) {
return 'payment'
}
return 'cart'
}
@@ -26,11 +32,12 @@ export function CheckoutFlow() {
}
if (!isAuthenticated && !PUBLIC_STEPS.has(step)) {
const returnTo = `${location.pathname}${location.search}`
return (
<Navigate
to={loginPathWithRedirect(location.pathname, '/checkout/login')}
to={loginPathWithRedirect(returnTo, '/checkout/login')}
replace
state={{ from: location.pathname }}
state={{ from: returnTo }}
/>
)
}
@@ -43,11 +50,14 @@ export function CheckoutFlow() {
return <Navigate to={destination} replace />
}
const hideStepper =
location.pathname === '/checkout/success' ||
location.pathname === '/checkout/failed' ||
location.pathname === '/checkout/result'
return (
<>
{step !== 'login' &&
location.pathname !== '/checkout/success' &&
location.pathname !== '/checkout/failed' && (
{step !== 'login' && !hideStepper && (
<CheckoutStepper current={step} isAuthenticated={isAuthenticated} />
)}
<Outlet />
@@ -7,6 +7,7 @@ import { ApiError, isAbortError } from '../../lib/api'
import {
checkoutCart,
getCart,
submitPaymentRedirect,
type CheckoutPaymentType,
type OnlineGatewayType,
} from '../../services/cartService'
@@ -16,9 +17,9 @@ 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 }[] = [
{ id: 'mellat_behpardakht', label: 'درگاه پرداخت بانک ملت - به‌پرداخت' },
{ id: 'saman_kish', label: 'درگاه پرداخت بانک سامان - سامان کیش' },
const ONLINE_GATEWAYS: { id: OnlineGatewayType; label: string; ready: boolean }[] = [
{ id: 'mellat', label: 'درگاه پرداخت بانک ملت - به‌پرداخت', ready: true },
{ id: 'sep', label: 'درگاه پرداخت بانک سامان - سامان کیش', ready: false },
]
export function CheckoutPaymentStep() {
@@ -31,7 +32,7 @@ export function CheckoutPaymentStep() {
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const [paymentType, setPaymentType] = useState<CheckoutPaymentType>('e_payment_gate')
const [gatewayType, setGatewayType] = useState<OnlineGatewayType>('mellat_behpardakht')
const [gatewayType, setGatewayType] = useState<OnlineGatewayType>('mellat')
const [transferAccount, setTransferAccount] = useState('')
const [transferRefNumber, setTransferRefNumber] = useState('')
const [discountApplied, setDiscountApplied] = useState(false)
@@ -100,7 +101,14 @@ export function CheckoutPaymentStep() {
return
}
// Only bank transfer requires extra fields before creating the order
if (paymentType === 'e_payment_gate') {
const gateway = ONLINE_GATEWAYS.find((item) => item.id === gatewayType)
if (!gateway?.ready) {
setError('این درگاه هنوز فعال نیست. لطفاً ملت را انتخاب کنید.')
return
}
}
if (paymentType === 'transfer') {
if (!transferAccount.trim() || !transferRefNumber.trim()) {
setError('لطفاً شماره حساب/کارت و شماره مرجع را وارد کنید.')
@@ -119,9 +127,12 @@ export function CheckoutPaymentStep() {
}
try {
// Both types persist via existing backend TransactionType:
// e_payment_gate (+ gatewayType) | transfer (+ account/ref)
const payment = buildPaymentPayload()
const returnUrl =
paymentType === 'e_payment_gate'
? `${window.location.origin}/checkout/result`
: undefined
const payload =
deliveryMode === 'pickup'
? {
@@ -132,17 +143,25 @@ export function CheckoutPaymentStep() {
postalCode: '0000000000',
},
customerNotes: notes.join(' · ') || undefined,
returnUrl,
payment,
}
: {
addressId: selectedAddressId!,
customerNotes: notes.join(' · ') || undefined,
returnUrl,
payment,
}
const result = await checkoutCart(payload)
setPlacedOrder(result.order)
// Pass order in navigation state so success page does not race context updates
if (result.payment?.redirect) {
showToast('در حال انتقال به درگاه پرداخت...', 'info')
submitPaymentRedirect(result.payment.redirect)
return
}
navigate('/checkout/success', { replace: true, state: { order: result.order } })
showToast('سفارش با موفقیت ثبت شد.', 'success')
} catch (err) {
@@ -243,12 +262,15 @@ export function CheckoutPaymentStep() {
.filter(Boolean)
.join(' ')}
onClick={() => setGatewayType(gateway.id)}
disabled={submitting}
disabled={submitting || !gateway.ready}
>
<span className={styles.paymentSubRadio}>
{active ? <span className={styles.paymentSubRadioInner} /> : null}
</span>
<span>{gateway.label}</span>
<span>
{gateway.label}
{!gateway.ready ? ' (به‌زودی)' : ''}
</span>
</button>
)
})}
@@ -321,7 +343,15 @@ export function CheckoutPaymentStep() {
</div>
<CheckoutActionBar
primaryLabel={submitting ? 'در حال ثبت سفارش...' : 'پرداخت و ثبت سفارش'}
primaryLabel={
submitting
? paymentType === 'e_payment_gate'
? 'در حال انتقال به درگاه...'
: 'در حال ثبت سفارش...'
: paymentType === 'e_payment_gate'
? 'پرداخت آنلاین'
: 'پرداخت و ثبت سفارش'
}
onPrimary={() => void handlePlaceOrder()}
primaryDisabled={submitting}
/>
@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react'
import { Navigate, useSearchParams } from 'react-router-dom'
import { useCheckout } from '../../context/CheckoutContext'
import { ApiError, isAbortError } from '../../lib/api'
import { getOrder, type Order } from '../../services/orderService'
import {
buildPaymentInfoFromOrder,
type CheckoutPaymentInfo,
type PaymentResultStatus,
} from '../../utils/checkoutPayment'
import { CheckoutFailedStep } from './CheckoutFailedStep'
import { CheckoutSuccessStep } from './CheckoutSuccessStep'
import styles from './CheckoutSteps.module.css'
function readStatus(raw: string | null): PaymentResultStatus | null {
if (raw === 'success' || raw === 'failed') return raw
return null
}
/**
* Bank return URL target (`returnUrl`).
* Query: status, orderId, transactionId, trackId, message
*/
export function CheckoutResultStep() {
const [searchParams] = useSearchParams()
const { setPlacedOrder } = useCheckout()
const [order, setOrder] = useState<Order | null>(null)
const [payment, setPayment] = useState<CheckoutPaymentInfo | null>(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const status = readStatus(searchParams.get('status'))
const orderId = searchParams.get('orderId')?.trim() || ''
const transactionId = searchParams.get('transactionId')?.trim() || undefined
const trackId = searchParams.get('trackId')?.trim() || undefined
const message = searchParams.get('message')?.trim() || undefined
useEffect(() => {
if (!status || !orderId) {
setLoading(false)
return
}
const controller = new AbortController()
async function load() {
setLoading(true)
setError('')
try {
const data = await getOrder(orderId, controller.signal)
if (controller.signal.aborted) return
setOrder(data.order)
setPlacedOrder(data.order)
setPayment(
buildPaymentInfoFromOrder(data.order, {
status,
transactionId,
trackId,
message,
}),
)
} catch (err) {
if (isAbortError(err)) return
// Still show result shell with query payment data if order fetch fails
setPayment({
status: status!,
transactionId,
trackId,
message:
message ||
(err instanceof ApiError
? err.message
: 'بارگذاری جزئیات سفارش ممکن نشد.'),
})
setError(err instanceof ApiError ? err.message : 'بارگذاری سفارش ممکن نشد.')
} finally {
if (!controller.signal.aborted) setLoading(false)
}
}
void load()
return () => controller.abort()
}, [status, orderId, transactionId, trackId, message, setPlacedOrder])
if (!status) {
return <Navigate to="/checkout/cart" replace />
}
if (loading) {
return <div className={styles.loading}>در حال دریافت نتیجه پرداخت...</div>
}
if (status === 'success') {
if (!order) {
return (
<CheckoutFailedStep
message={error || message || 'پرداخت تأیید شد اما سفارش یافت نشد.'}
payment={payment}
/>
)
}
return <CheckoutSuccessStep order={order} payment={payment} />
}
return (
<CheckoutFailedStep
message={
payment?.message ||
message ||
error ||
'پرداخت انجام نشد. در صورت کسر وجه، مبلغ به‌زودی به حساب شما بازمی‌گردد.'
}
payment={payment}
order={order}
/>
)
}
@@ -4,14 +4,35 @@ import { useCheckout } from '../../context/CheckoutContext'
import type { Order } from '../../services/orderService'
import { getWebsiteUrl } from '../../services/websiteService'
import { formatIrtPrice } from '../../utils/irtPrice'
import {
gatewayLabelFa,
transactionStatusLabelFa,
type CheckoutPaymentInfo,
} from '../../utils/checkoutPayment'
import orderSucceedImg from '../../assets/order-succeed.png'
import styles from './CheckoutSteps.module.css'
export function CheckoutSuccessStep() {
type SuccessLocationState = {
order?: Order
payment?: CheckoutPaymentInfo
}
type CheckoutSuccessStepProps = {
/** When rendered from bank result page */
order?: Order
payment?: CheckoutPaymentInfo | null
}
export function CheckoutSuccessStep({
order: orderProp,
payment: paymentProp,
}: CheckoutSuccessStepProps = {}) {
const location = useLocation()
const { placedOrder, setPlacedOrder, resetCheckout } = useCheckout()
const orderFromNav = (location.state as { order?: Order } | null)?.order
const order = placedOrder ?? orderFromNav ?? null
const navState = (location.state as SuccessLocationState | null) ?? null
const orderFromNav = navState?.order
const payment = paymentProp ?? navState?.payment ?? null
const order = orderProp ?? placedOrder ?? orderFromNav ?? null
const websiteUrl = getWebsiteUrl()
useEffect(() => {
@@ -35,8 +56,14 @@ export function CheckoutSuccessStep() {
</div>
<div className={styles.successHeader}>
<h1 className={styles.stepTitle}>سفارش ثبت شد</h1>
<p className={styles.stepDesc}>از خرید شما سپاسگزاریم — سفارش دریافت شد.</p>
<h1 className={styles.stepTitle}>
{payment ? 'پرداخت موفق' : 'سفارش ثبت شد'}
</h1>
<p className={styles.stepDesc}>
{payment
? 'پرداخت شما تأیید شد و سفارش ثبت گردید.'
: 'از خرید شما سپاسگزاریم — سفارش دریافت شد.'}
</p>
</div>
<dl className={styles.successMeta}>
@@ -46,17 +73,50 @@ export function CheckoutSuccessStep() {
</div>
<div className={styles.successMetaRow}>
<dt>مبلغ</dt>
<dd className={styles.successMetaValue}>{formatIrtPrice(order.total)}</dd>
</div>
<div className={styles.successMetaRow}>
<dt>وضعیت</dt>
<dd>در انتظار تأیید</dd>
<dd className={styles.successMetaValue}>
{formatIrtPrice(payment?.amount ?? order.total)}
</dd>
</div>
{payment ? (
<>
<div className={styles.successMetaRow}>
<dt>وضعیت پرداخت</dt>
<dd>{transactionStatusLabelFa('completed')}</dd>
</div>
<div className={styles.successMetaRow}>
<dt>درگاه</dt>
<dd>{gatewayLabelFa(payment.gatewayType)}</dd>
</div>
{payment.trackId ? (
<div className={styles.successMetaRow}>
<dt>کد پیگیری</dt>
<dd className={styles.successMetaValue}>{payment.trackId}</dd>
</div>
) : null}
{payment.transactionId ? (
<div className={styles.successMetaRow}>
<dt>شناسه تراکنش</dt>
<dd className={styles.successMetaValue}>{payment.transactionId}</dd>
</div>
) : null}
{payment.gatewayRef ? (
<div className={styles.successMetaRow}>
<dt>شناسه مرجع درگاه</dt>
<dd className={styles.successMetaValue}>{payment.gatewayRef}</dd>
</div>
) : null}
</>
) : (
<div className={styles.successMetaRow}>
<dt>وضعیت</dt>
<dd>در انتظار تأیید</dd>
</div>
)}
</dl>
<div className={styles.successActions}>
<Link to="/" className={styles.successDarkBtn} onClick={() => resetCheckout()}>
رفتن به داشبورد مدیریت
<Link to="/orders" className={styles.successDarkBtn} onClick={() => resetCheckout()}>
مشاهده سفارش‌ها
</Link>
<a href={websiteUrl} className={styles.successNeutralBtn} onClick={() => resetCheckout()}>
بازگشت به وبسایت
+48 -2
View File
@@ -46,7 +46,8 @@ export interface ShippingAddressInput {
export type CheckoutPaymentType = 'e_payment_gate' | 'transfer'
export type OnlineGatewayType = 'mellat_behpardakht' | 'saman_kish'
/** Matches backend PaymentGatewayId (mellat first; others later). */
export type OnlineGatewayType = 'mellat' | 'sep' | 'snappay' | 'digipay' | 'zarinpal'
export interface CheckoutPaymentInput {
type: CheckoutPaymentType
@@ -55,13 +56,33 @@ export interface CheckoutPaymentInput {
transferRefNumber?: string
}
export interface PaymentRedirect {
method: 'GET' | 'POST'
url: string
fields?: Record<string, string>
}
export interface CheckoutPaymentResult {
gatewayType: string
transactionId: string
redirect: PaymentRedirect
}
export interface CheckoutInput {
addressId?: string
shippingAddress?: ShippingAddressInput
customerNotes?: string
/** Absolute URL for bank callback return (required for e_payment_gate). */
returnUrl?: string
payment: CheckoutPaymentInput
}
export interface CheckoutResponse {
message: string
order: Order
payment?: CheckoutPaymentResult
}
function cartPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
@@ -98,9 +119,34 @@ export async function removeCartItem(itemId: string) {
}
export async function checkoutCart(input: CheckoutInput) {
return apiRequest<{ message: string; order: Order }>(cartPath('/checkout'), {
return apiRequest<CheckoutResponse>(cartPath('/checkout'), {
method: 'POST',
auth: true,
body: input,
})
}
/** Submit a gateway redirect (POST form or GET navigation). */
export function submitPaymentRedirect(redirect: PaymentRedirect) {
if (redirect.method === 'GET') {
window.location.assign(redirect.url)
return
}
const form = document.createElement('form')
form.method = 'POST'
form.action = redirect.url
form.style.display = 'none'
form.acceptCharset = 'UTF-8'
for (const [name, value] of Object.entries(redirect.fields ?? {})) {
const input = document.createElement('input')
input.type = 'hidden'
input.name = name
input.value = value
form.appendChild(input)
}
document.body.appendChild(form)
form.submit()
}
@@ -38,6 +38,22 @@ export interface OrderCustomer {
email: string | null
}
export interface OrderTransaction {
id: string
type: string
status: string
amount: number
posType: string | null
gatewayType: string | null
gatewayRef: string | null
gatewayTrackId: string | null
transferAccount: string | null
transferRefNumber: string | null
notes: string | null
createdAt: string
updatedAt: string
}
export interface Order {
id: string
businessId: string
@@ -61,6 +77,7 @@ export interface Order {
updatedAt: string
customer: OrderCustomer
items: OrderItem[]
transactions?: OrderTransaction[]
}
export interface OrdersListResponse {
@@ -0,0 +1,82 @@
import type { Order, OrderTransaction } from '../services/orderService'
export type PaymentResultStatus = 'success' | 'failed'
export interface CheckoutPaymentInfo {
status: PaymentResultStatus
transactionId?: string
trackId?: string
gatewayType?: string
gatewayRef?: string
amount?: number
message?: string
}
export function gatewayLabelFa(gatewayType: string | null | undefined): string {
switch (gatewayType) {
case 'mellat':
return 'بانک ملت (به‌پرداخت)'
case 'sep':
return 'سپ (سامان کیش)'
case 'snappay':
return 'اسنپ‌پی'
case 'digipay':
return 'دیجی‌پی'
case 'zarinpal':
return 'زرین‌پال'
default:
return gatewayType?.trim() || 'درگاه آنلاین'
}
}
export function transactionStatusLabelFa(status: string | null | undefined): string {
switch (status) {
case 'completed':
return 'پرداخت موفق'
case 'pending':
return 'در انتظار پرداخت'
case 'failed':
return 'پرداخت ناموفق'
case 'refunded':
return 'بازگشت وجه'
default:
return status || '—'
}
}
export function pickOrderPaymentTransaction(
order: Order | null | undefined,
transactionId?: string,
): OrderTransaction | null {
const rows = order?.transactions ?? []
if (!rows.length) return null
if (transactionId) {
return rows.find((row) => row.id === transactionId) ?? null
}
return (
rows.find((row) => row.type === 'e_payment_gate') ??
rows[0] ??
null
)
}
export function buildPaymentInfoFromOrder(
order: Order,
opts: {
status: PaymentResultStatus
transactionId?: string
trackId?: string
message?: string
},
): CheckoutPaymentInfo {
const tx = pickOrderPaymentTransaction(order, opts.transactionId)
return {
status: opts.status,
transactionId: tx?.id ?? opts.transactionId,
trackId: tx?.gatewayTrackId ?? opts.trackId,
gatewayType: tx?.gatewayType ?? undefined,
gatewayRef: tx?.gatewayRef ?? undefined,
amount: tx?.amount ?? order.total,
message: opts.message,
}
}