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
@@ -152,6 +152,19 @@
box-shadow: 0 6px 16px rgba(var(--primary-rgb) / 0.35);
}
.submitBtn:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.errorText {
margin: 0;
font-size: 12px;
color: #ef4444;
}
.modalIn {
animation: modalFadeIn 0.25s ease forwards;
}
@@ -81,18 +81,16 @@
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
inset-inline: 0;
max-height: 260px;
overflow-y: auto;
position: static;
margin-top: 6px;
max-height: none;
overflow: visible;
background: var(--elevated-surface);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow);
z-index: 30;
list-style: none;
padding: 6px;
margin: 0;
@@ -81,18 +81,16 @@
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
inset-inline: 0;
max-height: 220px;
overflow-y: auto;
position: static;
margin-top: 6px;
max-height: none;
overflow: visible;
background: var(--elevated-surface);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow);
z-index: 20;
list-style: none;
padding: 6px;
margin: 0;
@@ -64,15 +64,13 @@
}
.dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 20;
position: static;
margin-top: 4px;
z-index: auto;
display: flex;
flex-direction: column;
max-height: 240px;
overflow: hidden;
max-height: none;
overflow: visible;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
@@ -108,6 +108,10 @@
margin-top: 24px;
}
.span4 {
grid-column: span 4;
}
.col6 {
grid-column: span 6;
}
@@ -121,6 +125,10 @@
grid-template-columns: repeat(4, 1fr);
gap: 24px;
}
.span4 {
grid-column: span 2;
}
}
@media (max-width: 1400px) {
@@ -133,6 +141,10 @@
.gridHome {
grid-template-columns: repeat(3, 1fr);
}
.span4 {
grid-column: span 2;
}
}
@media (max-width: 900px) {
@@ -141,6 +153,10 @@
.gridCols4 {
grid-template-columns: repeat(2, 1fr);
}
.span4 {
grid-column: span 2;
}
}
@media (max-width: 768px) {
@@ -173,7 +189,8 @@
gap: 16px;
}
.span4,
.col6 {
grid-column: span 12;
grid-column: span 1;
}
}
@@ -69,31 +69,29 @@
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
max-height: 220px;
overflow-y: auto;
position: static;
margin-top: 6px;
max-height: none;
overflow: visible;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: 0 8px 24px rgba(31, 38, 135, 0.12);
z-index: 10;
list-style: none;
padding: 6px;
animation: dropdownIn 0.15s ease;
}
/* Prefer for selects inside overflow-clipped modals — expands parent instead of clipping. */
/* Alias — in-flow is the default so search results expand the parent modal. */
.dropdownInFlow {
position: static;
top: auto;
left: auto;
right: auto;
max-height: min(50vh, 400px);
max-height: none;
overflow: visible;
margin-top: 6px;
z-index: auto;
}
+3 -1
View File
@@ -8,6 +8,7 @@ interface SectionCardProps {
description: string
linkText?: string
href: string
className?: string
/** Entity total shown beside the arrow; omit for sections without a count (e.g. settings). */
count?: number | null
countLabel?: string
@@ -18,6 +19,7 @@ export function SectionCard({
title,
description,
href,
className,
count,
countLabel,
}: SectionCardProps) {
@@ -27,7 +29,7 @@ export function SectionCard({
: null
return (
<Link to={href} className={styles.card}>
<Link to={href} className={className ? `${styles.card} ${className}` : styles.card}>
<div className={styles.iconWrap}>
<Icon size={24} strokeWidth={1.75} />
</div>
-1
View File
@@ -212,7 +212,6 @@ export function Sidebar() {
{ labelKey: 'nav.website.subscriptions', to: '/website/subscriptions' },
{ labelKey: 'nav.website.faq', to: '/website/faq' },
{ labelKey: 'nav.website.badges', to: '/website/badges' },
{ labelKey: 'nav.website.ePayment', to: '/website/e-payment' },
],
},
{ type: 'link', id: 'settings', icon: Settings, labelKey: 'nav.settings', to: '/settings' },
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
updateStoreItemDiscounts,
@@ -15,6 +17,7 @@ import rowStyles from './CreateStoreItemsModal.module.css'
interface StoreItemDiscountModalProps {
open: boolean
productTitle: string
productNameFa?: string
items: StoreItem[]
onClose: () => void
onSaved?: (items: StoreItem[]) => void
@@ -33,10 +36,15 @@ const ANIMATION_MS = 220
export function StoreItemDiscountModal({
open,
productTitle,
productNameFa = '',
items,
onClose,
onSaved,
}: StoreItemDiscountModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const displayTitle = isFa ? productNameFa || productTitle : productTitle || productNameFa
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [rows, setRows] = useState<DiscountDraftRow[]>([])
@@ -113,7 +121,7 @@ export function StoreItemDiscountModal({
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to save discounts.')
setError(t('storeItems.discount.errorSave'))
}
} finally {
setIsSubmitting(false)
@@ -131,24 +139,26 @@ export function StoreItemDiscountModal({
role="dialog"
aria-modal="true"
aria-labelledby="store-discount-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={modalStyles.header}>
<div>
<h3 id="store-discount-title" className={modalStyles.title}>
Set Discounts
{t('storeItems.discount.title')}
</h3>
<p className={modalStyles.subtitle}>{productTitle}</p>
<p className={modalStyles.subtitle}>{displayTitle}</p>
</div>
<button className={modalStyles.closeBtn} onClick={onClose} aria-label="Close">
<button className={modalStyles.closeBtn} onClick={onClose} aria-label={t('common.close')}>
<X size={20} />
</button>
</div>
<form className={modalStyles.body} onSubmit={(e) => void handleSubmit(e)}>
<div className={rowStyles.discountHeader}>
<span>Variant</span>
<span>Regular price</span>
<span>Discounted price (IRT)</span>
<span>{t('storeItems.discount.variant')}</span>
<span>{t('storeItems.discount.regularPrice')}</span>
<span>{t('storeItems.discount.discountedPrice')}</span>
</div>
<div className={rowStyles.discountRows}>
@@ -172,7 +182,7 @@ export function StoreItemDiscountModal({
),
)
}
placeholder="No discount"
placeholder={t('storeItems.discount.placeholder')}
disabled={isSubmitting}
/>
</div>
@@ -188,10 +198,10 @@ export function StoreItemDiscountModal({
onClick={onClose}
disabled={isSubmitting}
>
Cancel
{t('products.form.cancel')}
</button>
<button type="submit" className={modalStyles.submitBtn} disabled={!canSubmit}>
{isSubmitting ? 'Saving…' : 'Save discounts'}
{isSubmitting ? t('storeItems.discount.saving') : t('storeItems.discount.save')}
</button>
</div>
</form>
@@ -4,10 +4,6 @@
gap: 12px;
}
.dropdown {
max-height: 280px;
}
.option {
display: flex;
align-items: center;
@@ -125,7 +125,7 @@ export function StoreItemMultiSearchSelect({
</div>
{showDropdown && (
<ul className={`${selectStyles.dropdown} ${styles.dropdown}`} role="listbox">
<ul className={`${selectStyles.dropdown} ${selectStyles.dropdownInFlow}`} role="listbox">
{filtered.length === 0 ? (
<li className={selectStyles.noResults}>
{options.length === 0
@@ -164,7 +164,7 @@ export function StoreItemMultiSearchSelect({
)}
{open && query.trim().length > 0 && query.trim().length < minSearchLength && (
<ul className={selectStyles.dropdown}>
<ul className={`${selectStyles.dropdown} ${selectStyles.dropdownInFlow}`}>
<li className={selectStyles.noResults}>
{t('storeItems.search.minChars', { count: minSearchLength })}
</li>
@@ -19,11 +19,45 @@ interface StoreSpecialModalProps {
title?: string
submitLabel?: string
isSubmitting?: boolean
error?: string
}
const ANIMATION_MS = 220
const KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/
function slugifyGroupKey(raw: string): string {
const slug = raw
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
if (!slug || !/^[a-z]/.test(slug)) return ''
return KEY_PATTERN.test(slug) ? slug : ''
}
function hasNonAscii(value: string): boolean {
return /[^\u0000-\u007F]/.test(value)
}
function resolveGroupFields(keyRaw: string, titleRaw: string): StoreSpecialModalValues | null {
const key = keyRaw.trim()
const title = titleRaw.trim()
const keySlug = slugifyGroupKey(key)
const titleSlug = slugifyGroupKey(title)
const keyLooksLikeTitle = key.length > 0 && !keySlug && hasNonAscii(key)
if (keySlug && title) return { key: keySlug, title }
if (keyLooksLikeTitle && titleSlug) return { key: titleSlug, title: key }
if (!key && titleSlug) return { key: titleSlug, title }
if (title && !keySlug && !titleSlug) {
return { key: `special-${Date.now().toString(36)}`, title }
}
if (keyLooksLikeTitle && !title) {
return { key: `special-${Date.now().toString(36)}`, title: key }
}
return null
}
export function StoreSpecialModal({
open,
onClose,
@@ -33,6 +67,7 @@ export function StoreSpecialModal({
title,
submitLabel,
isSubmitting = false,
error,
}: StoreSpecialModalProps) {
const t = useT()
const { locale } = useLocale()
@@ -42,6 +77,7 @@ export function StoreSpecialModal({
const [closing, setClosing] = useState(false)
const [groupKey, setGroupKey] = useState(initialKey)
const [groupTitle, setGroupTitle] = useState(initialTitle)
const [keyTouched, setKeyTouched] = useState(Boolean(initialKey))
useEffect(() => {
if (open) {
@@ -49,6 +85,7 @@ export function StoreSpecialModal({
setClosing(false)
setGroupKey(initialKey)
setGroupTitle(initialTitle)
setKeyTouched(Boolean(initialKey))
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
@@ -70,15 +107,22 @@ export function StoreSpecialModal({
if (!mounted) return null
const trimmedKey = groupKey.trim()
const trimmedTitle = groupTitle.trim()
const keyValid = KEY_PATTERN.test(trimmedKey)
const canSubmit = keyValid && trimmedTitle.length > 0
const resolved = resolveGroupFields(groupKey, groupTitle)
const canSubmit = resolved !== null
const keySlug = slugifyGroupKey(groupKey)
const showKeyError = groupKey.trim().length > 0 && !keySlug && !hasNonAscii(groupKey.trim())
function handleTitleChange(value: string) {
setGroupTitle(value)
if (!keyTouched) {
setGroupKey(slugifyGroupKey(value))
}
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!canSubmit) return
onSubmit({ key: trimmedKey, title: trimmedTitle })
if (!resolved) return
onSubmit(resolved)
}
return createPortal(
@@ -110,33 +154,47 @@ export function StoreSpecialModal({
</div>
<form ref={formRef} className={styles.form} onSubmit={handleSubmit}>
<div className={styles.field}>
<label htmlFor="special-group-title">{t('website.groupValue')}</label>
<input
id="special-group-title"
value={groupTitle}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder={t('website.groupValuePlaceholder')}
autoFocus
disabled={isSubmitting}
dir={isFa ? 'rtl' : 'ltr'}
/>
</div>
<div className={styles.field}>
<label htmlFor="special-group-key">{t('website.groupKey')}</label>
<input
id="special-group-key"
value={groupKey}
onChange={(e) => setGroupKey(e.target.value)}
onChange={(e) => {
setKeyTouched(true)
setGroupKey(e.target.value)
}}
placeholder={t('website.groupKeyPlaceholder')}
autoFocus
disabled={isSubmitting}
dir="ltr"
autoComplete="off"
spellCheck={false}
/>
<p className={styles.hint}>{t('website.groupKeyHint')}</p>
{showKeyError ? (
<p className={styles.errorText} role="alert">
{t('website.groupKeyError')}
</p>
) : null}
</div>
<div className={styles.field}>
<label htmlFor="special-group-title">{t('website.groupValue')}</label>
<input
id="special-group-title"
value={groupTitle}
onChange={(e) => setGroupTitle(e.target.value)}
placeholder={t('website.groupValuePlaceholder')}
disabled={isSubmitting}
dir={isFa ? 'rtl' : 'ltr'}
/>
</div>
{error ? (
<p className={styles.errorText} role="alert">
{error}
</p>
) : null}
<div className={styles.actions}>
<button
@@ -2,8 +2,8 @@
position: fixed;
inset: 0;
background: var(--modal-overlay-bg);
backdrop-filter: blur(var(--modal-overlay-blur));
-webkit-backdrop-filter: blur(var(--modal-overlay-blur));
backdrop-filter: blur(var(--modal-overlay-blur, 16px));
-webkit-backdrop-filter: blur(var(--modal-overlay-blur, 16px));
display: flex;
align-items: center;
justify-content: center;
@@ -11,6 +11,14 @@
padding: 20px;
}
.overlayIn {
animation: overlayFadeIn 0.2s ease forwards;
}
.overlayOut {
animation: overlayFadeOut 0.18s ease forwards;
}
.overlayNested {
z-index: 1010;
}
@@ -417,6 +425,16 @@
color: #ef4444;
margin-bottom: 12px;
}
@keyframes overlayFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes overlayFadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes modalFadeIn {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }