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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user