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:
co-authored by
Cursor
parent
9a59bc8f65
commit
e79211e3cd
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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')] },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+7
-2
@@ -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;
|
||||||
}
|
}
|
||||||
+173
-18
@@ -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,18 +206,51 @@ 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}>
|
||||||
|
<section className={sharedStyles.panel}>
|
||||||
|
<h3 className={sharedStyles.sectionTitle}>{t('title.ePayment')}</h3>
|
||||||
<div className={sharedStyles.list}>
|
<div className={sharedStyles.list}>
|
||||||
<div className={sharedStyles.row}>
|
<div className={sharedStyles.row}>
|
||||||
<div className={sharedStyles.rowText}>
|
<div className={sharedStyles.rowText}>
|
||||||
@@ -204,6 +269,7 @@ export function WebsiteEPaymentPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,6 +246,7 @@ export function CheckoutPaymentStep() {
|
|||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
<label>روش پرداخت</label>
|
<label>روش پرداخت</label>
|
||||||
<div className={styles.paymentOptions}>
|
<div className={styles.paymentOptions}>
|
||||||
|
{onlinePaymentsEnabled ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={[
|
className={[
|
||||||
@@ -244,10 +264,11 @@ export function CheckoutPaymentStep() {
|
|||||||
<span className={styles.paymentOptionHint}>پرداخت امن از طریق درگاه</span>
|
<span className={styles.paymentOptionHint}>پرداخت امن از طریق درگاه</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user