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;
|
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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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': 'در حال ذخیره…',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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': 'تسویهحساب',
|
||||||
|
|||||||
Reference in New Issue
Block a user