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 -3
View File
@@ -1,4 +1,4 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { BrowserRouter, Navigate, Routes, Route } from 'react-router-dom'
import { BusinessDomainGuard } from './components/BusinessDomainGuard'
import { DashboardDocumentTitle } from './components/DashboardDocumentTitle'
import { AuthProvider } from './context/AuthContext'
@@ -66,7 +66,7 @@ import { WebsiteContactPage } from './pages/WebsiteContactPage'
import { WebsiteSubscriptionsPage } from './pages/WebsiteSubscriptionsPage'
import { WebsiteFaqPage } from './pages/WebsiteFaqPage'
import { WebsiteBadgesPage } from './pages/WebsiteBadgesPage'
import { WebsiteEPaymentPage } from './pages/WebsiteEPaymentPage'
import { SettingsPage } from './pages/SettingsPage'
import { WebsiteSlidersPage } from './pages/WebsiteSlidersPage'
import { WebsiteSpecialCategoriesPage } from './pages/WebsiteSpecialCategoriesPage'
import { WebsiteSpecialBrandsPage } from './pages/WebsiteSpecialBrandsPage'
@@ -91,6 +91,7 @@ function App() {
<Route index element={<HomePage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="business-profile" element={<BusinessProfilePage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="products" element={<ProductsPage />} />
<Route path="products/categories" element={<CategoriesPage />} />
<Route path="products/brands" element={<BrandsPage />} />
@@ -155,7 +156,7 @@ function App() {
<Route path="website/subscriptions" element={<WebsiteSubscriptionsPage />} />
<Route path="website/faq" element={<WebsiteFaqPage />} />
<Route path="website/badges" element={<WebsiteBadgesPage />} />
<Route path="website/e-payment" element={<WebsiteEPaymentPage />} />
<Route path="website/e-payment" element={<Navigate to="/settings" replace />} />
</Route>
</Route>
</Routes>
@@ -152,6 +152,19 @@
box-shadow: 0 6px 16px rgba(var(--primary-rgb) / 0.35);
}
.submitBtn:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.errorText {
margin: 0;
font-size: 12px;
color: #ef4444;
}
.modalIn {
animation: modalFadeIn 0.25s ease forwards;
}
@@ -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;
@@ -64,15 +64,13 @@
}
.dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 20;
position: static;
margin-top: 4px;
z-index: auto;
display: flex;
flex-direction: column;
max-height: 240px;
overflow: hidden;
max-height: none;
overflow: visible;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
@@ -108,6 +108,10 @@
margin-top: 24px;
}
.span4 {
grid-column: span 4;
}
.col6 {
grid-column: span 6;
}
@@ -121,6 +125,10 @@
grid-template-columns: repeat(4, 1fr);
gap: 24px;
}
.span4 {
grid-column: span 2;
}
}
@media (max-width: 1400px) {
@@ -133,6 +141,10 @@
.gridHome {
grid-template-columns: repeat(3, 1fr);
}
.span4 {
grid-column: span 2;
}
}
@media (max-width: 900px) {
@@ -141,6 +153,10 @@
.gridCols4 {
grid-template-columns: repeat(2, 1fr);
}
.span4 {
grid-column: span 2;
}
}
@media (max-width: 768px) {
@@ -173,7 +189,8 @@
gap: 16px;
}
.span4,
.col6 {
grid-column: span 12;
grid-column: span 1;
}
}
@@ -69,31 +69,29 @@
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
max-height: 220px;
overflow-y: auto;
position: static;
margin-top: 6px;
max-height: none;
overflow: visible;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: 0 8px 24px rgba(31, 38, 135, 0.12);
z-index: 10;
list-style: none;
padding: 6px;
animation: dropdownIn 0.15s ease;
}
/* Prefer for selects inside overflow-clipped modals — expands parent instead of clipping. */
/* Alias — in-flow is the default so search results expand the parent modal. */
.dropdownInFlow {
position: static;
top: auto;
left: auto;
right: auto;
max-height: min(50vh, 400px);
max-height: none;
overflow: visible;
margin-top: 6px;
z-index: auto;
}
+3 -1
View File
@@ -8,6 +8,7 @@ interface SectionCardProps {
description: string
linkText?: string
href: string
className?: string
/** Entity total shown beside the arrow; omit for sections without a count (e.g. settings). */
count?: number | null
countLabel?: string
@@ -18,6 +19,7 @@ export function SectionCard({
title,
description,
href,
className,
count,
countLabel,
}: SectionCardProps) {
@@ -27,7 +29,7 @@ export function SectionCard({
: null
return (
<Link to={href} className={styles.card}>
<Link to={href} className={className ? `${styles.card} ${className}` : styles.card}>
<div className={styles.iconWrap}>
<Icon size={24} strokeWidth={1.75} />
</div>
-1
View File
@@ -212,7 +212,6 @@ export function Sidebar() {
{ labelKey: 'nav.website.subscriptions', to: '/website/subscriptions' },
{ labelKey: 'nav.website.faq', to: '/website/faq' },
{ labelKey: 'nav.website.badges', to: '/website/badges' },
{ labelKey: 'nav.website.ePayment', to: '/website/e-payment' },
],
},
{ type: 'link', id: 'settings', icon: Settings, labelKey: 'nav.settings', to: '/settings' },
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
updateStoreItemDiscounts,
@@ -15,6 +17,7 @@ import rowStyles from './CreateStoreItemsModal.module.css'
interface StoreItemDiscountModalProps {
open: boolean
productTitle: string
productNameFa?: string
items: StoreItem[]
onClose: () => void
onSaved?: (items: StoreItem[]) => void
@@ -33,10 +36,15 @@ const ANIMATION_MS = 220
export function StoreItemDiscountModal({
open,
productTitle,
productNameFa = '',
items,
onClose,
onSaved,
}: StoreItemDiscountModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const displayTitle = isFa ? productNameFa || productTitle : productTitle || productNameFa
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [rows, setRows] = useState<DiscountDraftRow[]>([])
@@ -113,7 +121,7 @@ export function StoreItemDiscountModal({
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to save discounts.')
setError(t('storeItems.discount.errorSave'))
}
} finally {
setIsSubmitting(false)
@@ -131,24 +139,26 @@ export function StoreItemDiscountModal({
role="dialog"
aria-modal="true"
aria-labelledby="store-discount-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={modalStyles.header}>
<div>
<h3 id="store-discount-title" className={modalStyles.title}>
Set Discounts
{t('storeItems.discount.title')}
</h3>
<p className={modalStyles.subtitle}>{productTitle}</p>
<p className={modalStyles.subtitle}>{displayTitle}</p>
</div>
<button className={modalStyles.closeBtn} onClick={onClose} aria-label="Close">
<button className={modalStyles.closeBtn} onClick={onClose} aria-label={t('common.close')}>
<X size={20} />
</button>
</div>
<form className={modalStyles.body} onSubmit={(e) => void handleSubmit(e)}>
<div className={rowStyles.discountHeader}>
<span>Variant</span>
<span>Regular price</span>
<span>Discounted price (IRT)</span>
<span>{t('storeItems.discount.variant')}</span>
<span>{t('storeItems.discount.regularPrice')}</span>
<span>{t('storeItems.discount.discountedPrice')}</span>
</div>
<div className={rowStyles.discountRows}>
@@ -172,7 +182,7 @@ export function StoreItemDiscountModal({
),
)
}
placeholder="No discount"
placeholder={t('storeItems.discount.placeholder')}
disabled={isSubmitting}
/>
</div>
@@ -188,10 +198,10 @@ export function StoreItemDiscountModal({
onClick={onClose}
disabled={isSubmitting}
>
Cancel
{t('products.form.cancel')}
</button>
<button type="submit" className={modalStyles.submitBtn} disabled={!canSubmit}>
{isSubmitting ? 'Saving…' : 'Save discounts'}
{isSubmitting ? t('storeItems.discount.saving') : t('storeItems.discount.save')}
</button>
</div>
</form>
@@ -4,10 +4,6 @@
gap: 12px;
}
.dropdown {
max-height: 280px;
}
.option {
display: flex;
align-items: center;
@@ -125,7 +125,7 @@ export function StoreItemMultiSearchSelect({
</div>
{showDropdown && (
<ul className={`${selectStyles.dropdown} ${styles.dropdown}`} role="listbox">
<ul className={`${selectStyles.dropdown} ${selectStyles.dropdownInFlow}`} role="listbox">
{filtered.length === 0 ? (
<li className={selectStyles.noResults}>
{options.length === 0
@@ -164,7 +164,7 @@ export function StoreItemMultiSearchSelect({
)}
{open && query.trim().length > 0 && query.trim().length < minSearchLength && (
<ul className={selectStyles.dropdown}>
<ul className={`${selectStyles.dropdown} ${selectStyles.dropdownInFlow}`}>
<li className={selectStyles.noResults}>
{t('storeItems.search.minChars', { count: minSearchLength })}
</li>
@@ -19,11 +19,45 @@ interface StoreSpecialModalProps {
title?: string
submitLabel?: string
isSubmitting?: boolean
error?: string
}
const ANIMATION_MS = 220
const KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/
function slugifyGroupKey(raw: string): string {
const slug = raw
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
if (!slug || !/^[a-z]/.test(slug)) return ''
return KEY_PATTERN.test(slug) ? slug : ''
}
function hasNonAscii(value: string): boolean {
return /[^\u0000-\u007F]/.test(value)
}
function resolveGroupFields(keyRaw: string, titleRaw: string): StoreSpecialModalValues | null {
const key = keyRaw.trim()
const title = titleRaw.trim()
const keySlug = slugifyGroupKey(key)
const titleSlug = slugifyGroupKey(title)
const keyLooksLikeTitle = key.length > 0 && !keySlug && hasNonAscii(key)
if (keySlug && title) return { key: keySlug, title }
if (keyLooksLikeTitle && titleSlug) return { key: titleSlug, title: key }
if (!key && titleSlug) return { key: titleSlug, title }
if (title && !keySlug && !titleSlug) {
return { key: `special-${Date.now().toString(36)}`, title }
}
if (keyLooksLikeTitle && !title) {
return { key: `special-${Date.now().toString(36)}`, title: key }
}
return null
}
export function StoreSpecialModal({
open,
onClose,
@@ -33,6 +67,7 @@ export function StoreSpecialModal({
title,
submitLabel,
isSubmitting = false,
error,
}: StoreSpecialModalProps) {
const t = useT()
const { locale } = useLocale()
@@ -42,6 +77,7 @@ export function StoreSpecialModal({
const [closing, setClosing] = useState(false)
const [groupKey, setGroupKey] = useState(initialKey)
const [groupTitle, setGroupTitle] = useState(initialTitle)
const [keyTouched, setKeyTouched] = useState(Boolean(initialKey))
useEffect(() => {
if (open) {
@@ -49,6 +85,7 @@ export function StoreSpecialModal({
setClosing(false)
setGroupKey(initialKey)
setGroupTitle(initialTitle)
setKeyTouched(Boolean(initialKey))
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
@@ -70,15 +107,22 @@ export function StoreSpecialModal({
if (!mounted) return null
const trimmedKey = groupKey.trim()
const trimmedTitle = groupTitle.trim()
const keyValid = KEY_PATTERN.test(trimmedKey)
const canSubmit = keyValid && trimmedTitle.length > 0
const resolved = resolveGroupFields(groupKey, groupTitle)
const canSubmit = resolved !== null
const keySlug = slugifyGroupKey(groupKey)
const showKeyError = groupKey.trim().length > 0 && !keySlug && !hasNonAscii(groupKey.trim())
function handleTitleChange(value: string) {
setGroupTitle(value)
if (!keyTouched) {
setGroupKey(slugifyGroupKey(value))
}
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!canSubmit) return
onSubmit({ key: trimmedKey, title: trimmedTitle })
if (!resolved) return
onSubmit(resolved)
}
return createPortal(
@@ -110,33 +154,47 @@ export function StoreSpecialModal({
</div>
<form ref={formRef} className={styles.form} onSubmit={handleSubmit}>
<div className={styles.field}>
<label htmlFor="special-group-title">{t('website.groupValue')}</label>
<input
id="special-group-title"
value={groupTitle}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder={t('website.groupValuePlaceholder')}
autoFocus
disabled={isSubmitting}
dir={isFa ? 'rtl' : 'ltr'}
/>
</div>
<div className={styles.field}>
<label htmlFor="special-group-key">{t('website.groupKey')}</label>
<input
id="special-group-key"
value={groupKey}
onChange={(e) => setGroupKey(e.target.value)}
onChange={(e) => {
setKeyTouched(true)
setGroupKey(e.target.value)
}}
placeholder={t('website.groupKeyPlaceholder')}
autoFocus
disabled={isSubmitting}
dir="ltr"
autoComplete="off"
spellCheck={false}
/>
<p className={styles.hint}>{t('website.groupKeyHint')}</p>
{showKeyError ? (
<p className={styles.errorText} role="alert">
{t('website.groupKeyError')}
</p>
) : null}
</div>
<div className={styles.field}>
<label htmlFor="special-group-title">{t('website.groupValue')}</label>
<input
id="special-group-title"
value={groupTitle}
onChange={(e) => setGroupTitle(e.target.value)}
placeholder={t('website.groupValuePlaceholder')}
disabled={isSubmitting}
dir={isFa ? 'rtl' : 'ltr'}
/>
</div>
{error ? (
<p className={styles.errorText} role="alert">
{error}
</p>
) : null}
<div className={styles.actions}>
<button
@@ -2,8 +2,8 @@
position: fixed;
inset: 0;
background: var(--modal-overlay-bg);
backdrop-filter: blur(var(--modal-overlay-blur));
-webkit-backdrop-filter: blur(var(--modal-overlay-blur));
backdrop-filter: blur(var(--modal-overlay-blur, 16px));
-webkit-backdrop-filter: blur(var(--modal-overlay-blur, 16px));
display: flex;
align-items: center;
justify-content: center;
@@ -11,6 +11,14 @@
padding: 20px;
}
.overlayIn {
animation: overlayFadeIn 0.2s ease forwards;
}
.overlayOut {
animation: overlayFadeOut 0.18s ease forwards;
}
.overlayNested {
z-index: 1010;
}
@@ -417,6 +425,16 @@
color: #ef4444;
margin-bottom: 12px;
}
@keyframes overlayFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes overlayFadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes modalFadeIn {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
+45 -27
View File
@@ -218,7 +218,7 @@ const en = {
'home.card.portfolios.link': 'View portfolios',
'home.card.portfolios.count': 'portfolios',
'home.card.website.title': 'Website',
'home.card.website.desc': 'Manage contact forms, FAQ, badges, subscriptions, and e-payment.',
'home.card.website.desc': 'Manage contact forms, FAQ, badges, and subscriptions.',
'home.card.website.link': 'View website',
'home.card.finance.title': 'Finance Management',
'home.card.finance.desc': 'Manage invoices and financial transactions.',
@@ -728,6 +728,14 @@ const en = {
'storeItems.search.noProducts': 'No products available.',
'storeItems.search.noMatch': 'No matching products.',
'storeItems.search.minChars': 'Type at least {count} characters to search',
'storeItems.discount.title': 'Set Discounts',
'storeItems.discount.variant': 'Variant',
'storeItems.discount.regularPrice': 'Regular price',
'storeItems.discount.discountedPrice': 'Discounted price (IRT)',
'storeItems.discount.placeholder': 'No discount',
'storeItems.discount.save': 'Save discounts',
'storeItems.discount.saving': 'Saving…',
'storeItems.discount.errorSave': 'Unable to save discounts.',
'orders.title': 'Orders',
'orders.subtitle':
@@ -1263,8 +1271,7 @@ const en = {
'website.card.faq.link': 'Manage FAQ',
'website.card.badges.desc': 'Show trust badges, certifications, and highlights on your website.',
'website.card.badges.link': 'Manage badges',
'website.card.ePayment.desc': 'Configure online payment gateways and checkout payment options.',
'website.card.ePayment.link': 'Manage e-payment',
'settings.subtitle': 'Configure online payment gateways and checkout payment options.',
'website.scrollLeft': 'Scroll left',
'website.scrollRight': 'Scroll right',
@@ -1281,6 +1288,8 @@ const en = {
'website.groupKeyPlaceholder': 'e.g. top-selling',
'website.groupKeyHint':
'English only — letters, numbers, hyphens, underscores. Used by websites.',
'website.groupKeyError':
'Use English letters, numbers, hyphens, or underscores only — e.g. special-sale.',
'website.groupValue': 'Display title',
'website.groupValuePlaceholder': 'Shown on the website in any language',
'website.removeFromGroupTitle': 'Remove from Group',
@@ -1461,10 +1470,10 @@ const en = {
'website.ePayment.subtitle': 'Configure online payment methods for your website checkout.',
'website.ePayment.master': 'Enable online payments',
'website.ePayment.masterDesc':
'When enabled, customers can pay through a configured gateway at website checkout.',
'When a gateway is enabled and configured, customers can use it at website checkout or in the app.',
'website.ePayment.gateway.mellat': 'Bank Mellat (Behpardakht)',
'website.ePayment.gateway.mellatDesc':
'First gateway. Enter terminal ID, username, and password from the Mellat merchant panel.',
'Enter terminal ID, username, and password from the Mellat merchant panel.',
'website.ePayment.gateway.sep': 'SEP (Saman Kish)',
'website.ePayment.gateway.sepDesc': 'Saman electronic payment — coming soon.',
'website.ePayment.gateway.snappay': 'Snapp Pay',
@@ -1916,7 +1925,7 @@ const fa: Record<MessageKey, string> = {
'nav.website.sliders': 'اسلایدرها',
'nav.website.specialCategories': 'دسته‌های ویژه',
'nav.website.specialBrands': 'برندهای ویژه',
'nav.website.specialItems': 'اقلام ویژه',
'nav.website.specialItems': 'محصولات ویژه فروشگاه',
'nav.website.contact': 'فرم تماس با ما',
'nav.website.subscriptions': 'عضویت‌ها',
'nav.website.faq': 'سوالات متداول',
@@ -2062,7 +2071,7 @@ const fa: Record<MessageKey, string> = {
'home.card.videos.link': 'مشاهده ویدیوها',
'home.card.videos.count': 'ویدیو',
'home.card.website.title': 'وب‌سایت',
'home.card.website.desc': 'فرم تماس، سوالات متداول، نشان‌ها، عضویت‌ها و پرداخت الکترونیک.',
'home.card.website.desc': 'فرم تماس، سوالات متداول، نشان‌ها و عضویت‌ها.',
'home.card.website.link': 'مشاهده وب‌سایت',
'home.card.finance.title': 'مدیریت مالی',
'home.card.finance.desc': 'فاکتورها و تراکنش‌های مالی را مدیریت کنید.',
@@ -2570,6 +2579,14 @@ const fa: Record<MessageKey, string> = {
'storeItems.search.noProducts': 'محصولی موجود نیست.',
'storeItems.search.noMatch': 'محصولی یافت نشد.',
'storeItems.search.minChars': 'حداقل {count} کاراکتر برای جستجو وارد کنید',
'storeItems.discount.title': 'تنظیم تخفیف',
'storeItems.discount.variant': 'تنوع',
'storeItems.discount.regularPrice': 'قیمت عادی',
'storeItems.discount.discountedPrice': 'قیمت با تخفیف (IRT)',
'storeItems.discount.placeholder': 'بدون تخفیف',
'storeItems.discount.save': 'ذخیره تخفیف',
'storeItems.discount.saving': 'در حال ذخیره…',
'storeItems.discount.errorSave': 'ذخیره تخفیف ممکن نشد.',
'orders.title': 'سفارش‌ها',
'orders.subtitle':
@@ -3096,8 +3113,8 @@ const fa: Record<MessageKey, string> = {
'website.card.specialBrands.desc': 'برندهای شریک یا ویژه را در صفحه اصلی وب‌سایت نمایش دهید.',
'website.card.specialBrands.link': 'مدیریت برندها',
'website.card.specialItems.desc':
'اقلام فروشگاه ویژه را در دسته‌ها برای کاروسل‌های وب‌سایت گردآوری کنید.',
'website.card.specialItems.link': 'مدیریت اقلام ویژه',
'محصولات ویژه فروشگاه را در دسته‌ها برای کاروسل‌های وب‌سایت گردآوری کنید.',
'website.card.specialItems.link': 'مدیریت محصولات ویژه',
'website.card.contact.desc': 'ارسال‌های فرم تماس وب‌سایت را مشاهده کنید.',
'website.card.contact.link': 'مشاهده ارسال‌ها',
'website.card.subscriptions.desc': 'عضویت خبرنامه و گزینه‌های اشتراک بازدیدکنندگان را مدیریت کنید.',
@@ -3106,8 +3123,7 @@ const fa: Record<MessageKey, string> = {
'website.card.faq.link': 'مدیریت سوالات متداول',
'website.card.badges.desc': 'نشان‌های اعتماد، گواهی‌ها و برجسته‌ها را در وب‌سایت نمایش دهید.',
'website.card.badges.link': 'مدیریت نشان‌ها',
'website.card.ePayment.desc': 'درگاه‌های پرداخت آنلاین و گزینه‌های تسویه حساب را پیکربندی کنید.',
'website.card.ePayment.link': 'مدیریت پرداخت الکترونیک',
'settings.subtitle': 'درگاه‌های پرداخت آنلاین و گزینه‌های تسویه حساب را پیکربندی کنید.',
'website.scrollLeft': 'اسکرول به چپ',
'website.scrollRight': 'اسکرول به راست',
@@ -3124,6 +3140,8 @@ const fa: Record<MessageKey, string> = {
'website.groupKeyPlaceholder': 'مثلاً: top-selling',
'website.groupKeyHint':
'فقط انگلیسی — حروف، اعداد، خط تیره و زیرخط. برای استفاده وب‌سایت.',
'website.groupKeyError':
'فقط حروف انگلیسی، اعداد، خط تیره یا زیرخط — مثلاً special-sale.',
'website.groupValue': 'عنوان نمایشی',
'website.groupValuePlaceholder': 'عنوانی که در وب‌سایت نمایش داده می‌شود (هر زبانی)',
'website.removeFromGroupTitle': 'حذف از گروه',
@@ -3217,40 +3235,40 @@ const fa: Record<MessageKey, string> = {
'website.specialBrands.pickErrorLoad': 'بارگذاری برندها ممکن نشد.',
'website.specialItems.subtitle':
'اقلام فروشگاه ویژه را برای کاروسل‌های وب‌سایت در دسته‌ها گردآوری کنید.',
'محصولات ویژه فروشگاه را برای کاروسل‌های وب‌سایت در دسته‌ها گردآوری کنید.',
'website.specialItems.loading': 'در حال بارگذاری دسته‌های ویژه...',
'website.specialItems.empty':
'هنوز دسته ویژه‌ای نیست. با دکمه + یکی بسازید، سپس اقلام فروشگاه را به هر کاروسل اضافه کنید.',
'هنوز دسته ویژه‌ای نیست. با دکمه + یکی بسازید، سپس محصولات فروشگاه را به هر کاروسل اضافه کنید.',
'website.specialItems.errorLoad': 'بارگذاری دسته‌های ویژه ممکن نشد.',
'website.specialItems.errorCreate': 'ایجاد دسته ویژه ممکن نشد.',
'website.specialItems.errorUpdate': 'به‌روزرسانی دسته ویژه ممکن نشد.',
'website.specialItems.errorDelete': 'حذف دسته ویژه ممکن نشد.',
'website.specialItems.errorAdd': 'افزودن اقلام فروشگاه ممکن نشد.',
'website.specialItems.errorRemove': 'حذف قلم فروشگاه ممکن نشد.',
'website.specialItems.errorAdd': 'افزودن محصولات فروشگاه ممکن نشد.',
'website.specialItems.errorRemove': 'حذف محصول فروشگاه ممکن نشد.',
'website.specialItems.toastCreated': 'دسته ویژه ایجاد شد.',
'website.specialItems.toastUpdated': 'دسته ویژه به‌روزرسانی شد.',
'website.specialItems.toastDeleted': 'دسته ویژه حذف شد.',
'website.specialItems.toastAdded': 'اقلام فروشگاه به دسته ویژه افزوده شد.',
'website.specialItems.toastAdded': 'محصولات فروشگاه به دسته ویژه افزوده شد.',
'website.specialItems.toastRemoved': 'از دسته ویژه حذف شد.',
'website.specialItems.addFab': 'افزودن دسته ویژه',
'website.specialItems.createTitle': 'افزودن دسته ویژه',
'website.specialItems.editTitle': 'ویرایش دسته ویژه',
'website.specialItems.deleteTitle': 'حذف دسته ویژه',
'website.specialItems.deleteMessage':
'«{title}» حذف شود؟ اقلام فروشگاه در کاتالوگ باقی می‌مانند.',
'«{title}» حذف شود؟ محصولات فروشگاه در کاتالوگ باقی می‌مانند.',
'website.specialItems.removeTitle': 'حذف از ویژه',
'website.specialItems.removeMessage': '«{name}» از «{group}» حذف شود؟',
'website.specialItems.editCategory': 'ویرایش دسته',
'website.specialItems.deleteCategory': 'حذف دسته',
'website.specialItems.scrollLeft': 'اسکرول به چپ',
'website.specialItems.scrollRight': 'اسکرول به راست',
'website.specialItems.addItemsTo': 'افزودن اقلام فروشگاه به {title}',
'website.specialItems.addItemsTo': 'افزودن محصولات فروشگاه به {title}',
'website.specialItems.removeFromSpecial': 'حذف از ویژه',
'website.specialItems.pickSubtitle': 'اقلام فروشگاه را برای نمایش در این دسته انتخاب کنید.',
'website.specialItems.pickLoading': 'در حال بارگذاری اقلام فروشگاه...',
'website.specialItems.pickEmpty': 'همه اقلام فروشگاه قبلاً در این دسته هستند.',
'website.specialItems.pickSearch': 'حداقل ۳ حرف برای جستجوی اقلام فروشگاه تایپ کنید',
'website.specialItems.pickErrorLoad': 'بارگذاری اقلام فروشگاه ممکن نشد.',
'website.specialItems.pickSubtitle': 'محصولات فروشگاه را برای نمایش در این دسته انتخاب کنید.',
'website.specialItems.pickLoading': 'در حال بارگذاری محصولات فروشگاه...',
'website.specialItems.pickEmpty': 'همه محصولات فروشگاه قبلاً در این دسته هستند.',
'website.specialItems.pickSearch': 'حداقل ۳ حرف برای جستجوی محصولات فروشگاه تایپ کنید',
'website.specialItems.pickErrorLoad': 'بارگذاری محصولات فروشگاه ممکن نشد.',
'website.pick.addOneItem': 'افزودن ۱ قلم',
'website.pick.addItems': 'افزودن {count} قلم',
@@ -3301,10 +3319,10 @@ const fa: Record<MessageKey, string> = {
'website.ePayment.subtitle': 'روش‌های پرداخت آنلاین برای تسویه حساب وب‌سایت را پیکربندی کنید.',
'website.ePayment.master': 'فعال‌سازی پرداخت آنلاین',
'website.ePayment.masterDesc':
'با فعال بودن این گزینه، مشتریان در تسویه حساب وب‌سایت می‌توانند از درگاه پیکربندی‌شده پرداخت کنند.',
'با فعال بودن هر درگاه و پیکربندی آن، مشتریان در تسویه حساب وبسایت و یا اپلیکیشن از آن درگاه استفاده کنند.',
'website.ePayment.gateway.mellat': 'بانک ملت (به‌پرداخت)',
'website.ePayment.gateway.mellatDesc':
'اولین درگاه. شناسه ترمینال، نام کاربری و رمز عبور را از پنل پذیرنده ملت وارد کنید.',
'شناسه ترمینال، نام کاربری و رمز عبور را از پنل پذیرنده ملت وارد کنید.',
'website.ePayment.gateway.sep': 'سپ (سامان کیش)',
'website.ePayment.gateway.sepDesc': 'درگاه پرداخت سامان — به‌زودی.',
'website.ePayment.gateway.snappay': 'اسنپ‌پی',
@@ -3379,7 +3397,7 @@ const fa: Record<MessageKey, string> = {
'title.sliders': 'اسلایدرها',
'title.specialCategories': 'دسته‌های ویژه',
'title.specialBrands': 'برندهای ویژه',
'title.specialItems': 'اقلام ویژه',
'title.specialItems': 'محصولات ویژه فروشگاه',
'title.contactForm': 'فرم تماس با ما',
'title.subscriptions': 'عضویت‌ها',
'title.faq': 'سوالات متداول',
@@ -3860,8 +3878,8 @@ export function getBusinessRouteTitleRules(locale: DashboardLocale): RouteTitleR
{ match: '/website/subscriptions', labels: [t('title.website'), t('title.subscriptions')] },
{ match: '/website/faq', labels: [t('title.website'), t('title.faq')] },
{ match: '/website/badges', labels: [t('title.website'), t('title.badges')] },
{ match: '/website/e-payment', labels: [t('title.website'), t('title.ePayment')] },
{ match: '/website', labels: [t('title.website')] },
{ match: '/settings', labels: [t('title.settings')] },
{ match: '/', labels: [t('title.home')] },
]
}
+6
View File
@@ -120,6 +120,12 @@ body::before {
}
}
/* Portaled dialogs sit on body; blur the app behind them. */
body:has([aria-modal='true']) #root {
filter: blur(var(--modal-overlay-blur, 16px));
pointer-events: none;
}
button {
font-family: inherit;
cursor: pointer;
+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() {
+12 -2
View File
@@ -36,6 +36,12 @@ export interface MellatGatewaySettings {
passwordSet?: boolean
}
export interface ZarinpalGatewaySettings {
enabled: boolean
merchantId: string
sandbox: boolean
}
export interface StubGatewaySettings {
enabled: boolean
}
@@ -48,7 +54,7 @@ export interface EPaymentSettings {
sep: StubGatewaySettings
snappay: StubGatewaySettings
digipay: StubGatewaySettings
zarinpal: StubGatewaySettings
zarinpal: ZarinpalGatewaySettings
}
}
@@ -72,7 +78,11 @@ export const DEFAULT_EPAYMENT_SETTINGS: EPaymentSettings = {
sep: { enabled: false },
snappay: { enabled: false },
digipay: { enabled: false },
zarinpal: { enabled: false },
zarinpal: {
enabled: false,
merchantId: '',
sandbox: false,
},
},
}
@@ -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') {
@@ -82,7 +82,7 @@
display: flex;
align-items: center;
gap: 12px;
padding: 6px 12px 6px 6px;
padding: 8px 16px;
border-radius: 50px;
background: rgba(255, 255, 255, 0.45);
backdrop-filter: blur(12px);
@@ -94,6 +94,7 @@
.profileInfo {
display: flex;
flex-direction: column;
padding: 0 4px;
}
.name {
+5
View File
@@ -164,3 +164,8 @@ textarea::placeholder {
font-family: var(--font-ui);
font-weight: 400;
}
body:has([aria-modal='true']) #root {
filter: blur(var(--modal-overlay-blur, 16px));
pointer-events: none;
}
@@ -1,6 +1,11 @@
.colName,
.colRole {
padding-inline: 22px;
}
.roleBadge {
display: inline-block;
padding: 3px 8px;
padding: 3px 12px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
@@ -9,6 +14,23 @@
border: 1px solid rgba(var(--primary-rgb) / 0.18);
}
.shieldSuperAdmin {
color: #0f172a;
}
.shieldSuperAdmin:hover:not(:disabled) {
color: #0f172a;
background: rgba(15, 23, 42, 0.08);
}
.shieldOwner {
color: var(--primary);
}
.shieldOwner:hover:not(:disabled) {
color: var(--primary);
}
.inactiveRow {
opacity: 0.55;
}
+31 -6
View File
@@ -41,6 +41,18 @@ function displayName(user: UserListItem) {
return name || '—'
}
function isSuperAdminUser(user: UserListItem) {
return user.roleSlug === 'super_admin' || /\bsuper admin\b/i.test(user.roles ?? '')
}
function isBusinessOwnerUser(user: UserListItem) {
return (
user.roleSlug === 'business_owner' ||
Boolean(user.isBusinessOwner) ||
/\bbusiness owner\b/i.test(user.roles ?? '')
)
}
const TEAM_ROLE_ORDER = ['admin', 'editor', 'viewer'] as const
const TEAM_ROLE_LABELS: Record<string, string> = {
@@ -644,9 +656,9 @@ export function UsersPage() {
<table className={tableStyles.table}>
<thead>
<tr>
<th className={tableStyles.th}>Name</th>
<th className={`${tableStyles.th} ${styles.colName}`}>Name</th>
<th className={tableStyles.th}>Cell number</th>
<th className={tableStyles.th}>Role</th>
<th className={`${tableStyles.th} ${styles.colRole}`}>Role</th>
<th className={tableStyles.th}>Business</th>
<th className={tableStyles.th}>Date joined</th>
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th>
@@ -672,12 +684,12 @@ export function UsersPage() {
{!loading &&
data?.items?.map((user) => (
<tr key={user.id} className={!user.isActive ? styles.inactiveRow : undefined}>
<td className={tableStyles.td}>
<td className={`${tableStyles.td} ${styles.colName}`}>
<div>{displayName(user)}</div>
{!user.isActive && <div className={styles.statusInactive}>Inactive</div>}
</td>
<td className={tableStyles.td}>{formatCellForDisplay(user.cellNumber)}</td>
<td className={tableStyles.td}>
<td className={`${tableStyles.td} ${styles.colRole}`}>
{(() => {
const label = businessFilter
? formatBusinessUserRoleLabel(user, teamRoles)
@@ -697,7 +709,13 @@ export function UsersPage() {
<div className={tableStyles.rowActions}>
<button
type="button"
className={tableStyles.controlBtn}
className={`${tableStyles.controlBtn} ${
isSuperAdminUser(user)
? styles.shieldSuperAdmin
: isBusinessOwnerUser(user)
? styles.shieldOwner
: ''
}`}
onClick={() => openRoleChange(user)}
title={
businessFilter && user.isBusinessOwner
@@ -711,7 +729,14 @@ export function UsersPage() {
}
disabled={!user.isActive || Boolean(businessFilter && user.isBusinessOwner)}
>
<Shield size={16} />
<Shield
size={16}
fill={
isSuperAdminUser(user) || isBusinessOwnerUser(user)
? 'currentColor'
: 'none'
}
/>
</button>
<button
type="button"
@@ -52,7 +52,7 @@
}
.deployStarted {
color: #b45309;
color: #0f172a;
}
.deployFail {
+2 -2
View File
@@ -3,7 +3,7 @@
> **For AI agents:** Read this file at the start of a new chat before making changes.
> Update this document when a major feature is completed or architecture changes.
Last updated: August 14, 2026
Last updated: August 16, 2026
---
@@ -222,7 +222,7 @@ Add to `/etc/hosts` (one line per tenant):
| `/website/subscriptions` | Subscriptions | Placeholder |
| `/website/faq` | FAQ | Placeholder |
| `/website/badges` | Badges | Placeholder |
| `/website/e-payment` | E-payment | Mellat credentials + multi-gateway stubs (SEP, Snapp Pay, DigiPay, Zarinpal) |
| `/settings` | General settings | E-payment: Mellat + ZarinPal credentials; stubs for SEP / Snapp Pay / DigiPay |
---