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 { BusinessDomainGuard } from './components/BusinessDomainGuard'
import { DashboardDocumentTitle } from './components/DashboardDocumentTitle' import { DashboardDocumentTitle } from './components/DashboardDocumentTitle'
import { AuthProvider } from './context/AuthContext' import { AuthProvider } from './context/AuthContext'
@@ -66,7 +66,7 @@ import { WebsiteContactPage } from './pages/WebsiteContactPage'
import { WebsiteSubscriptionsPage } from './pages/WebsiteSubscriptionsPage' import { WebsiteSubscriptionsPage } from './pages/WebsiteSubscriptionsPage'
import { WebsiteFaqPage } from './pages/WebsiteFaqPage' import { WebsiteFaqPage } from './pages/WebsiteFaqPage'
import { WebsiteBadgesPage } from './pages/WebsiteBadgesPage' import { WebsiteBadgesPage } from './pages/WebsiteBadgesPage'
import { WebsiteEPaymentPage } from './pages/WebsiteEPaymentPage' import { SettingsPage } from './pages/SettingsPage'
import { WebsiteSlidersPage } from './pages/WebsiteSlidersPage' import { WebsiteSlidersPage } from './pages/WebsiteSlidersPage'
import { WebsiteSpecialCategoriesPage } from './pages/WebsiteSpecialCategoriesPage' import { WebsiteSpecialCategoriesPage } from './pages/WebsiteSpecialCategoriesPage'
import { WebsiteSpecialBrandsPage } from './pages/WebsiteSpecialBrandsPage' import { WebsiteSpecialBrandsPage } from './pages/WebsiteSpecialBrandsPage'
@@ -91,6 +91,7 @@ function App() {
<Route index element={<HomePage />} /> <Route index element={<HomePage />} />
<Route path="profile" element={<ProfilePage />} /> <Route path="profile" element={<ProfilePage />} />
<Route path="business-profile" element={<BusinessProfilePage />} /> <Route path="business-profile" element={<BusinessProfilePage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="products" element={<ProductsPage />} /> <Route path="products" element={<ProductsPage />} />
<Route path="products/categories" element={<CategoriesPage />} /> <Route path="products/categories" element={<CategoriesPage />} />
<Route path="products/brands" element={<BrandsPage />} /> <Route path="products/brands" element={<BrandsPage />} />
@@ -155,7 +156,7 @@ function App() {
<Route path="website/subscriptions" element={<WebsiteSubscriptionsPage />} /> <Route path="website/subscriptions" element={<WebsiteSubscriptionsPage />} />
<Route path="website/faq" element={<WebsiteFaqPage />} /> <Route path="website/faq" element={<WebsiteFaqPage />} />
<Route path="website/badges" element={<WebsiteBadgesPage />} /> <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>
</Route> </Route>
</Routes> </Routes>
@@ -152,6 +152,19 @@
box-shadow: 0 6px 16px rgba(var(--primary-rgb) / 0.35); 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 { .modalIn {
animation: modalFadeIn 0.25s ease forwards; animation: modalFadeIn 0.25s ease forwards;
} }
@@ -81,18 +81,16 @@
} }
.dropdown { .dropdown {
position: absolute; position: static;
top: calc(100% + 6px); margin-top: 6px;
inset-inline: 0; max-height: none;
max-height: 260px; overflow: visible;
overflow-y: auto;
background: var(--elevated-surface); background: var(--elevated-surface);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow); box-shadow: var(--glass-shadow);
z-index: 30;
list-style: none; list-style: none;
padding: 6px; padding: 6px;
margin: 0; margin: 0;
@@ -81,18 +81,16 @@
} }
.dropdown { .dropdown {
position: absolute; position: static;
top: calc(100% + 6px); margin-top: 6px;
inset-inline: 0; max-height: none;
max-height: 220px; overflow: visible;
overflow-y: auto;
background: var(--elevated-surface); background: var(--elevated-surface);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow); box-shadow: var(--glass-shadow);
z-index: 20;
list-style: none; list-style: none;
padding: 6px; padding: 6px;
margin: 0; margin: 0;
@@ -64,15 +64,13 @@
} }
.dropdown { .dropdown {
position: absolute; position: static;
top: calc(100% + 4px); margin-top: 4px;
left: 0; z-index: auto;
right: 0;
z-index: 20;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: 240px; max-height: none;
overflow: hidden; overflow: visible;
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
@@ -108,6 +108,10 @@
margin-top: 24px; margin-top: 24px;
} }
.span4 {
grid-column: span 4;
}
.col6 { .col6 {
grid-column: span 6; grid-column: span 6;
} }
@@ -121,6 +125,10 @@
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 24px; gap: 24px;
} }
.span4 {
grid-column: span 2;
}
} }
@media (max-width: 1400px) { @media (max-width: 1400px) {
@@ -133,6 +141,10 @@
.gridHome { .gridHome {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
.span4 {
grid-column: span 2;
}
} }
@media (max-width: 900px) { @media (max-width: 900px) {
@@ -141,6 +153,10 @@
.gridCols4 { .gridCols4 {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.span4 {
grid-column: span 2;
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -173,7 +189,8 @@
gap: 16px; gap: 16px;
} }
.span4,
.col6 { .col6 {
grid-column: span 12; grid-column: span 1;
} }
} }
@@ -69,31 +69,29 @@
} }
.dropdown { .dropdown {
position: absolute; position: static;
top: calc(100% + 6px); margin-top: 6px;
left: 0; max-height: none;
right: 0; overflow: visible;
max-height: 220px;
overflow-y: auto;
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
box-shadow: 0 8px 24px rgba(31, 38, 135, 0.12); box-shadow: 0 8px 24px rgba(31, 38, 135, 0.12);
z-index: 10;
list-style: none; list-style: none;
padding: 6px; padding: 6px;
animation: dropdownIn 0.15s ease; 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 { .dropdownInFlow {
position: static; position: static;
top: auto; top: auto;
left: auto; left: auto;
right: auto; right: auto;
max-height: min(50vh, 400px); max-height: none;
overflow: visible;
margin-top: 6px; margin-top: 6px;
z-index: auto; z-index: auto;
} }
+3 -1
View File
@@ -8,6 +8,7 @@ interface SectionCardProps {
description: string description: string
linkText?: string linkText?: string
href: string href: string
className?: string
/** Entity total shown beside the arrow; omit for sections without a count (e.g. settings). */ /** Entity total shown beside the arrow; omit for sections without a count (e.g. settings). */
count?: number | null count?: number | null
countLabel?: string countLabel?: string
@@ -18,6 +19,7 @@ export function SectionCard({
title, title,
description, description,
href, href,
className,
count, count,
countLabel, countLabel,
}: SectionCardProps) { }: SectionCardProps) {
@@ -27,7 +29,7 @@ export function SectionCard({
: null : null
return ( return (
<Link to={href} className={styles.card}> <Link to={href} className={className ? `${styles.card} ${className}` : styles.card}>
<div className={styles.iconWrap}> <div className={styles.iconWrap}>
<Icon size={24} strokeWidth={1.75} /> <Icon size={24} strokeWidth={1.75} />
</div> </div>
-1
View File
@@ -212,7 +212,6 @@ export function Sidebar() {
{ labelKey: 'nav.website.subscriptions', to: '/website/subscriptions' }, { labelKey: 'nav.website.subscriptions', to: '/website/subscriptions' },
{ labelKey: 'nav.website.faq', to: '/website/faq' }, { labelKey: 'nav.website.faq', to: '/website/faq' },
{ labelKey: 'nav.website.badges', to: '/website/badges' }, { 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' }, { type: 'link', id: 'settings', icon: Settings, labelKey: 'nav.settings', to: '/settings' },
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
updateStoreItemDiscounts, updateStoreItemDiscounts,
@@ -15,6 +17,7 @@ import rowStyles from './CreateStoreItemsModal.module.css'
interface StoreItemDiscountModalProps { interface StoreItemDiscountModalProps {
open: boolean open: boolean
productTitle: string productTitle: string
productNameFa?: string
items: StoreItem[] items: StoreItem[]
onClose: () => void onClose: () => void
onSaved?: (items: StoreItem[]) => void onSaved?: (items: StoreItem[]) => void
@@ -33,10 +36,15 @@ const ANIMATION_MS = 220
export function StoreItemDiscountModal({ export function StoreItemDiscountModal({
open, open,
productTitle, productTitle,
productNameFa = '',
items, items,
onClose, onClose,
onSaved, onSaved,
}: StoreItemDiscountModalProps) { }: StoreItemDiscountModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const displayTitle = isFa ? productNameFa || productTitle : productTitle || productNameFa
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [rows, setRows] = useState<DiscountDraftRow[]>([]) const [rows, setRows] = useState<DiscountDraftRow[]>([])
@@ -113,7 +121,7 @@ export function StoreItemDiscountModal({
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to save discounts.') setError(t('storeItems.discount.errorSave'))
} }
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
@@ -131,24 +139,26 @@ export function StoreItemDiscountModal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="store-discount-title" aria-labelledby="store-discount-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
> >
<div className={modalStyles.header}> <div className={modalStyles.header}>
<div> <div>
<h3 id="store-discount-title" className={modalStyles.title}> <h3 id="store-discount-title" className={modalStyles.title}>
Set Discounts {t('storeItems.discount.title')}
</h3> </h3>
<p className={modalStyles.subtitle}>{productTitle}</p> <p className={modalStyles.subtitle}>{displayTitle}</p>
</div> </div>
<button className={modalStyles.closeBtn} onClick={onClose} aria-label="Close"> <button className={modalStyles.closeBtn} onClick={onClose} aria-label={t('common.close')}>
<X size={20} /> <X size={20} />
</button> </button>
</div> </div>
<form className={modalStyles.body} onSubmit={(e) => void handleSubmit(e)}> <form className={modalStyles.body} onSubmit={(e) => void handleSubmit(e)}>
<div className={rowStyles.discountHeader}> <div className={rowStyles.discountHeader}>
<span>Variant</span> <span>{t('storeItems.discount.variant')}</span>
<span>Regular price</span> <span>{t('storeItems.discount.regularPrice')}</span>
<span>Discounted price (IRT)</span> <span>{t('storeItems.discount.discountedPrice')}</span>
</div> </div>
<div className={rowStyles.discountRows}> <div className={rowStyles.discountRows}>
@@ -172,7 +182,7 @@ export function StoreItemDiscountModal({
), ),
) )
} }
placeholder="No discount" placeholder={t('storeItems.discount.placeholder')}
disabled={isSubmitting} disabled={isSubmitting}
/> />
</div> </div>
@@ -188,10 +198,10 @@ export function StoreItemDiscountModal({
onClick={onClose} onClick={onClose}
disabled={isSubmitting} disabled={isSubmitting}
> >
Cancel {t('products.form.cancel')}
</button> </button>
<button type="submit" className={modalStyles.submitBtn} disabled={!canSubmit}> <button type="submit" className={modalStyles.submitBtn} disabled={!canSubmit}>
{isSubmitting ? 'Saving…' : 'Save discounts'} {isSubmitting ? t('storeItems.discount.saving') : t('storeItems.discount.save')}
</button> </button>
</div> </div>
</form> </form>
@@ -4,10 +4,6 @@
gap: 12px; gap: 12px;
} }
.dropdown {
max-height: 280px;
}
.option { .option {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -125,7 +125,7 @@ export function StoreItemMultiSearchSelect({
</div> </div>
{showDropdown && ( {showDropdown && (
<ul className={`${selectStyles.dropdown} ${styles.dropdown}`} role="listbox"> <ul className={`${selectStyles.dropdown} ${selectStyles.dropdownInFlow}`} role="listbox">
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<li className={selectStyles.noResults}> <li className={selectStyles.noResults}>
{options.length === 0 {options.length === 0
@@ -164,7 +164,7 @@ export function StoreItemMultiSearchSelect({
)} )}
{open && query.trim().length > 0 && query.trim().length < minSearchLength && ( {open && query.trim().length > 0 && query.trim().length < minSearchLength && (
<ul className={selectStyles.dropdown}> <ul className={`${selectStyles.dropdown} ${selectStyles.dropdownInFlow}`}>
<li className={selectStyles.noResults}> <li className={selectStyles.noResults}>
{t('storeItems.search.minChars', { count: minSearchLength })} {t('storeItems.search.minChars', { count: minSearchLength })}
</li> </li>
@@ -19,11 +19,45 @@ interface StoreSpecialModalProps {
title?: string title?: string
submitLabel?: string submitLabel?: string
isSubmitting?: boolean isSubmitting?: boolean
error?: string
} }
const ANIMATION_MS = 220 const ANIMATION_MS = 220
const KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/ 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({ export function StoreSpecialModal({
open, open,
onClose, onClose,
@@ -33,6 +67,7 @@ export function StoreSpecialModal({
title, title,
submitLabel, submitLabel,
isSubmitting = false, isSubmitting = false,
error,
}: StoreSpecialModalProps) { }: StoreSpecialModalProps) {
const t = useT() const t = useT()
const { locale } = useLocale() const { locale } = useLocale()
@@ -42,6 +77,7 @@ export function StoreSpecialModal({
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [groupKey, setGroupKey] = useState(initialKey) const [groupKey, setGroupKey] = useState(initialKey)
const [groupTitle, setGroupTitle] = useState(initialTitle) const [groupTitle, setGroupTitle] = useState(initialTitle)
const [keyTouched, setKeyTouched] = useState(Boolean(initialKey))
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -49,6 +85,7 @@ export function StoreSpecialModal({
setClosing(false) setClosing(false)
setGroupKey(initialKey) setGroupKey(initialKey)
setGroupTitle(initialTitle) setGroupTitle(initialTitle)
setKeyTouched(Boolean(initialKey))
} else if (mounted) { } else if (mounted) {
setClosing(true) setClosing(true)
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -70,15 +107,22 @@ export function StoreSpecialModal({
if (!mounted) return null if (!mounted) return null
const trimmedKey = groupKey.trim() const resolved = resolveGroupFields(groupKey, groupTitle)
const trimmedTitle = groupTitle.trim() const canSubmit = resolved !== null
const keyValid = KEY_PATTERN.test(trimmedKey) const keySlug = slugifyGroupKey(groupKey)
const canSubmit = keyValid && trimmedTitle.length > 0 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>) { function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault() e.preventDefault()
if (!canSubmit) return if (!resolved) return
onSubmit({ key: trimmedKey, title: trimmedTitle }) onSubmit(resolved)
} }
return createPortal( return createPortal(
@@ -110,33 +154,47 @@ export function StoreSpecialModal({
</div> </div>
<form ref={formRef} className={styles.form} onSubmit={handleSubmit}> <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}> <div className={styles.field}>
<label htmlFor="special-group-key">{t('website.groupKey')}</label> <label htmlFor="special-group-key">{t('website.groupKey')}</label>
<input <input
id="special-group-key" id="special-group-key"
value={groupKey} value={groupKey}
onChange={(e) => setGroupKey(e.target.value)} onChange={(e) => {
setKeyTouched(true)
setGroupKey(e.target.value)
}}
placeholder={t('website.groupKeyPlaceholder')} placeholder={t('website.groupKeyPlaceholder')}
autoFocus
disabled={isSubmitting} disabled={isSubmitting}
dir="ltr" dir="ltr"
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
/> />
<p className={styles.hint}>{t('website.groupKeyHint')}</p> <p className={styles.hint}>{t('website.groupKeyHint')}</p>
{showKeyError ? (
<p className={styles.errorText} role="alert">
{t('website.groupKeyError')}
</p>
) : null}
</div> </div>
<div className={styles.field}> {error ? (
<label htmlFor="special-group-title">{t('website.groupValue')}</label> <p className={styles.errorText} role="alert">
<input {error}
id="special-group-title" </p>
value={groupTitle} ) : null}
onChange={(e) => setGroupTitle(e.target.value)}
placeholder={t('website.groupValuePlaceholder')}
disabled={isSubmitting}
dir={isFa ? 'rtl' : 'ltr'}
/>
</div>
<div className={styles.actions}> <div className={styles.actions}>
<button <button
@@ -2,8 +2,8 @@
position: fixed; position: fixed;
inset: 0; inset: 0;
background: var(--modal-overlay-bg); background: var(--modal-overlay-bg);
backdrop-filter: blur(var(--modal-overlay-blur)); backdrop-filter: blur(var(--modal-overlay-blur, 16px));
-webkit-backdrop-filter: blur(var(--modal-overlay-blur)); -webkit-backdrop-filter: blur(var(--modal-overlay-blur, 16px));
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -11,6 +11,14 @@
padding: 20px; padding: 20px;
} }
.overlayIn {
animation: overlayFadeIn 0.2s ease forwards;
}
.overlayOut {
animation: overlayFadeOut 0.18s ease forwards;
}
.overlayNested { .overlayNested {
z-index: 1010; z-index: 1010;
} }
@@ -417,6 +425,16 @@
color: #ef4444; color: #ef4444;
margin-bottom: 12px; margin-bottom: 12px;
} }
@keyframes overlayFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes overlayFadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes modalFadeIn { @keyframes modalFadeIn {
from { opacity: 0; transform: translateY(12px) scale(0.98); } from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); } 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.link': 'View portfolios',
'home.card.portfolios.count': 'portfolios', 'home.card.portfolios.count': 'portfolios',
'home.card.website.title': 'Website', '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.website.link': 'View website',
'home.card.finance.title': 'Finance Management', 'home.card.finance.title': 'Finance Management',
'home.card.finance.desc': 'Manage invoices and financial transactions.', 'home.card.finance.desc': 'Manage invoices and financial transactions.',
@@ -728,6 +728,14 @@ const en = {
'storeItems.search.noProducts': 'No products available.', 'storeItems.search.noProducts': 'No products available.',
'storeItems.search.noMatch': 'No matching products.', 'storeItems.search.noMatch': 'No matching products.',
'storeItems.search.minChars': 'Type at least {count} characters to search', '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.title': 'Orders',
'orders.subtitle': 'orders.subtitle':
@@ -1263,8 +1271,7 @@ const en = {
'website.card.faq.link': 'Manage FAQ', 'website.card.faq.link': 'Manage FAQ',
'website.card.badges.desc': 'Show trust badges, certifications, and highlights on your website.', 'website.card.badges.desc': 'Show trust badges, certifications, and highlights on your website.',
'website.card.badges.link': 'Manage badges', 'website.card.badges.link': 'Manage badges',
'website.card.ePayment.desc': 'Configure online payment gateways and checkout payment options.', 'settings.subtitle': 'Configure online payment gateways and checkout payment options.',
'website.card.ePayment.link': 'Manage e-payment',
'website.scrollLeft': 'Scroll left', 'website.scrollLeft': 'Scroll left',
'website.scrollRight': 'Scroll right', 'website.scrollRight': 'Scroll right',
@@ -1281,6 +1288,8 @@ const en = {
'website.groupKeyPlaceholder': 'e.g. top-selling', 'website.groupKeyPlaceholder': 'e.g. top-selling',
'website.groupKeyHint': 'website.groupKeyHint':
'English only — letters, numbers, hyphens, underscores. Used by websites.', '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.groupValue': 'Display title',
'website.groupValuePlaceholder': 'Shown on the website in any language', 'website.groupValuePlaceholder': 'Shown on the website in any language',
'website.removeFromGroupTitle': 'Remove from Group', 'website.removeFromGroupTitle': 'Remove from Group',
@@ -1461,10 +1470,10 @@ const en = {
'website.ePayment.subtitle': 'Configure online payment methods for your website checkout.', 'website.ePayment.subtitle': 'Configure online payment methods for your website checkout.',
'website.ePayment.master': 'Enable online payments', 'website.ePayment.master': 'Enable online payments',
'website.ePayment.masterDesc': '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.mellat': 'Bank Mellat (Behpardakht)',
'website.ePayment.gateway.mellatDesc': '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.sep': 'SEP (Saman Kish)',
'website.ePayment.gateway.sepDesc': 'Saman electronic payment — coming soon.', 'website.ePayment.gateway.sepDesc': 'Saman electronic payment — coming soon.',
'website.ePayment.gateway.snappay': 'Snapp Pay', 'website.ePayment.gateway.snappay': 'Snapp Pay',
@@ -1916,7 +1925,7 @@ const fa: Record<MessageKey, string> = {
'nav.website.sliders': 'اسلایدرها', 'nav.website.sliders': 'اسلایدرها',
'nav.website.specialCategories': 'دسته‌های ویژه', 'nav.website.specialCategories': 'دسته‌های ویژه',
'nav.website.specialBrands': 'برندهای ویژه', 'nav.website.specialBrands': 'برندهای ویژه',
'nav.website.specialItems': 'اقلام ویژه', 'nav.website.specialItems': 'محصولات ویژه فروشگاه',
'nav.website.contact': 'فرم تماس با ما', 'nav.website.contact': 'فرم تماس با ما',
'nav.website.subscriptions': 'عضویت‌ها', 'nav.website.subscriptions': 'عضویت‌ها',
'nav.website.faq': 'سوالات متداول', 'nav.website.faq': 'سوالات متداول',
@@ -2062,7 +2071,7 @@ const fa: Record<MessageKey, string> = {
'home.card.videos.link': 'مشاهده ویدیوها', 'home.card.videos.link': 'مشاهده ویدیوها',
'home.card.videos.count': 'ویدیو', 'home.card.videos.count': 'ویدیو',
'home.card.website.title': 'وب‌سایت', 'home.card.website.title': 'وب‌سایت',
'home.card.website.desc': 'فرم تماس، سوالات متداول، نشان‌ها، عضویت‌ها و پرداخت الکترونیک.', 'home.card.website.desc': 'فرم تماس، سوالات متداول، نشان‌ها و عضویت‌ها.',
'home.card.website.link': 'مشاهده وب‌سایت', 'home.card.website.link': 'مشاهده وب‌سایت',
'home.card.finance.title': 'مدیریت مالی', 'home.card.finance.title': 'مدیریت مالی',
'home.card.finance.desc': 'فاکتورها و تراکنش‌های مالی را مدیریت کنید.', 'home.card.finance.desc': 'فاکتورها و تراکنش‌های مالی را مدیریت کنید.',
@@ -2570,6 +2579,14 @@ const fa: Record<MessageKey, string> = {
'storeItems.search.noProducts': 'محصولی موجود نیست.', 'storeItems.search.noProducts': 'محصولی موجود نیست.',
'storeItems.search.noMatch': 'محصولی یافت نشد.', 'storeItems.search.noMatch': 'محصولی یافت نشد.',
'storeItems.search.minChars': 'حداقل {count} کاراکتر برای جستجو وارد کنید', '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.title': 'سفارش‌ها',
'orders.subtitle': 'orders.subtitle':
@@ -3096,8 +3113,8 @@ const fa: Record<MessageKey, string> = {
'website.card.specialBrands.desc': 'برندهای شریک یا ویژه را در صفحه اصلی وب‌سایت نمایش دهید.', 'website.card.specialBrands.desc': 'برندهای شریک یا ویژه را در صفحه اصلی وب‌سایت نمایش دهید.',
'website.card.specialBrands.link': 'مدیریت برندها', 'website.card.specialBrands.link': 'مدیریت برندها',
'website.card.specialItems.desc': 'website.card.specialItems.desc':
'اقلام فروشگاه ویژه را در دسته‌ها برای کاروسل‌های وب‌سایت گردآوری کنید.', 'محصولات ویژه فروشگاه را در دسته‌ها برای کاروسل‌های وب‌سایت گردآوری کنید.',
'website.card.specialItems.link': 'مدیریت اقلام ویژه', 'website.card.specialItems.link': 'مدیریت محصولات ویژه',
'website.card.contact.desc': 'ارسال‌های فرم تماس وب‌سایت را مشاهده کنید.', 'website.card.contact.desc': 'ارسال‌های فرم تماس وب‌سایت را مشاهده کنید.',
'website.card.contact.link': 'مشاهده ارسال‌ها', 'website.card.contact.link': 'مشاهده ارسال‌ها',
'website.card.subscriptions.desc': 'عضویت خبرنامه و گزینه‌های اشتراک بازدیدکنندگان را مدیریت کنید.', 'website.card.subscriptions.desc': 'عضویت خبرنامه و گزینه‌های اشتراک بازدیدکنندگان را مدیریت کنید.',
@@ -3106,8 +3123,7 @@ const fa: Record<MessageKey, string> = {
'website.card.faq.link': 'مدیریت سوالات متداول', 'website.card.faq.link': 'مدیریت سوالات متداول',
'website.card.badges.desc': 'نشان‌های اعتماد، گواهی‌ها و برجسته‌ها را در وب‌سایت نمایش دهید.', 'website.card.badges.desc': 'نشان‌های اعتماد، گواهی‌ها و برجسته‌ها را در وب‌سایت نمایش دهید.',
'website.card.badges.link': 'مدیریت نشان‌ها', 'website.card.badges.link': 'مدیریت نشان‌ها',
'website.card.ePayment.desc': 'درگاه‌های پرداخت آنلاین و گزینه‌های تسویه حساب را پیکربندی کنید.', 'settings.subtitle': 'درگاه‌های پرداخت آنلاین و گزینه‌های تسویه حساب را پیکربندی کنید.',
'website.card.ePayment.link': 'مدیریت پرداخت الکترونیک',
'website.scrollLeft': 'اسکرول به چپ', 'website.scrollLeft': 'اسکرول به چپ',
'website.scrollRight': 'اسکرول به راست', 'website.scrollRight': 'اسکرول به راست',
@@ -3124,6 +3140,8 @@ const fa: Record<MessageKey, string> = {
'website.groupKeyPlaceholder': 'مثلاً: top-selling', 'website.groupKeyPlaceholder': 'مثلاً: top-selling',
'website.groupKeyHint': 'website.groupKeyHint':
'فقط انگلیسی — حروف، اعداد، خط تیره و زیرخط. برای استفاده وب‌سایت.', 'فقط انگلیسی — حروف، اعداد، خط تیره و زیرخط. برای استفاده وب‌سایت.',
'website.groupKeyError':
'فقط حروف انگلیسی، اعداد، خط تیره یا زیرخط — مثلاً special-sale.',
'website.groupValue': 'عنوان نمایشی', 'website.groupValue': 'عنوان نمایشی',
'website.groupValuePlaceholder': 'عنوانی که در وب‌سایت نمایش داده می‌شود (هر زبانی)', 'website.groupValuePlaceholder': 'عنوانی که در وب‌سایت نمایش داده می‌شود (هر زبانی)',
'website.removeFromGroupTitle': 'حذف از گروه', 'website.removeFromGroupTitle': 'حذف از گروه',
@@ -3217,40 +3235,40 @@ const fa: Record<MessageKey, string> = {
'website.specialBrands.pickErrorLoad': 'بارگذاری برندها ممکن نشد.', 'website.specialBrands.pickErrorLoad': 'بارگذاری برندها ممکن نشد.',
'website.specialItems.subtitle': 'website.specialItems.subtitle':
'اقلام فروشگاه ویژه را برای کاروسل‌های وب‌سایت در دسته‌ها گردآوری کنید.', 'محصولات ویژه فروشگاه را برای کاروسل‌های وب‌سایت در دسته‌ها گردآوری کنید.',
'website.specialItems.loading': 'در حال بارگذاری دسته‌های ویژه...', 'website.specialItems.loading': 'در حال بارگذاری دسته‌های ویژه...',
'website.specialItems.empty': 'website.specialItems.empty':
'هنوز دسته ویژه‌ای نیست. با دکمه + یکی بسازید، سپس اقلام فروشگاه را به هر کاروسل اضافه کنید.', 'هنوز دسته ویژه‌ای نیست. با دکمه + یکی بسازید، سپس محصولات فروشگاه را به هر کاروسل اضافه کنید.',
'website.specialItems.errorLoad': 'بارگذاری دسته‌های ویژه ممکن نشد.', 'website.specialItems.errorLoad': 'بارگذاری دسته‌های ویژه ممکن نشد.',
'website.specialItems.errorCreate': 'ایجاد دسته ویژه ممکن نشد.', 'website.specialItems.errorCreate': 'ایجاد دسته ویژه ممکن نشد.',
'website.specialItems.errorUpdate': 'به‌روزرسانی دسته ویژه ممکن نشد.', 'website.specialItems.errorUpdate': 'به‌روزرسانی دسته ویژه ممکن نشد.',
'website.specialItems.errorDelete': 'حذف دسته ویژه ممکن نشد.', 'website.specialItems.errorDelete': 'حذف دسته ویژه ممکن نشد.',
'website.specialItems.errorAdd': 'افزودن اقلام فروشگاه ممکن نشد.', 'website.specialItems.errorAdd': 'افزودن محصولات فروشگاه ممکن نشد.',
'website.specialItems.errorRemove': 'حذف قلم فروشگاه ممکن نشد.', 'website.specialItems.errorRemove': 'حذف محصول فروشگاه ممکن نشد.',
'website.specialItems.toastCreated': 'دسته ویژه ایجاد شد.', 'website.specialItems.toastCreated': 'دسته ویژه ایجاد شد.',
'website.specialItems.toastUpdated': 'دسته ویژه به‌روزرسانی شد.', 'website.specialItems.toastUpdated': 'دسته ویژه به‌روزرسانی شد.',
'website.specialItems.toastDeleted': 'دسته ویژه حذف شد.', 'website.specialItems.toastDeleted': 'دسته ویژه حذف شد.',
'website.specialItems.toastAdded': 'اقلام فروشگاه به دسته ویژه افزوده شد.', 'website.specialItems.toastAdded': 'محصولات فروشگاه به دسته ویژه افزوده شد.',
'website.specialItems.toastRemoved': 'از دسته ویژه حذف شد.', 'website.specialItems.toastRemoved': 'از دسته ویژه حذف شد.',
'website.specialItems.addFab': 'افزودن دسته ویژه', 'website.specialItems.addFab': 'افزودن دسته ویژه',
'website.specialItems.createTitle': 'افزودن دسته ویژه', 'website.specialItems.createTitle': 'افزودن دسته ویژه',
'website.specialItems.editTitle': 'ویرایش دسته ویژه', 'website.specialItems.editTitle': 'ویرایش دسته ویژه',
'website.specialItems.deleteTitle': 'حذف دسته ویژه', 'website.specialItems.deleteTitle': 'حذف دسته ویژه',
'website.specialItems.deleteMessage': 'website.specialItems.deleteMessage':
'«{title}» حذف شود؟ اقلام فروشگاه در کاتالوگ باقی می‌مانند.', '«{title}» حذف شود؟ محصولات فروشگاه در کاتالوگ باقی می‌مانند.',
'website.specialItems.removeTitle': 'حذف از ویژه', 'website.specialItems.removeTitle': 'حذف از ویژه',
'website.specialItems.removeMessage': '«{name}» از «{group}» حذف شود؟', 'website.specialItems.removeMessage': '«{name}» از «{group}» حذف شود؟',
'website.specialItems.editCategory': 'ویرایش دسته', 'website.specialItems.editCategory': 'ویرایش دسته',
'website.specialItems.deleteCategory': 'حذف دسته', 'website.specialItems.deleteCategory': 'حذف دسته',
'website.specialItems.scrollLeft': 'اسکرول به چپ', 'website.specialItems.scrollLeft': 'اسکرول به چپ',
'website.specialItems.scrollRight': 'اسکرول به راست', 'website.specialItems.scrollRight': 'اسکرول به راست',
'website.specialItems.addItemsTo': 'افزودن اقلام فروشگاه به {title}', 'website.specialItems.addItemsTo': 'افزودن محصولات فروشگاه به {title}',
'website.specialItems.removeFromSpecial': 'حذف از ویژه', 'website.specialItems.removeFromSpecial': 'حذف از ویژه',
'website.specialItems.pickSubtitle': 'اقلام فروشگاه را برای نمایش در این دسته انتخاب کنید.', 'website.specialItems.pickSubtitle': 'محصولات فروشگاه را برای نمایش در این دسته انتخاب کنید.',
'website.specialItems.pickLoading': 'در حال بارگذاری اقلام فروشگاه...', 'website.specialItems.pickLoading': 'در حال بارگذاری محصولات فروشگاه...',
'website.specialItems.pickEmpty': 'همه اقلام فروشگاه قبلاً در این دسته هستند.', 'website.specialItems.pickEmpty': 'همه محصولات فروشگاه قبلاً در این دسته هستند.',
'website.specialItems.pickSearch': 'حداقل ۳ حرف برای جستجوی اقلام فروشگاه تایپ کنید', 'website.specialItems.pickSearch': 'حداقل ۳ حرف برای جستجوی محصولات فروشگاه تایپ کنید',
'website.specialItems.pickErrorLoad': 'بارگذاری اقلام فروشگاه ممکن نشد.', 'website.specialItems.pickErrorLoad': 'بارگذاری محصولات فروشگاه ممکن نشد.',
'website.pick.addOneItem': 'افزودن ۱ قلم', 'website.pick.addOneItem': 'افزودن ۱ قلم',
'website.pick.addItems': 'افزودن {count} قلم', 'website.pick.addItems': 'افزودن {count} قلم',
@@ -3301,10 +3319,10 @@ const fa: Record<MessageKey, string> = {
'website.ePayment.subtitle': 'روش‌های پرداخت آنلاین برای تسویه حساب وب‌سایت را پیکربندی کنید.', 'website.ePayment.subtitle': 'روش‌های پرداخت آنلاین برای تسویه حساب وب‌سایت را پیکربندی کنید.',
'website.ePayment.master': 'فعال‌سازی پرداخت آنلاین', 'website.ePayment.master': 'فعال‌سازی پرداخت آنلاین',
'website.ePayment.masterDesc': 'website.ePayment.masterDesc':
'با فعال بودن این گزینه، مشتریان در تسویه حساب وب‌سایت می‌توانند از درگاه پیکربندی‌شده پرداخت کنند.', 'با فعال بودن هر درگاه و پیکربندی آن، مشتریان در تسویه حساب وبسایت و یا اپلیکیشن از آن درگاه استفاده کنند.',
'website.ePayment.gateway.mellat': 'بانک ملت (به‌پرداخت)', 'website.ePayment.gateway.mellat': 'بانک ملت (به‌پرداخت)',
'website.ePayment.gateway.mellatDesc': 'website.ePayment.gateway.mellatDesc':
'اولین درگاه. شناسه ترمینال، نام کاربری و رمز عبور را از پنل پذیرنده ملت وارد کنید.', 'شناسه ترمینال، نام کاربری و رمز عبور را از پنل پذیرنده ملت وارد کنید.',
'website.ePayment.gateway.sep': 'سپ (سامان کیش)', 'website.ePayment.gateway.sep': 'سپ (سامان کیش)',
'website.ePayment.gateway.sepDesc': 'درگاه پرداخت سامان — به‌زودی.', 'website.ePayment.gateway.sepDesc': 'درگاه پرداخت سامان — به‌زودی.',
'website.ePayment.gateway.snappay': 'اسنپ‌پی', 'website.ePayment.gateway.snappay': 'اسنپ‌پی',
@@ -3379,7 +3397,7 @@ const fa: Record<MessageKey, string> = {
'title.sliders': 'اسلایدرها', 'title.sliders': 'اسلایدرها',
'title.specialCategories': 'دسته‌های ویژه', 'title.specialCategories': 'دسته‌های ویژه',
'title.specialBrands': 'برندهای ویژه', 'title.specialBrands': 'برندهای ویژه',
'title.specialItems': 'اقلام ویژه', 'title.specialItems': 'محصولات ویژه فروشگاه',
'title.contactForm': 'فرم تماس با ما', 'title.contactForm': 'فرم تماس با ما',
'title.subscriptions': 'عضویت‌ها', 'title.subscriptions': 'عضویت‌ها',
'title.faq': 'سوالات متداول', 'title.faq': 'سوالات متداول',
@@ -3860,8 +3878,8 @@ export function getBusinessRouteTitleRules(locale: DashboardLocale): RouteTitleR
{ match: '/website/subscriptions', labels: [t('title.website'), t('title.subscriptions')] }, { match: '/website/subscriptions', labels: [t('title.website'), t('title.subscriptions')] },
{ match: '/website/faq', labels: [t('title.website'), t('title.faq')] }, { match: '/website/faq', labels: [t('title.website'), t('title.faq')] },
{ match: '/website/badges', labels: [t('title.website'), t('title.badges')] }, { 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: '/website', labels: [t('title.website')] },
{ match: '/settings', labels: [t('title.settings')] },
{ match: '/', labels: [t('title.home')] }, { 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 { button {
font-family: inherit; font-family: inherit;
cursor: pointer; cursor: pointer;
+4
View File
@@ -40,6 +40,8 @@ const sections: {
countLabelKey?: BusinessMessageKey countLabelKey?: BusinessMessageKey
href: string href: string
countKey?: CountKey countKey?: CountKey
/** Double-width tile (4 of 6 home columns). */
wide?: boolean
/** When set, card is shown only if this optional module is enabled. */ /** When set, card is shown only if this optional module is enabled. */
moduleId?: BusinessModuleId moduleId?: BusinessModuleId
/** When set, card is shown if any listed module is enabled. */ /** When set, card is shown if any listed module is enabled. */
@@ -63,6 +65,7 @@ const sections: {
countLabelKey: 'home.card.store.count', countLabelKey: 'home.card.store.count',
href: '/store', href: '/store',
countKey: 'store', countKey: 'store',
wide: true,
moduleId: 'store', moduleId: 'store',
}, },
{ {
@@ -185,6 +188,7 @@ export function HomePage() {
description={t(section.descKey)} description={t(section.descKey)}
linkText={t(section.linkKey)} linkText={t(section.linkKey)}
href={section.href} href={section.href}
className={section.wide ? styles.span4 : undefined}
count={section.countKey ? counts[section.countKey] : undefined} count={section.countKey ? counts[section.countKey] : undefined}
countLabel={section.countLabelKey ? t(section.countLabelKey) : undefined} countLabel={section.countLabelKey ? t(section.countLabelKey) : undefined}
/> />
@@ -42,7 +42,7 @@
.credentials { .credentials {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 12px 16px; gap: 12px 16px;
margin-top: 18px; margin-top: 18px;
padding-top: 18px; padding-top: 18px;
@@ -50,10 +50,11 @@
} }
.field { .field {
grid-column: span 3;
min-width: 0; min-width: 0;
} }
.field:last-of-type { .fullRow {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
@@ -142,6 +143,10 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.field {
grid-column: 1 / -1;
}
.gatewayHeader { .gatewayHeader {
flex-direction: column; flex-direction: column;
} }
@@ -1,6 +1,7 @@
import { useEffect, useState, type FormEvent } from 'react' import { useEffect, useState, type FormEvent } from 'react'
import { Eye, EyeOff } from 'lucide-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 { Switch } from '../components/Switch'
import { Tooltip } from '../components/Tooltip' import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
@@ -14,12 +15,13 @@ import {
type EPaymentSettings, type EPaymentSettings,
type PaymentGatewayId, type PaymentGatewayId,
} from '../services/settingsService' } from '../services/settingsService'
import pageStyles from '../components/PageContent.module.css'
import sharedStyles from './ProductSettingsPage.module.css' import sharedStyles from './ProductSettingsPage.module.css'
import fieldStyles from './AddNewProductPage.module.css' import fieldStyles from './AddNewProductPage.module.css'
import styles from './WebsiteEPaymentPage.module.css' import styles from './SettingsPage.module.css'
const COMING_SOON_GATEWAYS: { const COMING_SOON_GATEWAYS: {
id: Exclude<PaymentGatewayId, 'mellat'> id: Exclude<PaymentGatewayId, 'mellat' | 'zarinpal'>
nameKey: BusinessMessageKey nameKey: BusinessMessageKey
descKey: BusinessMessageKey descKey: BusinessMessageKey
}[] = [ }[] = [
@@ -38,14 +40,25 @@ const COMING_SOON_GATEWAYS: {
nameKey: 'website.ePayment.gateway.digipay', nameKey: 'website.ePayment.gateway.digipay',
descKey: 'website.ePayment.gateway.digipayDesc', 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 t = useT()
const { showToast } = useToast() const { showToast } = useToast()
const [ePayment, setEPayment] = useState<EPaymentSettings>(DEFAULT_EPAYMENT_SETTINGS) const [ePayment, setEPayment] = useState<EPaymentSettings>(DEFAULT_EPAYMENT_SETTINGS)
@@ -98,6 +111,10 @@ export function WebsiteEPaymentPage() {
...next.gateways.mellat, ...next.gateways.mellat,
password: password ?? '', password: password ?? '',
}, },
zarinpal: {
...next.gateways.zarinpal,
merchantId: next.gateways.zarinpal.merchantId.trim(),
},
}, },
} }
@@ -121,7 +138,12 @@ export function WebsiteEPaymentPage() {
...ePayment, ...ePayment,
enabled: checked, enabled: checked,
defaultGateway: checked defaultGateway: checked
? ePayment.defaultGateway ?? 'mellat' ? ePayment.defaultGateway ??
(ePayment.gateways.mellat.enabled
? 'mellat'
: ePayment.gateways.zarinpal.enabled
? 'zarinpal'
: 'mellat')
: ePayment.defaultGateway, : ePayment.defaultGateway,
}) })
} }
@@ -133,8 +155,18 @@ export function WebsiteEPaymentPage() {
...ePayment.gateways, ...ePayment.gateways,
mellat: { ...ePayment.gateways.mellat, enabled: checked }, mellat: { ...ePayment.gateways.mellat, enabled: checked },
}, },
defaultGateway: defaultGateway: nextDefaultGateway(ePayment, 'mellat', checked),
checked && !ePayment.defaultGateway ? 'mellat' : ePayment.defaultGateway, })
}
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 ( return (
<WebsiteSectionShell <main className={pageStyles.content}>
sectionLabel="E-Payment" <Breadcrumbs
title={t('title.ePayment')} items={[
subtitle={t('website.ePayment.subtitle')} { 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} {error ? <div className={sharedStyles.alertError}>{error}</div> : null}
{isLoading ? ( {isLoading ? (
<p className={sharedStyles.status}>{t('productSettings.loading')}</p> <p className={sharedStyles.status}>{t('productSettings.loading')}</p>
) : ( ) : (
<div className={styles.stack}> <div className={styles.stack}>
<div className={sharedStyles.list}> <section className={sharedStyles.panel}>
<div className={sharedStyles.row}> <h3 className={sharedStyles.sectionTitle}>{t('title.ePayment')}</h3>
<div className={sharedStyles.rowText}> <div className={sharedStyles.list}>
<label className={sharedStyles.rowLabel} htmlFor="epayment-master"> <div className={sharedStyles.row}>
{t('website.ePayment.master')} <div className={sharedStyles.rowText}>
</label> <label className={sharedStyles.rowLabel} htmlFor="epayment-master">
<p className={sharedStyles.rowDescription}> {t('website.ePayment.master')}
{t('website.ePayment.masterDesc')} </label>
</p> <p className={sharedStyles.rowDescription}>
{t('website.ePayment.masterDesc')}
</p>
</div>
<Switch
id="epayment-master"
checked={ePayment.enabled}
disabled={savingKey !== null}
onChange={(checked) => void handleMasterToggle(checked)}
/>
</div> </div>
<Switch
id="epayment-master"
checked={ePayment.enabled}
disabled={savingKey !== null}
onChange={(checked) => void handleMasterToggle(checked)}
/>
</div> </div>
</div> </section>
<section className={styles.gatewayCard} data-card-hover> <section className={styles.gatewayCard} data-card-hover>
<div className={styles.gatewayHeader}> <div className={styles.gatewayHeader}>
@@ -339,6 +405,95 @@ export function WebsiteEPaymentPage() {
</form> </form>
</section> </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}> <div className={styles.comingSoonGrid}>
{COMING_SOON_GATEWAYS.map((gateway) => ( {COMING_SOON_GATEWAYS.map((gateway) => (
<section key={gateway.id} className={styles.comingSoonCard}> <section key={gateway.id} className={styles.comingSoonCard}>
@@ -354,6 +509,6 @@ export function WebsiteEPaymentPage() {
</div> </div>
</div> </div>
)} )}
</WebsiteSectionShell> </main>
) )
} }
@@ -468,6 +468,7 @@ function StoreItemsPageContent() {
<StoreItemDiscountModal <StoreItemDiscountModal
open={!!discountTarget} open={!!discountTarget}
productTitle={discountTarget?.productTitle ?? ''} productTitle={discountTarget?.productTitle ?? ''}
productNameFa={discountTarget?.productNameFa ?? ''}
items={productItems} items={productItems}
onClose={() => setDiscountTarget(null)} onClose={() => setDiscountTarget(null)}
onSaved={mergeUpdatedItems} onSaved={mergeUpdatedItems}
@@ -44,7 +44,11 @@ const DEFAULT_STORE_SETTINGS: StoreSettings = {
sep: { enabled: false }, sep: { enabled: false },
snappay: { enabled: false }, snappay: { enabled: false },
digipay: { 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) setCreateOpen(false)
showToast(t('website.specialItems.toastCreated'), 'success') showToast(t('website.specialItems.toastCreated'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { const message = err instanceof ApiError ? err.message : t('website.specialItems.errorCreate')
setError(err.message) setError(message)
} else { showToast(message, 'error')
setError(t('website.specialItems.errorCreate'))
}
} finally { } finally {
setIsSaving(false) setIsSaving(false)
} }
@@ -183,11 +181,9 @@ export function StoreSpecialsPage() {
setEditSpecialTarget(null) setEditSpecialTarget(null)
showToast(t('website.specialItems.toastUpdated'), 'success') showToast(t('website.specialItems.toastUpdated'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { const message = err instanceof ApiError ? err.message : t('website.specialItems.errorUpdate')
setError(err.message) setError(message)
} else { showToast(message, 'error')
setError(t('website.specialItems.errorUpdate'))
}
} finally { } finally {
setIsSaving(false) setIsSaving(false)
} }
@@ -362,7 +358,10 @@ export function StoreSpecialsPage() {
<button <button
type="button" type="button"
className={fabStyles.addFab} className={fabStyles.addFab}
onClick={() => setCreateOpen(true)} onClick={() => {
setError('')
setCreateOpen(true)
}}
aria-label={t('website.specialItems.addFab')} aria-label={t('website.specialItems.addFab')}
> >
<Plus size={24} /> <Plus size={24} />
@@ -376,6 +375,7 @@ export function StoreSpecialsPage() {
title={t('website.specialItems.createTitle')} title={t('website.specialItems.createTitle')}
submitLabel={t('website.create')} submitLabel={t('website.create')}
isSubmitting={isSaving} isSubmitting={isSaving}
error={createOpen ? error : undefined}
/> />
<StoreSpecialModal <StoreSpecialModal
@@ -387,6 +387,7 @@ export function StoreSpecialsPage() {
title={t('website.specialItems.editTitle')} title={t('website.specialItems.editTitle')}
submitLabel={t('website.save')} submitLabel={t('website.save')}
isSubmitting={isSaving} isSubmitting={isSaving}
error={editSpecialTarget ? error : undefined}
/> />
<PickStoreSpecialItemsModal <PickStoreSpecialItemsModal
@@ -410,6 +411,7 @@ export function StoreSpecialsPage() {
<StoreItemDiscountModal <StoreItemDiscountModal
open={!!discountTarget} open={!!discountTarget}
productTitle={discountTarget?.productTitle ?? ''} productTitle={discountTarget?.productTitle ?? ''}
productNameFa={discountTarget?.productNameFa ?? ''}
items={productItems} items={productItems}
onClose={() => setDiscountTarget(null)} onClose={() => setDiscountTarget(null)}
onSaved={mergeUpdatedItems} onSaved={mergeUpdatedItems}
-8
View File
@@ -3,7 +3,6 @@ import {
BellRing, BellRing,
CircleHelp, CircleHelp,
Award, Award,
CreditCard,
Images, Images,
LayoutGrid, LayoutGrid,
Building2, Building2,
@@ -79,13 +78,6 @@ const websiteSections: {
linkKey: 'website.card.badges.link', linkKey: 'website.card.badges.link',
href: '/website/badges', 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() { export function WebsitePage() {
+12 -2
View File
@@ -36,6 +36,12 @@ export interface MellatGatewaySettings {
passwordSet?: boolean passwordSet?: boolean
} }
export interface ZarinpalGatewaySettings {
enabled: boolean
merchantId: string
sandbox: boolean
}
export interface StubGatewaySettings { export interface StubGatewaySettings {
enabled: boolean enabled: boolean
} }
@@ -48,7 +54,7 @@ export interface EPaymentSettings {
sep: StubGatewaySettings sep: StubGatewaySettings
snappay: StubGatewaySettings snappay: StubGatewaySettings
digipay: StubGatewaySettings digipay: StubGatewaySettings
zarinpal: StubGatewaySettings zarinpal: ZarinpalGatewaySettings
} }
} }
@@ -72,7 +78,11 @@ export const DEFAULT_EPAYMENT_SETTINGS: EPaymentSettings = {
sep: { enabled: false }, sep: { enabled: false },
snappay: { enabled: false }, snappay: { enabled: false },
digipay: { enabled: false }, digipay: { enabled: false },
zarinpal: { enabled: false }, zarinpal: {
enabled: false,
merchantId: '',
sandbox: false,
},
}, },
} }
@@ -81,18 +81,16 @@
} }
.dropdown { .dropdown {
position: absolute; position: static;
top: calc(100% + 6px); margin-top: 6px;
inset-inline: 0; max-height: none;
max-height: 260px; overflow: visible;
overflow-y: auto;
background: var(--elevated-surface); background: var(--elevated-surface);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow); box-shadow: var(--glass-shadow);
z-index: 30;
list-style: none; list-style: none;
padding: 6px; padding: 6px;
margin: 0; margin: 0;
@@ -81,18 +81,16 @@
} }
.dropdown { .dropdown {
position: absolute; position: static;
top: calc(100% + 6px); margin-top: 6px;
inset-inline: 0; max-height: none;
max-height: 220px; overflow: visible;
overflow-y: auto;
background: var(--elevated-surface); background: var(--elevated-surface);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow); box-shadow: var(--glass-shadow);
z-index: 20;
list-style: none; list-style: none;
padding: 6px; padding: 6px;
margin: 0; 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 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%); 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 { import {
checkoutCart, checkoutCart,
getCart, getCart,
getPaymentMethods,
submitPaymentRedirect, submitPaymentRedirect,
type CheckoutPaymentType, type CheckoutPaymentType,
type OnlineGatewayType, type OnlineGatewayType,
type PublicPaymentGateway,
} from '../../services/cartService' } from '../../services/cartService'
import { syncGuestCartToServer } from '../../services/syncGuestCart' import { syncGuestCartToServer } from '../../services/syncGuestCart'
import { formatIrtPrice } from '../../utils/irtPrice' import { formatIrtPrice } from '../../utils/irtPrice'
@@ -17,11 +19,6 @@ import { CheckoutActionBar } from '../../components/checkout/CheckoutActionBar'
import { CheckoutBackButton } from '../../components/checkout/CheckoutBackButton' import { CheckoutBackButton } from '../../components/checkout/CheckoutBackButton'
import styles from './CheckoutSteps.module.css' 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() { export function CheckoutPaymentStep() {
const navigate = useNavigate() const navigate = useNavigate()
const { showToast } = useToast() const { showToast } = useToast()
@@ -31,6 +28,8 @@ export function CheckoutPaymentStep() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [onlineGateways, setOnlineGateways] = useState<PublicPaymentGateway[]>([])
const [onlinePaymentsEnabled, setOnlinePaymentsEnabled] = useState(false)
const [paymentType, setPaymentType] = useState<CheckoutPaymentType>('e_payment_gate') const [paymentType, setPaymentType] = useState<CheckoutPaymentType>('e_payment_gate')
const [gatewayType, setGatewayType] = useState<OnlineGatewayType>('mellat') const [gatewayType, setGatewayType] = useState<OnlineGatewayType>('mellat')
const [transferAccount, setTransferAccount] = useState('') const [transferAccount, setTransferAccount] = useState('')
@@ -43,9 +42,13 @@ export function CheckoutPaymentStep() {
async function load() { async function load() {
setLoading(true) setLoading(true)
try { 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 if (controller.signal.aborted) return
let data = cartResult
if (data.cart.items.length === 0) { if (data.cart.items.length === 0) {
try { try {
const synced = await syncGuestCartToServer(controller.signal) const synced = await syncGuestCartToServer(controller.signal)
@@ -59,6 +62,23 @@ export function CheckoutPaymentStep() {
setSubtotal(data.cart.subtotal) setSubtotal(data.cart.subtotal)
if (data.cart.items.length === 0) { if (data.cart.items.length === 0) {
navigate('/checkout/cart', { replace: true }) 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) { } catch (err) {
if (isAbortError(err)) return if (isAbortError(err)) return
@@ -102,9 +122,8 @@ export function CheckoutPaymentStep() {
} }
if (paymentType === 'e_payment_gate') { if (paymentType === 'e_payment_gate') {
const gateway = ONLINE_GATEWAYS.find((item) => item.id === gatewayType) if (!onlinePaymentsEnabled || !onlineGateways.some((g) => g.id === gatewayType)) {
if (!gateway?.ready) { setError('درگاه پرداخت آنلاین در دسترس نیست. لطفاً روش دیگری انتخاب کنید.')
setError('این درگاه هنوز فعال نیست. لطفاً ملت را انتخاب کنید.')
return return
} }
} }
@@ -227,27 +246,29 @@ export function CheckoutPaymentStep() {
<div className={styles.field}> <div className={styles.field}>
<label>روش پرداخت</label> <label>روش پرداخت</label>
<div className={styles.paymentOptions}> <div className={styles.paymentOptions}>
<button {onlinePaymentsEnabled ? (
type="button" <button
className={[ type="button"
styles.paymentOption, className={[
paymentType === 'e_payment_gate' ? styles.paymentOptionActive : '', styles.paymentOption,
] paymentType === 'e_payment_gate' ? styles.paymentOptionActive : '',
.filter(Boolean) ]
.join(' ')} .filter(Boolean)
onClick={() => setPaymentType('e_payment_gate')} .join(' ')}
disabled={submitting} onClick={() => setPaymentType('e_payment_gate')}
> disabled={submitting}
<CreditCard size={20} /> >
<span className={styles.paymentOptionBody}> <CreditCard size={20} />
<span className={styles.paymentOptionTitle}>پرداخت آنلاین</span> <span className={styles.paymentOptionBody}>
<span className={styles.paymentOptionHint}>پرداخت امن از طریق درگاه</span> <span className={styles.paymentOptionTitle}>پرداخت آنلاین</span>
</span> <span className={styles.paymentOptionHint}>پرداخت امن از طریق درگاه</span>
</button> </span>
</button>
) : null}
{paymentType === 'e_payment_gate' && ( {onlinePaymentsEnabled && paymentType === 'e_payment_gate' && (
<div className={styles.paymentSubOptions} role="radiogroup" aria-label="انتخاب درگاه"> <div className={styles.paymentSubOptions} role="radiogroup" aria-label="انتخاب درگاه">
{ONLINE_GATEWAYS.map((gateway) => { {onlineGateways.map((gateway) => {
const active = gatewayType === gateway.id const active = gatewayType === gateway.id
return ( return (
<button <button
@@ -262,15 +283,12 @@ export function CheckoutPaymentStep() {
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
onClick={() => setGatewayType(gateway.id)} onClick={() => setGatewayType(gateway.id)}
disabled={submitting || !gateway.ready} disabled={submitting}
> >
<span className={styles.paymentSubRadio}> <span className={styles.paymentSubRadio}>
{active ? <span className={styles.paymentSubRadioInner} /> : null} {active ? <span className={styles.paymentSubRadioInner} /> : null}
</span> </span>
<span> <span>{gateway.labelFa || gateway.label}</span>
{gateway.label}
{!gateway.ready ? ' (به‌زودی)' : ''}
</span>
</button> </button>
) )
})} })}
+27
View File
@@ -83,6 +83,18 @@ export interface CheckoutResponse {
payment?: CheckoutPaymentResult payment?: CheckoutPaymentResult
} }
export interface PublicPaymentGateway {
id: OnlineGatewayType
label: string
labelFa: string
}
export interface PublicPaymentMethods {
enabled: boolean
defaultGateway: OnlineGatewayType | null
gateways: PublicPaymentGateway[]
}
function cartPath(suffix = '') { function cartPath(suffix = '') {
const businessId = getActiveBusinessId() const businessId = getActiveBusinessId()
if (!businessId) { if (!businessId) {
@@ -91,6 +103,14 @@ function cartPath(suffix = '') {
return `/businesses/${businessId}/cart${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) { export async function getCart(signal?: AbortSignal) {
return apiRequest<{ cart: Cart }>(cartPath(), { auth: true, signal }) 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). */ /** Submit a gateway redirect (POST form or GET navigation). */
export function submitPaymentRedirect(redirect: PaymentRedirect) { export function submitPaymentRedirect(redirect: PaymentRedirect) {
if (redirect.method === 'GET') { if (redirect.method === 'GET') {
@@ -82,7 +82,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 6px 12px 6px 6px; padding: 8px 16px;
border-radius: 50px; border-radius: 50px;
background: rgba(255, 255, 255, 0.45); background: rgba(255, 255, 255, 0.45);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
@@ -94,6 +94,7 @@
.profileInfo { .profileInfo {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0 4px;
} }
.name { .name {
+5
View File
@@ -164,3 +164,8 @@ textarea::placeholder {
font-family: var(--font-ui); font-family: var(--font-ui);
font-weight: 400; 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 { .roleBadge {
display: inline-block; display: inline-block;
padding: 3px 8px; padding: 3px 12px;
border-radius: 999px; border-radius: 999px;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
@@ -9,6 +14,23 @@
border: 1px solid rgba(var(--primary-rgb) / 0.18); 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 { .inactiveRow {
opacity: 0.55; opacity: 0.55;
} }
+31 -6
View File
@@ -41,6 +41,18 @@ function displayName(user: UserListItem) {
return name || '—' 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_ORDER = ['admin', 'editor', 'viewer'] as const
const TEAM_ROLE_LABELS: Record<string, string> = { const TEAM_ROLE_LABELS: Record<string, string> = {
@@ -644,9 +656,9 @@ export function UsersPage() {
<table className={tableStyles.table}> <table className={tableStyles.table}>
<thead> <thead>
<tr> <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}>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}>Business</th>
<th className={tableStyles.th}>Date joined</th> <th className={tableStyles.th}>Date joined</th>
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th> <th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th>
@@ -672,12 +684,12 @@ export function UsersPage() {
{!loading && {!loading &&
data?.items?.map((user) => ( data?.items?.map((user) => (
<tr key={user.id} className={!user.isActive ? styles.inactiveRow : undefined}> <tr key={user.id} className={!user.isActive ? styles.inactiveRow : undefined}>
<td className={tableStyles.td}> <td className={`${tableStyles.td} ${styles.colName}`}>
<div>{displayName(user)}</div> <div>{displayName(user)}</div>
{!user.isActive && <div className={styles.statusInactive}>Inactive</div>} {!user.isActive && <div className={styles.statusInactive}>Inactive</div>}
</td> </td>
<td className={tableStyles.td}>{formatCellForDisplay(user.cellNumber)}</td> <td className={tableStyles.td}>{formatCellForDisplay(user.cellNumber)}</td>
<td className={tableStyles.td}> <td className={`${tableStyles.td} ${styles.colRole}`}>
{(() => { {(() => {
const label = businessFilter const label = businessFilter
? formatBusinessUserRoleLabel(user, teamRoles) ? formatBusinessUserRoleLabel(user, teamRoles)
@@ -697,7 +709,13 @@ export function UsersPage() {
<div className={tableStyles.rowActions}> <div className={tableStyles.rowActions}>
<button <button
type="button" type="button"
className={tableStyles.controlBtn} className={`${tableStyles.controlBtn} ${
isSuperAdminUser(user)
? styles.shieldSuperAdmin
: isBusinessOwnerUser(user)
? styles.shieldOwner
: ''
}`}
onClick={() => openRoleChange(user)} onClick={() => openRoleChange(user)}
title={ title={
businessFilter && user.isBusinessOwner businessFilter && user.isBusinessOwner
@@ -711,7 +729,14 @@ export function UsersPage() {
} }
disabled={!user.isActive || Boolean(businessFilter && user.isBusinessOwner)} disabled={!user.isActive || Boolean(businessFilter && user.isBusinessOwner)}
> >
<Shield size={16} /> <Shield
size={16}
fill={
isSuperAdminUser(user) || isBusinessOwnerUser(user)
? 'currentColor'
: 'none'
}
/>
</button> </button>
<button <button
type="button" type="button"
@@ -52,7 +52,7 @@
} }
.deployStarted { .deployStarted {
color: #b45309; color: #0f172a;
} }
.deployFail { .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. > **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. > 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/subscriptions` | Subscriptions | Placeholder |
| `/website/faq` | FAQ | Placeholder | | `/website/faq` | FAQ | Placeholder |
| `/website/badges` | Badges | 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 |
--- ---