mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Add store-item bulk create, unlimited stock, and contact-for-price UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
5eaf7fb046
commit
ad83fc1099
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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': 'در حال ذخیره…',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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': 'تسویهحساب',
|
||||
|
||||
Reference in New Issue
Block a user