Add store-item bulk create, unlimited stock, and contact-for-price UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-08 15:36:41 +03:30
co-authored by Cursor
parent 5eaf7fb046
commit ad83fc1099
15 changed files with 803 additions and 77 deletions
@@ -0,0 +1,191 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.28);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1200;
padding: 20px;
}
.modal {
position: relative;
width: 100%;
max-width: 440px;
padding: 32px 28px 24px;
text-align: start;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(245, 158, 11, 0.28);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.18);
font-family: var(--font-ui);
}
.iconWrap {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
background: rgba(245, 158, 11, 0.14);
color: #d97706;
border-radius: 50%;
}
.title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
text-align: center;
font-family: var(--font-ui);
}
.message {
font-size: 14px;
line-height: 1.6;
color: var(--text-secondary);
margin-bottom: 12px;
text-align: center;
font-family: var(--font-ui);
}
.points {
margin: 0 0 18px;
padding-inline-start: 1.25rem;
font-size: 13px;
line-height: 1.65;
color: var(--text-secondary);
font-family: var(--font-ui);
}
.points li + li {
margin-top: 4px;
}
.agree {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 22px;
padding: 12px 14px;
font-size: 13px;
line-height: 1.45;
color: var(--text-primary);
font-family: var(--font-ui);
background: rgba(148, 163, 184, 0.1);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: var(--radius-sm);
cursor: pointer;
text-align: start;
}
.agreeCheck {
width: 16px;
height: 16px;
margin-top: 2px;
flex-shrink: 0;
accent-color: var(--primary);
cursor: pointer;
}
.agreeCheck:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.actions {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.cancelBtn {
padding: 10px 20px;
min-height: var(--field-height);
font-size: 14px;
font-weight: 500;
font-family: var(--font-ui);
color: var(--text-secondary);
border-radius: var(--radius-sm);
transition: background 0.2s;
}
.cancelBtn:hover:not(:disabled) {
background: rgba(148, 163, 184, 0.15);
}
.cancelBtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.confirmBtn {
padding: 10px 22px;
min-height: var(--field-height);
font-size: 14px;
font-weight: 600;
font-family: var(--font-ui);
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.3);
transition: transform 0.2s, opacity 0.2s;
}
.confirmBtn:hover:not(:disabled) {
transform: translateY(-1px);
}
.confirmBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.closeBtn {
position: absolute;
top: 14px;
inset-inline-end: 14px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-muted);
transition: background 0.2s, color 0.2s;
}
.closeBtn:hover:not(:disabled) {
background: rgba(245, 158, 11, 0.12);
color: #d97706;
}
.closeBtn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.overlayIn { animation: overlayFadeIn 0.22s ease forwards; }
.overlayOut { animation: overlayFadeOut 0.22s ease forwards; }
.modalIn { animation: modalFadeIn 0.25s ease forwards; }
.modalOut { animation: modalFadeOut 0.22s ease forwards; }
@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); }
}
@keyframes modalFadeOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(8px) scale(0.98); }
}
@@ -0,0 +1,164 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { AlertTriangle, PackagePlus, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import styles from './ConfirmBulkAddStoreItemsModal.module.css'
export interface BulkAddResult {
created: number
skipped: number
failed: number
}
interface ConfirmBulkAddStoreItemsModalProps {
open: boolean
busy: boolean
result: BulkAddResult | null
onConfirm: () => void
onClose: () => void
}
const ANIMATION_MS = 220
export function ConfirmBulkAddStoreItemsModal({
open,
busy,
result,
onConfirm,
onClose,
}: ConfirmBulkAddStoreItemsModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [agreed, setAgreed] = useState(false)
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setAgreed(false)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
setAgreed(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !busy) onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose, busy])
if (!mounted) return null
const showResult = result !== null
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={() => {
if (!busy) onClose()
}}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-bulk-add-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={styles.iconWrap}>
{showResult ? <PackagePlus size={28} /> : <AlertTriangle size={28} />}
</div>
<h3 id="confirm-bulk-add-title" className={styles.title}>
{showResult
? t('storeItems.create.addAllResultTitle')
: t('storeItems.create.addAllConfirmTitle')}
</h3>
{showResult ? (
<p className={styles.message}>
{t('storeItems.create.addAllDone', {
created: String(result.created),
skipped: String(result.skipped),
failed: String(result.failed),
})}
</p>
) : (
<>
<p className={styles.message}>{t('storeItems.create.addAllConfirmIntro')}</p>
<ul className={styles.points}>
<li>{t('storeItems.create.addAllConfirmMax')}</li>
<li>{t('storeItems.create.addAllConfirmSkip')}</li>
<li>{t('storeItems.create.addAllConfirmHeavy')}</li>
</ul>
<label className={styles.agree}>
<input
type="checkbox"
className={styles.agreeCheck}
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
disabled={busy}
/>
<span>{t('storeItems.create.addAllAgree')}</span>
</label>
</>
)}
<div className={styles.actions}>
{showResult ? (
<button type="button" className={styles.confirmBtn} onClick={onClose}>
{t('storeItems.create.addAllDoneBtn')}
</button>
) : (
<>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={busy}
>
{t('storeItems.create.cancel')}
</button>
<button
type="button"
className={styles.confirmBtn}
onClick={onConfirm}
disabled={!agreed || busy}
>
{busy
? t('storeItems.create.addingAll')
: t('storeItems.create.addAllConfirm')}
</button>
</>
)}
</div>
<button
className={styles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
disabled={busy}
>
<X size={18} />
</button>
</div>
</div>,
document.body,
)
}
@@ -65,6 +65,14 @@
cursor: pointer;
}
.rowActions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.addRowBtn {
display: inline-flex;
align-items: center;
@@ -76,13 +84,82 @@
color: var(--primary);
border-radius: var(--radius-sm);
transition: background 0.2s;
margin-bottom: 8px;
}
.addRowBtn:hover {
.addRowBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
}
.addRowBtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.footerActions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 16px;
margin-top: 8px;
border-top: 1px solid rgba(148, 163, 184, 0.2);
}
.footerBulkBtn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
min-height: var(--field-height);
font-size: 13px;
font-weight: 500;
font-family: var(--font-ui);
color: var(--primary);
border-radius: var(--radius-sm);
transition: background 0.2s;
}
.footerBulkBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
}
.footerBulkBtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.footerEnd {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-inline-start: auto;
}
.unlimitedCell {
display: flex;
align-items: center;
justify-content: center;
}
.unlimitedCheck {
width: 16px;
height: 16px;
accent-color: var(--primary);
cursor: pointer;
}
.unlimitedCheck:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.compactInput:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.modalWide {
max-width: 920px;
}
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, X } from 'lucide-react'
import { PackagePlus, Plus, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import { listAllProducts } from '../services/productService'
@@ -16,10 +17,15 @@ import {
import { createId } from '../utils/id'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import {
buildBulkCreateItems,
createEmptyStoreItemRow,
getVariationOptions,
type StoreItemDraftRow,
} from '../utils/storeItemRows'
import {
ConfirmBulkAddStoreItemsModal,
type BulkAddResult,
} from './ConfirmBulkAddStoreItemsModal'
import { ProductSearchSelect } from './ProductSearchSelect'
import { Tooltip } from './Tooltip'
import modalStyles from './VariationsModal.module.css'
@@ -44,6 +50,7 @@ export function CreateStoreItemsModal({
}: CreateStoreItemsModalProps) {
const t = useT()
const { locale } = useLocale()
const { showToast } = useToast()
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [products, setProducts] = useState<{ id: string; title: string; nameFa: string }[]>([])
@@ -53,8 +60,13 @@ export function CreateStoreItemsModal({
const [isLoadingProducts, setIsLoadingProducts] = useState(false)
const [isLoadingVariations, setIsLoadingVariations] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isBulkCreating, setIsBulkCreating] = useState(false)
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
const [bulkResult, setBulkResult] = useState<BulkAddResult | null>(null)
const [error, setError] = useState('')
const busy = isSubmitting || isBulkCreating
const formVariations = useMemo(
() => variations.filter((variation) => getVariationOptions(variation).length > 0),
[variations],
@@ -62,8 +74,10 @@ export function CreateStoreItemsModal({
const gridTemplate = useMemo(() => {
const variationCols = formVariations.map(() => 'minmax(110px, 1fr)').join(' ')
const cols = [variationCols, 'minmax(120px, 1fr)', '90px', '32px'].filter((part) => part).join(' ')
return cols || 'minmax(120px, 1fr) 90px 32px'
const cols = [variationCols, 'minmax(120px, 1fr)', '90px', '72px', '32px']
.filter((part) => part)
.join(' ')
return cols || 'minmax(120px, 1fr) 90px 72px 32px'
}, [formVariations])
useEffect(() => {
@@ -79,6 +93,8 @@ export function CreateStoreItemsModal({
setVariations([])
setRows([])
setError('')
setBulkConfirmOpen(false)
setBulkResult(null)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
@@ -154,11 +170,11 @@ export function CreateStoreItemsModal({
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'Escape' && !busy && !bulkConfirmOpen) onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose])
}, [mounted, closing, onClose, busy, bulkConfirmOpen])
if (!mounted) return null
@@ -205,8 +221,12 @@ export function CreateStoreItemsModal({
if (!optionId) return []
return [{ variationId: variation.id, optionId }]
}),
price: parseIrtInput(row.price) ?? undefined,
stockQuantity: row.stock.trim() ? Number(row.stock) : undefined,
price: parseIrtInput(row.price),
stockQuantity: row.unlimitedStock
? null
: row.stock.trim()
? Number(row.stock)
: null,
}))
}
@@ -215,10 +235,12 @@ export function CreateStoreItemsModal({
rows.length > 0 &&
rows.every((row) => {
const price = parseIrtInput(row.price)
if (price !== null && price < 0) return false
if (row.unlimitedStock) return true
const stock = Number(row.stock)
return price !== null && price >= 0 && row.stock.trim() !== '' && !Number.isNaN(stock) && stock >= 0
return row.stock.trim() !== '' && !Number.isNaN(stock) && stock >= 0
}) &&
!isSubmitting &&
!busy &&
!isLoadingVariations
async function handleSubmit(e: React.FormEvent) {
@@ -246,10 +268,91 @@ export function CreateStoreItemsModal({
}
}
function openBulkConfirm() {
if (busy || isLoadingProducts || products.length === 0) return
setBulkResult(null)
setBulkConfirmOpen(true)
}
function closeBulkConfirm() {
if (isBulkCreating) return
const hadResult = bulkResult !== null
const createdCount = bulkResult?.created ?? 0
setBulkConfirmOpen(false)
setBulkResult(null)
if (hadResult) {
onCreated?.()
if (createdCount > 0) onClose()
}
}
async function handleAddAllProducts() {
if (isBulkCreating || isLoadingProducts) return
setIsBulkCreating(true)
setError('')
setBulkResult(null)
let created = 0
let skipped = 0
let failed = 0
const skip = new Set(existingProductIds)
try {
const catalog =
products.length > 0
? products
: (await listAllProducts()).map((item) => ({
id: item.id,
title: item.title,
nameFa: item.nameFa,
}))
for (const product of catalog) {
if (skip.has(product.id)) {
skipped += 1
continue
}
try {
const data = await getProductVariationValues(product.id)
const items = buildBulkCreateItems(data.variations)
await batchCreateStoreItems({ productId: product.id, items })
created += 1
skip.add(product.id)
} catch {
failed += 1
}
}
const result = { created, skipped, failed }
setBulkResult(result)
showToast(
t('storeItems.create.addAllDone', {
created: String(created),
skipped: String(skipped),
failed: String(failed),
}),
failed > 0 && created === 0 ? 'error' : 'success',
)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('storeItems.create.errorCreate'))
}
setBulkConfirmOpen(false)
} finally {
setIsBulkCreating(false)
}
}
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
onClick={() => {
if (!busy && !bulkConfirmOpen) onClose()
}}
>
<div
className={`${modalStyles.modal} ${rowStyles.modalWide} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
@@ -271,6 +374,7 @@ export function CreateStoreItemsModal({
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
disabled={busy}
>
<X size={20} />
</button>
@@ -284,7 +388,7 @@ export function CreateStoreItemsModal({
value={productId}
onChange={handleProductChange}
placeholder={t('storeItems.create.productPlaceholder')}
disabled={isLoadingProducts || isSubmitting}
disabled={isLoadingProducts || busy}
aria-label={t('storeItems.create.productPlaceholder')}
/>
</div>
@@ -304,6 +408,7 @@ export function CreateStoreItemsModal({
))}
<span>{t('storeItems.create.price')}</span>
<span>{t('storeItems.create.stock')}</span>
<span>{t('storeItems.create.unlimited')}</span>
<span />
</div>
@@ -324,7 +429,7 @@ export function CreateStoreItemsModal({
onChange={(e) =>
updateRowSelection(row.id, variation.id, e.target.value)
}
disabled={isSubmitting}
disabled={busy}
>
<option value="">—</option>
{options.map((option) => (
@@ -341,25 +446,38 @@ export function CreateStoreItemsModal({
className={rowStyles.compactInput}
value={row.price}
onChange={(e) => updateRow(row.id, { price: formatIrtInput(e.target.value) })}
placeholder="0"
disabled={isSubmitting}
placeholder={t('storeItems.price.contact')}
disabled={busy}
/>
<input
type="number"
min="0"
step="1"
className={rowStyles.compactInput}
value={row.stock}
value={row.unlimitedStock ? '' : row.stock}
onChange={(e) => updateRow(row.id, { stock: e.target.value })}
disabled={isSubmitting}
disabled={busy || row.unlimitedStock}
placeholder={row.unlimitedStock ? '∞' : '0'}
/>
<div className={rowStyles.unlimitedCell}>
<input
type="checkbox"
className={rowStyles.unlimitedCheck}
checked={row.unlimitedStock}
onChange={(e) =>
updateRow(row.id, { unlimitedStock: e.target.checked })
}
disabled={busy}
aria-label={t('storeItems.create.unlimited')}
/>
</div>
<div className={rowStyles.removeCell}>
<Tooltip label={t('storeItems.create.removeRow')}>
<button
type="button"
className={modalStyles.removeRowBtn}
onClick={() => removeRow(row.id)}
disabled={rows.length <= 1 || isSubmitting}
disabled={rows.length <= 1 || busy}
aria-label={t('storeItems.create.removeRow')}
>
<X size={16} />
@@ -370,41 +488,62 @@ export function CreateStoreItemsModal({
))}
</div>
<button
type="button"
className={rowStyles.addRowBtn}
onClick={addRow}
disabled={isSubmitting}
>
<Plus size={16} />
{t('storeItems.create.addRow')}
</button>
<div className={rowStyles.rowActions}>
<button
type="button"
className={rowStyles.addRowBtn}
onClick={addRow}
disabled={busy}
>
<Plus size={16} />
{t('storeItems.create.addRow')}
</button>
</div>
</>
)}
{error && <p className={modalStyles.errorText}>{error}</p>}
<div className={modalStyles.actions}>
<div className={rowStyles.footerActions}>
<button
type="button"
className={modalStyles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
className={rowStyles.footerBulkBtn}
onClick={openBulkConfirm}
disabled={busy || isLoadingProducts || products.length === 0}
>
{t('storeItems.create.cancel')}
</button>
<button
type="submit"
className={modalStyles.submitBtn}
disabled={!canSubmit}
>
{isSubmitting
? t('storeItems.create.submitting')
: t('storeItems.create.submit')}
<PackagePlus size={16} />
{t('storeItems.create.addAllProducts')}
</button>
<div className={rowStyles.footerEnd}>
<button
type="button"
className={modalStyles.cancelBtn}
onClick={onClose}
disabled={busy}
>
{t('storeItems.create.cancel')}
</button>
<button
type="submit"
className={modalStyles.submitBtn}
disabled={!canSubmit}
>
{isSubmitting
? t('storeItems.create.submitting')
: t('storeItems.create.submit')}
</button>
</div>
</div>
</form>
</div>
<ConfirmBulkAddStoreItemsModal
open={bulkConfirmOpen}
busy={isBulkCreating}
result={bulkResult}
onConfirm={() => void handleAddAllProducts()}
onClose={closeBulkConfirm}
/>
</div>
,
document.body,
@@ -43,12 +43,15 @@ function itemsToRows(storeItems: StoreItem[]): StoreItemDraftRow[] {
selections[selection.variationId] = selection.optionId
}
const unlimited = item.stockQuantity === null
return {
id: createId(),
storeItemId: item.id,
selections,
price: item.price !== null ? formatIrtInput(String(item.price)) : '',
stock: item.stockQuantity !== null ? String(item.stockQuantity) : '1',
stock: unlimited ? '1' : String(item.stockQuantity),
unlimitedStock: unlimited,
}
})
}
@@ -82,8 +85,10 @@ export function EditStoreItemsModal({
const gridTemplate = useMemo(() => {
const variationCols = formVariations.map(() => 'minmax(110px, 1fr)').join(' ')
const cols = [variationCols, 'minmax(120px, 1fr)', '90px', '32px'].filter((part) => part).join(' ')
return cols || 'minmax(120px, 1fr) 90px 32px'
const cols = [variationCols, 'minmax(120px, 1fr)', '90px', '72px', '32px']
.filter((part) => part)
.join(' ')
return cols || 'minmax(120px, 1fr) 90px 72px 32px'
}, [formVariations])
useEffect(() => {
@@ -128,11 +133,11 @@ export function EditStoreItemsModal({
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'Escape' && !isSubmitting) onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose])
}, [mounted, closing, onClose, isSubmitting])
if (!mounted || !productId) return null
@@ -174,8 +179,10 @@ export function EditStoreItemsModal({
rows.length > 0 &&
rows.every((row) => {
const price = parseIrtInput(row.price)
if (price !== null && price < 0) return false
if (row.unlimitedStock) return true
const stock = Number(row.stock)
return price !== null && price >= 0 && row.stock.trim() !== '' && !Number.isNaN(stock) && stock >= 0
return row.stock.trim() !== '' && !Number.isNaN(stock) && stock >= 0
}) &&
!isSubmitting &&
!isLoading
@@ -197,8 +204,12 @@ export function EditStoreItemsModal({
if (!optionId) return []
return [{ variationId: variation.id, optionId }]
}),
price: parseIrtInput(row.price)!,
stockQuantity: Number(row.stock),
price: parseIrtInput(row.price),
stockQuantity: row.unlimitedStock
? null
: row.stock.trim()
? Number(row.stock)
: null,
})),
removedIds,
})
@@ -222,7 +233,9 @@ export function EditStoreItemsModal({
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
onClick={() => {
if (!isSubmitting) onClose()
}}
>
<div
className={`${modalStyles.modal} ${rowStyles.modalWide} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
@@ -244,6 +257,7 @@ export function EditStoreItemsModal({
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
disabled={isSubmitting}
>
<X size={20} />
</button>
@@ -263,6 +277,7 @@ export function EditStoreItemsModal({
))}
<span>{t('storeItems.create.price')}</span>
<span>{t('storeItems.create.stock')}</span>
<span>{t('storeItems.create.unlimited')}</span>
<span />
</div>
@@ -300,7 +315,7 @@ export function EditStoreItemsModal({
className={rowStyles.compactInput}
value={row.price}
onChange={(e) => updateRow(row.id, { price: formatIrtInput(e.target.value) })}
placeholder="0"
placeholder={t('storeItems.price.contact')}
disabled={isSubmitting}
/>
<input
@@ -308,10 +323,23 @@ export function EditStoreItemsModal({
min="0"
step="1"
className={rowStyles.compactInput}
value={row.stock}
value={row.unlimitedStock ? '' : row.stock}
onChange={(e) => updateRow(row.id, { stock: e.target.value })}
disabled={isSubmitting}
disabled={isSubmitting || row.unlimitedStock}
placeholder={row.unlimitedStock ? '∞' : '0'}
/>
<div className={rowStyles.unlimitedCell}>
<input
type="checkbox"
className={rowStyles.unlimitedCheck}
checked={row.unlimitedStock}
onChange={(e) =>
updateRow(row.id, { unlimitedStock: e.target.checked })
}
disabled={isSubmitting}
aria-label={t('storeItems.create.unlimited')}
/>
</div>
<div className={rowStyles.removeCell}>
<Tooltip label={t('storeItems.create.removeRow')}>
<button
@@ -329,15 +357,17 @@ export function EditStoreItemsModal({
))}
</div>
<button
type="button"
className={rowStyles.addRowBtn}
onClick={addRow}
disabled={isSubmitting}
>
<Plus size={16} />
{t('storeItems.create.addRow')}
</button>
<div className={rowStyles.rowActions}>
<button
type="button"
className={rowStyles.addRowBtn}
onClick={addRow}
disabled={isSubmitting}
>
<Plus size={16} />
{t('storeItems.create.addRow')}
</button>
</div>
</>
)}
@@ -52,7 +52,15 @@
.imagePlaceholder {
width: 100%;
height: 100%;
background: rgba(148, 163, 184, 0.08);
display: flex;
align-items: center;
justify-content: center;
color: rgba(148, 163, 184, 0.75);
background: linear-gradient(
135deg,
rgba(148, 163, 184, 0.14) 0%,
rgba(148, 163, 184, 0.05) 100%
);
}
.festivalBadge,
+15 -5
View File
@@ -1,5 +1,5 @@
import { useId } from 'react'
import { Pencil, Percent, Sparkles, Trash2 } from 'lucide-react'
import { useEffect, useId, useState } from 'react'
import { ImageOff, Pencil, Percent, Sparkles, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import {
@@ -80,20 +80,30 @@ export function StoreItemCard({
? listing.productTitle
: ''
: listing.productNameFa
const imageSrc = listing.productImage?.trim() || ''
const [imageFailed, setImageFailed] = useState(false)
const showImage = Boolean(imageSrc) && !imageFailed
useEffect(() => {
setImageFailed(false)
}, [imageSrc])
return (
<article className={styles.card}>
<button type="button" className={styles.clickable} onClick={() => onOpen(listing)}>
<div className={styles.imageWrap}>
{listing.productImage ? (
{showImage ? (
<img
src={listing.productImage}
src={imageSrc}
alt={primaryName}
className={styles.image}
loading="lazy"
onError={() => setImageFailed(true)}
/>
) : (
<div className={styles.imagePlaceholder} />
<div className={styles.imagePlaceholder} aria-hidden="true">
<ImageOff size={28} strokeWidth={1.5} />
</div>
)}
{listing.showFestival && (
<span className={styles.festivalBadge}>{t('storeItems.card.festival')}</span>
@@ -8,6 +8,17 @@
border-radius: 50px;
}
.contactPrice {
display: inline-block;
padding: 3px 10px;
font-size: 11px;
font-weight: 600;
font-family: var(--font-ui);
color: var(--text-muted);
background: rgba(148, 163, 184, 0.14);
border-radius: 50px;
}
.discountWrap {
display: flex;
flex-direction: column;
@@ -1,3 +1,4 @@
import { useT } from '../i18n/useT'
import {
calcDiscountPercent,
formatIrtPrice,
@@ -11,8 +12,14 @@ interface StoreItemPriceProps {
}
export function StoreItemPrice({ price, discountedPrice = null }: StoreItemPriceProps) {
const t = useT()
if (price === null || price === undefined) {
return <span className={styles.contactPrice}>{t('storeItems.price.contact')}</span>
}
if (hasStoreItemDiscount(price, discountedPrice)) {
const percent = calcDiscountPercent(price!, discountedPrice!)
const percent = calcDiscountPercent(price, discountedPrice!)
return (
<div className={styles.discountWrap}>
<span className={styles.originalPrice}>{formatIrtPrice(price)}</span>