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; cursor: pointer;
} }
.rowActions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.addRowBtn { .addRowBtn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -76,13 +84,82 @@
color: var(--primary); color: var(--primary);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
transition: background 0.2s; transition: background 0.2s;
margin-bottom: 8px;
} }
.addRowBtn:hover { .addRowBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1); 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 { .modalWide {
max-width: 920px; max-width: 920px;
} }
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom' 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 { useLocale } from '@meshkee/dashboard-ui'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { listAllProducts } from '../services/productService' import { listAllProducts } from '../services/productService'
@@ -16,10 +17,15 @@ import {
import { createId } from '../utils/id' import { createId } from '../utils/id'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice' import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import { import {
buildBulkCreateItems,
createEmptyStoreItemRow, createEmptyStoreItemRow,
getVariationOptions, getVariationOptions,
type StoreItemDraftRow, type StoreItemDraftRow,
} from '../utils/storeItemRows' } from '../utils/storeItemRows'
import {
ConfirmBulkAddStoreItemsModal,
type BulkAddResult,
} from './ConfirmBulkAddStoreItemsModal'
import { ProductSearchSelect } from './ProductSearchSelect' import { ProductSearchSelect } from './ProductSearchSelect'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import modalStyles from './VariationsModal.module.css' import modalStyles from './VariationsModal.module.css'
@@ -44,6 +50,7 @@ export function CreateStoreItemsModal({
}: CreateStoreItemsModalProps) { }: CreateStoreItemsModalProps) {
const t = useT() const t = useT()
const { locale } = useLocale() const { locale } = useLocale()
const { showToast } = useToast()
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [products, setProducts] = useState<{ id: string; title: string; nameFa: string }[]>([]) const [products, setProducts] = useState<{ id: string; title: string; nameFa: string }[]>([])
@@ -53,8 +60,13 @@ export function CreateStoreItemsModal({
const [isLoadingProducts, setIsLoadingProducts] = useState(false) const [isLoadingProducts, setIsLoadingProducts] = useState(false)
const [isLoadingVariations, setIsLoadingVariations] = useState(false) const [isLoadingVariations, setIsLoadingVariations] = useState(false)
const [isSubmitting, setIsSubmitting] = 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 [error, setError] = useState('')
const busy = isSubmitting || isBulkCreating
const formVariations = useMemo( const formVariations = useMemo(
() => variations.filter((variation) => getVariationOptions(variation).length > 0), () => variations.filter((variation) => getVariationOptions(variation).length > 0),
[variations], [variations],
@@ -62,8 +74,10 @@ export function CreateStoreItemsModal({
const gridTemplate = useMemo(() => { const gridTemplate = useMemo(() => {
const variationCols = formVariations.map(() => 'minmax(110px, 1fr)').join(' ') const variationCols = formVariations.map(() => 'minmax(110px, 1fr)').join(' ')
const cols = [variationCols, 'minmax(120px, 1fr)', '90px', '32px'].filter((part) => part).join(' ') const cols = [variationCols, 'minmax(120px, 1fr)', '90px', '72px', '32px']
return cols || 'minmax(120px, 1fr) 90px 32px' .filter((part) => part)
.join(' ')
return cols || 'minmax(120px, 1fr) 90px 72px 32px'
}, [formVariations]) }, [formVariations])
useEffect(() => { useEffect(() => {
@@ -79,6 +93,8 @@ export function CreateStoreItemsModal({
setVariations([]) setVariations([])
setRows([]) setRows([])
setError('') setError('')
setBulkConfirmOpen(false)
setBulkResult(null)
}, ANIMATION_MS) }, ANIMATION_MS)
return () => clearTimeout(timer) return () => clearTimeout(timer)
} }
@@ -154,11 +170,11 @@ export function CreateStoreItemsModal({
useEffect(() => { useEffect(() => {
if (!mounted || closing) return if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose() if (e.key === 'Escape' && !busy && !bulkConfirmOpen) onClose()
} }
document.addEventListener('keydown', onKey) document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose]) }, [mounted, closing, onClose, busy, bulkConfirmOpen])
if (!mounted) return null if (!mounted) return null
@@ -205,8 +221,12 @@ export function CreateStoreItemsModal({
if (!optionId) return [] if (!optionId) return []
return [{ variationId: variation.id, optionId }] return [{ variationId: variation.id, optionId }]
}), }),
price: parseIrtInput(row.price) ?? undefined, price: parseIrtInput(row.price),
stockQuantity: row.stock.trim() ? Number(row.stock) : undefined, stockQuantity: row.unlimitedStock
? null
: row.stock.trim()
? Number(row.stock)
: null,
})) }))
} }
@@ -215,10 +235,12 @@ export function CreateStoreItemsModal({
rows.length > 0 && rows.length > 0 &&
rows.every((row) => { rows.every((row) => {
const price = parseIrtInput(row.price) const price = parseIrtInput(row.price)
if (price !== null && price < 0) return false
if (row.unlimitedStock) return true
const stock = Number(row.stock) 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 !isLoadingVariations
async function handleSubmit(e: React.FormEvent) { 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( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose} onClick={() => {
if (!busy && !bulkConfirmOpen) onClose()
}}
> >
<div <div
className={`${modalStyles.modal} ${rowStyles.modalWide} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`} className={`${modalStyles.modal} ${rowStyles.modalWide} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
@@ -271,6 +374,7 @@ export function CreateStoreItemsModal({
className={modalStyles.closeBtn} className={modalStyles.closeBtn}
onClick={onClose} onClick={onClose}
aria-label={t('common.close')} aria-label={t('common.close')}
disabled={busy}
> >
<X size={20} /> <X size={20} />
</button> </button>
@@ -284,7 +388,7 @@ export function CreateStoreItemsModal({
value={productId} value={productId}
onChange={handleProductChange} onChange={handleProductChange}
placeholder={t('storeItems.create.productPlaceholder')} placeholder={t('storeItems.create.productPlaceholder')}
disabled={isLoadingProducts || isSubmitting} disabled={isLoadingProducts || busy}
aria-label={t('storeItems.create.productPlaceholder')} aria-label={t('storeItems.create.productPlaceholder')}
/> />
</div> </div>
@@ -304,6 +408,7 @@ export function CreateStoreItemsModal({
))} ))}
<span>{t('storeItems.create.price')}</span> <span>{t('storeItems.create.price')}</span>
<span>{t('storeItems.create.stock')}</span> <span>{t('storeItems.create.stock')}</span>
<span>{t('storeItems.create.unlimited')}</span>
<span /> <span />
</div> </div>
@@ -324,7 +429,7 @@ export function CreateStoreItemsModal({
onChange={(e) => onChange={(e) =>
updateRowSelection(row.id, variation.id, e.target.value) updateRowSelection(row.id, variation.id, e.target.value)
} }
disabled={isSubmitting} disabled={busy}
> >
<option value=""></option> <option value=""></option>
{options.map((option) => ( {options.map((option) => (
@@ -341,25 +446,38 @@ export function CreateStoreItemsModal({
className={rowStyles.compactInput} className={rowStyles.compactInput}
value={row.price} value={row.price}
onChange={(e) => updateRow(row.id, { price: formatIrtInput(e.target.value) })} onChange={(e) => updateRow(row.id, { price: formatIrtInput(e.target.value) })}
placeholder="0" placeholder={t('storeItems.price.contact')}
disabled={isSubmitting} disabled={busy}
/> />
<input <input
type="number" type="number"
min="0" min="0"
step="1" step="1"
className={rowStyles.compactInput} className={rowStyles.compactInput}
value={row.stock} value={row.unlimitedStock ? '' : row.stock}
onChange={(e) => updateRow(row.id, { stock: e.target.value })} 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}> <div className={rowStyles.removeCell}>
<Tooltip label={t('storeItems.create.removeRow')}> <Tooltip label={t('storeItems.create.removeRow')}>
<button <button
type="button" type="button"
className={modalStyles.removeRowBtn} className={modalStyles.removeRowBtn}
onClick={() => removeRow(row.id)} onClick={() => removeRow(row.id)}
disabled={rows.length <= 1 || isSubmitting} disabled={rows.length <= 1 || busy}
aria-label={t('storeItems.create.removeRow')} aria-label={t('storeItems.create.removeRow')}
> >
<X size={16} /> <X size={16} />
@@ -370,41 +488,62 @@ export function CreateStoreItemsModal({
))} ))}
</div> </div>
<button <div className={rowStyles.rowActions}>
type="button" <button
className={rowStyles.addRowBtn} type="button"
onClick={addRow} className={rowStyles.addRowBtn}
disabled={isSubmitting} onClick={addRow}
> disabled={busy}
<Plus size={16} /> >
{t('storeItems.create.addRow')} <Plus size={16} />
</button> {t('storeItems.create.addRow')}
</button>
</div>
</> </>
)} )}
{error && <p className={modalStyles.errorText}>{error}</p>} {error && <p className={modalStyles.errorText}>{error}</p>}
<div className={modalStyles.actions}> <div className={rowStyles.footerActions}>
<button <button
type="button" type="button"
className={modalStyles.cancelBtn} className={rowStyles.footerBulkBtn}
onClick={onClose} onClick={openBulkConfirm}
disabled={isSubmitting} disabled={busy || isLoadingProducts || products.length === 0}
> >
{t('storeItems.create.cancel')} <PackagePlus size={16} />
</button> {t('storeItems.create.addAllProducts')}
<button
type="submit"
className={modalStyles.submitBtn}
disabled={!canSubmit}
>
{isSubmitting
? t('storeItems.create.submitting')
: t('storeItems.create.submit')}
</button> </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> </div>
</form> </form>
</div> </div>
<ConfirmBulkAddStoreItemsModal
open={bulkConfirmOpen}
busy={isBulkCreating}
result={bulkResult}
onConfirm={() => void handleAddAllProducts()}
onClose={closeBulkConfirm}
/>
</div> </div>
, ,
document.body, document.body,
@@ -43,12 +43,15 @@ function itemsToRows(storeItems: StoreItem[]): StoreItemDraftRow[] {
selections[selection.variationId] = selection.optionId selections[selection.variationId] = selection.optionId
} }
const unlimited = item.stockQuantity === null
return { return {
id: createId(), id: createId(),
storeItemId: item.id, storeItemId: item.id,
selections, selections,
price: item.price !== null ? formatIrtInput(String(item.price)) : '', 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 gridTemplate = useMemo(() => {
const variationCols = formVariations.map(() => 'minmax(110px, 1fr)').join(' ') const variationCols = formVariations.map(() => 'minmax(110px, 1fr)').join(' ')
const cols = [variationCols, 'minmax(120px, 1fr)', '90px', '32px'].filter((part) => part).join(' ') const cols = [variationCols, 'minmax(120px, 1fr)', '90px', '72px', '32px']
return cols || 'minmax(120px, 1fr) 90px 32px' .filter((part) => part)
.join(' ')
return cols || 'minmax(120px, 1fr) 90px 72px 32px'
}, [formVariations]) }, [formVariations])
useEffect(() => { useEffect(() => {
@@ -128,11 +133,11 @@ export function EditStoreItemsModal({
useEffect(() => { useEffect(() => {
if (!mounted || closing) return if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose() if (e.key === 'Escape' && !isSubmitting) onClose()
} }
document.addEventListener('keydown', onKey) document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose]) }, [mounted, closing, onClose, isSubmitting])
if (!mounted || !productId) return null if (!mounted || !productId) return null
@@ -174,8 +179,10 @@ export function EditStoreItemsModal({
rows.length > 0 && rows.length > 0 &&
rows.every((row) => { rows.every((row) => {
const price = parseIrtInput(row.price) const price = parseIrtInput(row.price)
if (price !== null && price < 0) return false
if (row.unlimitedStock) return true
const stock = Number(row.stock) 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 && !isSubmitting &&
!isLoading !isLoading
@@ -197,8 +204,12 @@ export function EditStoreItemsModal({
if (!optionId) return [] if (!optionId) return []
return [{ variationId: variation.id, optionId }] return [{ variationId: variation.id, optionId }]
}), }),
price: parseIrtInput(row.price)!, price: parseIrtInput(row.price),
stockQuantity: Number(row.stock), stockQuantity: row.unlimitedStock
? null
: row.stock.trim()
? Number(row.stock)
: null,
})), })),
removedIds, removedIds,
}) })
@@ -222,7 +233,9 @@ export function EditStoreItemsModal({
return createPortal( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose} onClick={() => {
if (!isSubmitting) onClose()
}}
> >
<div <div
className={`${modalStyles.modal} ${rowStyles.modalWide} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`} className={`${modalStyles.modal} ${rowStyles.modalWide} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
@@ -244,6 +257,7 @@ export function EditStoreItemsModal({
className={modalStyles.closeBtn} className={modalStyles.closeBtn}
onClick={onClose} onClick={onClose}
aria-label={t('common.close')} aria-label={t('common.close')}
disabled={isSubmitting}
> >
<X size={20} /> <X size={20} />
</button> </button>
@@ -263,6 +277,7 @@ export function EditStoreItemsModal({
))} ))}
<span>{t('storeItems.create.price')}</span> <span>{t('storeItems.create.price')}</span>
<span>{t('storeItems.create.stock')}</span> <span>{t('storeItems.create.stock')}</span>
<span>{t('storeItems.create.unlimited')}</span>
<span /> <span />
</div> </div>
@@ -300,7 +315,7 @@ export function EditStoreItemsModal({
className={rowStyles.compactInput} className={rowStyles.compactInput}
value={row.price} value={row.price}
onChange={(e) => updateRow(row.id, { price: formatIrtInput(e.target.value) })} onChange={(e) => updateRow(row.id, { price: formatIrtInput(e.target.value) })}
placeholder="0" placeholder={t('storeItems.price.contact')}
disabled={isSubmitting} disabled={isSubmitting}
/> />
<input <input
@@ -308,10 +323,23 @@ export function EditStoreItemsModal({
min="0" min="0"
step="1" step="1"
className={rowStyles.compactInput} className={rowStyles.compactInput}
value={row.stock} value={row.unlimitedStock ? '' : row.stock}
onChange={(e) => updateRow(row.id, { stock: e.target.value })} 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}> <div className={rowStyles.removeCell}>
<Tooltip label={t('storeItems.create.removeRow')}> <Tooltip label={t('storeItems.create.removeRow')}>
<button <button
@@ -329,15 +357,17 @@ export function EditStoreItemsModal({
))} ))}
</div> </div>
<button <div className={rowStyles.rowActions}>
type="button" <button
className={rowStyles.addRowBtn} type="button"
onClick={addRow} className={rowStyles.addRowBtn}
disabled={isSubmitting} onClick={addRow}
> disabled={isSubmitting}
<Plus size={16} /> >
{t('storeItems.create.addRow')} <Plus size={16} />
</button> {t('storeItems.create.addRow')}
</button>
</div>
</> </>
)} )}
@@ -52,7 +52,15 @@
.imagePlaceholder { .imagePlaceholder {
width: 100%; width: 100%;
height: 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, .festivalBadge,
+15 -5
View File
@@ -1,5 +1,5 @@
import { useId } from 'react' import { useEffect, useId, useState } from 'react'
import { Pencil, Percent, Sparkles, Trash2 } from 'lucide-react' import { ImageOff, Pencil, Percent, Sparkles, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui' import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import { import {
@@ -80,20 +80,30 @@ export function StoreItemCard({
? listing.productTitle ? listing.productTitle
: '' : ''
: listing.productNameFa : listing.productNameFa
const imageSrc = listing.productImage?.trim() || ''
const [imageFailed, setImageFailed] = useState(false)
const showImage = Boolean(imageSrc) && !imageFailed
useEffect(() => {
setImageFailed(false)
}, [imageSrc])
return ( return (
<article className={styles.card}> <article className={styles.card}>
<button type="button" className={styles.clickable} onClick={() => onOpen(listing)}> <button type="button" className={styles.clickable} onClick={() => onOpen(listing)}>
<div className={styles.imageWrap}> <div className={styles.imageWrap}>
{listing.productImage ? ( {showImage ? (
<img <img
src={listing.productImage} src={imageSrc}
alt={primaryName} alt={primaryName}
className={styles.image} className={styles.image}
loading="lazy" 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 && ( {listing.showFestival && (
<span className={styles.festivalBadge}>{t('storeItems.card.festival')}</span> <span className={styles.festivalBadge}>{t('storeItems.card.festival')}</span>
@@ -8,6 +8,17 @@
border-radius: 50px; 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 { .discountWrap {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1,3 +1,4 @@
import { useT } from '../i18n/useT'
import { import {
calcDiscountPercent, calcDiscountPercent,
formatIrtPrice, formatIrtPrice,
@@ -11,8 +12,14 @@ interface StoreItemPriceProps {
} }
export function StoreItemPrice({ price, discountedPrice = null }: 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)) { if (hasStoreItemDiscount(price, discountedPrice)) {
const percent = calcDiscountPercent(price!, discountedPrice!) const percent = calcDiscountPercent(price, discountedPrice!)
return ( return (
<div className={styles.discountWrap}> <div className={styles.discountWrap}>
<span className={styles.originalPrice}>{formatIrtPrice(price)}</span> <span className={styles.originalPrice}>{formatIrtPrice(price)}</span>
+36
View File
@@ -408,14 +408,32 @@ const en = {
'storeItems.create.loadingVariations': 'Loading product variations...', 'storeItems.create.loadingVariations': 'Loading product variations...',
'storeItems.create.price': 'Price (IRT)', 'storeItems.create.price': 'Price (IRT)',
'storeItems.create.stock': 'Stock', 'storeItems.create.stock': 'Stock',
'storeItems.create.unlimited': 'Unlimited',
'storeItems.create.removeRow': 'Remove row', 'storeItems.create.removeRow': 'Remove row',
'storeItems.create.addRow': 'Add 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.cancel': 'Cancel',
'storeItems.create.submit': 'Create store items', 'storeItems.create.submit': 'Create store items',
'storeItems.create.submitting': 'Creating…', 'storeItems.create.submitting': 'Creating…',
'storeItems.create.errorProducts': 'Unable to load products.', 'storeItems.create.errorProducts': 'Unable to load products.',
'storeItems.create.errorVariations': 'Unable to load product variations.', 'storeItems.create.errorVariations': 'Unable to load product variations.',
'storeItems.create.errorCreate': 'Unable to create store items.', 'storeItems.create.errorCreate': 'Unable to create store items.',
'storeItems.price.contact': 'Contact for price',
'storeItems.edit.title': 'Edit Store Items', 'storeItems.edit.title': 'Edit Store Items',
'storeItems.edit.save': 'Save changes', 'storeItems.edit.save': 'Save changes',
'storeItems.edit.saving': 'Saving…', 'storeItems.edit.saving': 'Saving…',
@@ -1475,14 +1493,32 @@ const fa: Record<MessageKey, string> = {
'storeItems.create.loadingVariations': 'در حال بارگذاری تنوع‌های محصول...', 'storeItems.create.loadingVariations': 'در حال بارگذاری تنوع‌های محصول...',
'storeItems.create.price': 'قیمت (IRT)', 'storeItems.create.price': 'قیمت (IRT)',
'storeItems.create.stock': 'موجودی', 'storeItems.create.stock': 'موجودی',
'storeItems.create.unlimited': 'نامحدود',
'storeItems.create.removeRow': 'حذف ردیف', 'storeItems.create.removeRow': 'حذف ردیف',
'storeItems.create.addRow': 'افزودن ردیف', '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.cancel': 'انصراف',
'storeItems.create.submit': 'ایجاد اقلام فروشگاه', 'storeItems.create.submit': 'ایجاد اقلام فروشگاه',
'storeItems.create.submitting': 'در حال ایجاد…', 'storeItems.create.submitting': 'در حال ایجاد…',
'storeItems.create.errorProducts': 'بارگذاری محصولات ممکن نشد.', 'storeItems.create.errorProducts': 'بارگذاری محصولات ممکن نشد.',
'storeItems.create.errorVariations': 'بارگذاری تنوع‌های محصول ممکن نشد.', 'storeItems.create.errorVariations': 'بارگذاری تنوع‌های محصول ممکن نشد.',
'storeItems.create.errorCreate': 'ایجاد اقلام فروشگاه ممکن نشد.', 'storeItems.create.errorCreate': 'ایجاد اقلام فروشگاه ممکن نشد.',
'storeItems.price.contact': 'برای قیمت، تماس بگیرید',
'storeItems.edit.title': 'ویرایش اقلام فروشگاه', 'storeItems.edit.title': 'ویرایش اقلام فروشگاه',
'storeItems.edit.save': 'ذخیره تغییرات', 'storeItems.edit.save': 'ذخیره تغییرات',
'storeItems.edit.saving': 'در حال ذخیره…', 'storeItems.edit.saving': 'در حال ذخیره…',
+10 -6
View File
@@ -36,8 +36,10 @@ export interface StoreItemsListResponse {
export interface CreateStoreItemPayload { export interface CreateStoreItemPayload {
selections: { variationId: string; optionId: string }[] selections: { variationId: string; optionId: string }[]
price?: number /** null = contact for price */
stockQuantity?: number price?: number | null
/** null = unlimited stock */
stockQuantity?: number | null
} }
export interface BatchCreateStoreItemsPayload { export interface BatchCreateStoreItemsPayload {
@@ -46,8 +48,8 @@ export interface BatchCreateStoreItemsPayload {
} }
export interface UpdateStoreItemPayload { export interface UpdateStoreItemPayload {
price?: number price?: number | null
stockQuantity?: number stockQuantity?: number | null
discountedPrice?: number | null discountedPrice?: number | null
isFestival?: boolean isFestival?: boolean
} }
@@ -60,8 +62,10 @@ export interface BatchUpdateStoreItemDiscountsPayload {
export interface SyncStoreItemRowPayload { export interface SyncStoreItemRowPayload {
id?: string id?: string
selections: { variationId: string; optionId: string }[] selections: { variationId: string; optionId: string }[]
price: number /** null = contact for price */
stockQuantity: number price: number | null
/** null = unlimited stock */
stockQuantity: number | null
} }
export interface SyncProductStoreItemsPayload { export interface SyncProductStoreItemsPayload {
+29
View File
@@ -1,4 +1,5 @@
import type { ProductVariationSelection } from '../services/productVariationService' import type { ProductVariationSelection } from '../services/productVariationService'
import type { CreateStoreItemPayload } from '../services/storeItemService'
import { createId } from './id' import { createId } from './id'
export interface StoreItemDraftRow { export interface StoreItemDraftRow {
@@ -7,8 +8,13 @@ export interface StoreItemDraftRow {
selections: Record<string, string> selections: Record<string, string>
price: string price: string
stock: 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) { export function getVariationOptions(variation: ProductVariationSelection) {
if (variation.selectedOptionIds.length > 0) { if (variation.selectedOptionIds.length > 0) {
return variation.options.filter((option) => return variation.options.filter((option) =>
@@ -24,5 +30,28 @@ export function createEmptyStoreItemRow(): StoreItemDraftRow {
selections: {}, selections: {},
price: '', price: '',
stock: '1', 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; 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 { .discountWrap {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1,3 +1,4 @@
import { useT } from '../i18n/useT'
import { import {
calcDiscountPercent, calcDiscountPercent,
formatIrtPrice, formatIrtPrice,
@@ -11,8 +12,14 @@ interface StoreItemPriceProps {
} }
export function StoreItemPrice({ price, discountedPrice = null }: 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)) { if (hasStoreItemDiscount(price, discountedPrice)) {
const percent = calcDiscountPercent(price!, discountedPrice!) const percent = calcDiscountPercent(price, discountedPrice!)
return ( return (
<div className={styles.discountWrap}> <div className={styles.discountWrap}>
<span className={styles.originalPrice}>{formatIrtPrice(price)}</span> <span className={styles.originalPrice}>{formatIrtPrice(price)}</span>
+2
View File
@@ -154,6 +154,7 @@ const en = {
'favorites.inStock': '{count} in stock', 'favorites.inStock': '{count} in stock',
'favorites.variantOne': '1 variant', 'favorites.variantOne': '1 variant',
'favorites.variantMany': '{count} variants', 'favorites.variantMany': '{count} variants',
'storeItems.price.contact': 'Contact for price',
'title.signIn': 'Sign in', 'title.signIn': 'Sign in',
'title.checkout': 'Checkout', 'title.checkout': 'Checkout',
@@ -384,6 +385,7 @@ const fa: Record<MessageKey, string> = {
'favorites.inStock': '{count} موجود', 'favorites.inStock': '{count} موجود',
'favorites.variantOne': '۱ تنوع', 'favorites.variantOne': '۱ تنوع',
'favorites.variantMany': '{count} تنوع', 'favorites.variantMany': '{count} تنوع',
'storeItems.price.contact': 'برای قیمت، تماس بگیرید',
'title.signIn': 'ورود', 'title.signIn': 'ورود',
'title.checkout': 'تسویه‌حساب', 'title.checkout': 'تسویه‌حساب',