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>
+36
View File
@@ -408,14 +408,32 @@ const en = {
'storeItems.create.loadingVariations': 'Loading product variations...',
'storeItems.create.price': 'Price (IRT)',
'storeItems.create.stock': 'Stock',
'storeItems.create.unlimited': 'Unlimited',
'storeItems.create.removeRow': 'Remove row',
'storeItems.create.addRow': 'Add row',
'storeItems.create.addAllProducts': 'Add all products',
'storeItems.create.addingAll': 'Creating for all products…',
'storeItems.create.addAllDone': 'Created {created}, skipped {skipped}, failed {failed}',
'storeItems.create.addAllConfirmTitle': 'Add store items for all products?',
'storeItems.create.addAllConfirmIntro':
'Sellable variants will be created for every product that is not yet in the store. Use this only when you want to add all products to the store at once.',
'storeItems.create.addAllConfirmMax':
'At most the first 5 options from variations will be created (not the full combination of all variations).',
'storeItems.create.addAllConfirmSkip':
'Products that are already in the store will be skipped.',
'storeItems.create.addAllConfirmHeavy':
'Especially with a large number of products, this is a heavy task — use it wisely.',
'storeItems.create.addAllAgree': 'I understand and want to continue',
'storeItems.create.addAllConfirm': 'Create all',
'storeItems.create.addAllResultTitle': 'Bulk create finished',
'storeItems.create.addAllDoneBtn': 'Done',
'storeItems.create.cancel': 'Cancel',
'storeItems.create.submit': 'Create store items',
'storeItems.create.submitting': 'Creating…',
'storeItems.create.errorProducts': 'Unable to load products.',
'storeItems.create.errorVariations': 'Unable to load product variations.',
'storeItems.create.errorCreate': 'Unable to create store items.',
'storeItems.price.contact': 'Contact for price',
'storeItems.edit.title': 'Edit Store Items',
'storeItems.edit.save': 'Save changes',
'storeItems.edit.saving': 'Saving…',
@@ -1475,14 +1493,32 @@ const fa: Record<MessageKey, string> = {
'storeItems.create.loadingVariations': 'در حال بارگذاری تنوع‌های محصول...',
'storeItems.create.price': 'قیمت (IRT)',
'storeItems.create.stock': 'موجودی',
'storeItems.create.unlimited': 'نامحدود',
'storeItems.create.removeRow': 'حذف ردیف',
'storeItems.create.addRow': 'افزودن ردیف',
'storeItems.create.addAllProducts': 'افزودن همه محصولات',
'storeItems.create.addingAll': 'در حال ایجاد برای همه محصولات…',
'storeItems.create.addAllDone': 'ایجاد شد {created}، رد شد {skipped}، ناموفق {failed}',
'storeItems.create.addAllConfirmTitle': 'افزودن اقلام برای همه محصولات؟',
'storeItems.create.addAllConfirmIntro':
'برای هر محصولی که هنوز به فروشگاه اضافه نشده است، تنوع‌های قابل فروش ساخته می‌شود. فقط وقتی می‌خواهید همهٔ محصولات یک‌جا به فروشگاه اضافه شود از این گزینه استفاده کنید.',
'storeItems.create.addAllConfirmMax':
'حداکثر ۵ گزینهٔ اول از تنوع‌ها ساخته خواهد شد (نه ترکیب کامل همهٔ تنوع‌ها).',
'storeItems.create.addAllConfirmSkip':
'محصولاتی که در فروشگاه هستند رد خواهند شد.',
'storeItems.create.addAllConfirmHeavy':
'این کار به‌خصوص در تعداد محصول بالا، کار سنگینی محسوب می‌شود — از آن هوشمندانه استفاده کنید.',
'storeItems.create.addAllAgree': 'متوجه شدم و می‌خواهم ادامه دهم',
'storeItems.create.addAllConfirm': 'ایجاد برای همه',
'storeItems.create.addAllResultTitle': 'ایجاد گروهی تمام شد',
'storeItems.create.addAllDoneBtn': 'باشه',
'storeItems.create.cancel': 'انصراف',
'storeItems.create.submit': 'ایجاد اقلام فروشگاه',
'storeItems.create.submitting': 'در حال ایجاد…',
'storeItems.create.errorProducts': 'بارگذاری محصولات ممکن نشد.',
'storeItems.create.errorVariations': 'بارگذاری تنوع‌های محصول ممکن نشد.',
'storeItems.create.errorCreate': 'ایجاد اقلام فروشگاه ممکن نشد.',
'storeItems.price.contact': 'برای قیمت، تماس بگیرید',
'storeItems.edit.title': 'ویرایش اقلام فروشگاه',
'storeItems.edit.save': 'ذخیره تغییرات',
'storeItems.edit.saving': 'در حال ذخیره…',
+10 -6
View File
@@ -36,8 +36,10 @@ export interface StoreItemsListResponse {
export interface CreateStoreItemPayload {
selections: { variationId: string; optionId: string }[]
price?: number
stockQuantity?: number
/** null = contact for price */
price?: number | null
/** null = unlimited stock */
stockQuantity?: number | null
}
export interface BatchCreateStoreItemsPayload {
@@ -46,8 +48,8 @@ export interface BatchCreateStoreItemsPayload {
}
export interface UpdateStoreItemPayload {
price?: number
stockQuantity?: number
price?: number | null
stockQuantity?: number | null
discountedPrice?: number | null
isFestival?: boolean
}
@@ -60,8 +62,10 @@ export interface BatchUpdateStoreItemDiscountsPayload {
export interface SyncStoreItemRowPayload {
id?: string
selections: { variationId: string; optionId: string }[]
price: number
stockQuantity: number
/** null = contact for price */
price: number | null
/** null = unlimited stock */
stockQuantity: number | null
}
export interface SyncProductStoreItemsPayload {
+29
View File
@@ -1,4 +1,5 @@
import type { ProductVariationSelection } from '../services/productVariationService'
import type { CreateStoreItemPayload } from '../services/storeItemService'
import { createId } from './id'
export interface StoreItemDraftRow {
@@ -7,8 +8,13 @@ export interface StoreItemDraftRow {
selections: Record<string, string>
price: string
stock: string
/** When true, stockQuantity is sent as null (unlimited). */
unlimitedStock: boolean
}
/** Bulk-create: only first variation, at most this many options. */
export const BULK_CREATE_MAX_OPTIONS = 5
export function getVariationOptions(variation: ProductVariationSelection) {
if (variation.selectedOptionIds.length > 0) {
return variation.options.filter((option) =>
@@ -24,5 +30,28 @@ export function createEmptyStoreItemRow(): StoreItemDraftRow {
selections: {},
price: '',
stock: '1',
unlimitedStock: false,
}
}
/** Build create payloads for bulk “add all products” (price null, stock 0). */
export function buildBulkCreateItems(
variations: ProductVariationSelection[],
): CreateStoreItemPayload[] {
const withOptions = variations.filter(
(variation) => getVariationOptions(variation).length > 0,
)
if (withOptions.length === 0) {
return [{ selections: [], price: null, stockQuantity: 0 }]
}
const first = withOptions[0]!
const options = getVariationOptions(first).slice(0, BULK_CREATE_MAX_OPTIONS)
return options.map((option) => ({
selections: [{ variationId: first.id, optionId: option.id }],
price: null,
stockQuantity: 0,
}))
}
@@ -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>
+2
View File
@@ -154,6 +154,7 @@ const en = {
'favorites.inStock': '{count} in stock',
'favorites.variantOne': '1 variant',
'favorites.variantMany': '{count} variants',
'storeItems.price.contact': 'Contact for price',
'title.signIn': 'Sign in',
'title.checkout': 'Checkout',
@@ -384,6 +385,7 @@ const fa: Record<MessageKey, string> = {
'favorites.inStock': '{count} موجود',
'favorites.variantOne': '۱ تنوع',
'favorites.variantMany': '{count} تنوع',
'storeItems.price.contact': 'برای قیمت، تماس بگیرید',
'title.signIn': 'ورود',
'title.checkout': 'تسویه‌حساب',