Add used-product flag for store listings.
Merchants can mark items as used on create/edit, see a used badge on cards, and filter the store list for used products. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
32f0dbab63
commit
61d145d92e
@@ -155,6 +155,31 @@
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.usedCheckLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 4px 0 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.usedCheck {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--primary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.usedCheck:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.compactInput:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
|
||||
@@ -55,6 +55,7 @@ export function CreateStoreItemsModal({
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [products, setProducts] = useState<{ id: string; title: string; nameFa: string }[]>([])
|
||||
const [productId, setProductId] = useState('')
|
||||
const [isUsed, setIsUsed] = useState(false)
|
||||
const [variations, setVariations] = useState<ProductVariationSelection[]>([])
|
||||
const [rows, setRows] = useState<StoreItemDraftRow[]>([])
|
||||
const [isLoadingProducts, setIsLoadingProducts] = useState(false)
|
||||
@@ -118,6 +119,7 @@ export function CreateStoreItemsModal({
|
||||
setMounted(false)
|
||||
setClosing(false)
|
||||
setProductId('')
|
||||
setIsUsed(false)
|
||||
setVariations([])
|
||||
setRows([])
|
||||
setError('')
|
||||
@@ -281,6 +283,7 @@ export function CreateStoreItemsModal({
|
||||
try {
|
||||
await batchCreateStoreItems({
|
||||
productId,
|
||||
isUsed,
|
||||
items: buildPayload(),
|
||||
})
|
||||
onCreated?.()
|
||||
@@ -421,6 +424,17 @@ export function CreateStoreItemsModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className={rowStyles.usedCheckLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={rowStyles.usedCheck}
|
||||
checked={isUsed}
|
||||
onChange={(e) => setIsUsed(e.target.checked)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<span>{t('storeItems.create.isUsed')}</span>
|
||||
</label>
|
||||
|
||||
{productId && isLoadingVariations && (
|
||||
<p className={modalStyles.emptyText}>{t('storeItems.create.loadingVariations')}</p>
|
||||
)}
|
||||
|
||||
@@ -72,6 +72,7 @@ export function EditStoreItemsModal({
|
||||
const [variations, setVariations] = useState<ProductVariationSelection[]>([])
|
||||
const [rows, setRows] = useState<StoreItemDraftRow[]>([])
|
||||
const [removedIds, setRemovedIds] = useState<string[]>([])
|
||||
const [isUsed, setIsUsed] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -123,6 +124,7 @@ export function EditStoreItemsModal({
|
||||
setClosing(false)
|
||||
setRemovedIds([])
|
||||
setRows(itemsToRows(items))
|
||||
setIsUsed(items.some((item) => item.isUsed))
|
||||
setError('')
|
||||
const controller = new AbortController()
|
||||
void loadVariations(productId, controller.signal)
|
||||
@@ -223,6 +225,7 @@ export function EditStoreItemsModal({
|
||||
try {
|
||||
const data = await syncProductStoreItems({
|
||||
productId,
|
||||
isUsed,
|
||||
items: rows.map((row) => ({
|
||||
id: row.storeItemId,
|
||||
selections: formVariations.flatMap((variation) => {
|
||||
@@ -294,6 +297,17 @@ export function EditStoreItemsModal({
|
||||
<p className={modalStyles.emptyText}>{t('storeItems.create.loadingVariations')}</p>
|
||||
) : (
|
||||
<>
|
||||
<label className={rowStyles.usedCheckLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={rowStyles.usedCheck}
|
||||
checked={isUsed}
|
||||
onChange={(e) => setIsUsed(e.target.checked)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<span>{t('storeItems.create.isUsed')}</span>
|
||||
</label>
|
||||
|
||||
<div
|
||||
className={rowStyles.itemRowHeader}
|
||||
style={{ gridTemplateColumns: gridTemplate }}
|
||||
|
||||
@@ -64,7 +64,8 @@
|
||||
}
|
||||
|
||||
.festivalBadge,
|
||||
.stockBadge {
|
||||
.stockBadge,
|
||||
.usedBadge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
padding: 4px 10px;
|
||||
@@ -86,6 +87,14 @@
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.usedBadge {
|
||||
inset-inline-start: 8px;
|
||||
top: auto;
|
||||
bottom: 8px;
|
||||
color: #b45309;
|
||||
background: rgba(255, 251, 235, 0.95);
|
||||
}
|
||||
|
||||
.stockBadge {
|
||||
inset-inline-end: 8px;
|
||||
color: var(--primary);
|
||||
|
||||
@@ -115,6 +115,9 @@ export function StoreItemCard({
|
||||
{listing.showFestival && (
|
||||
<span className={styles.festivalBadge}>{t('storeItems.card.festival')}</span>
|
||||
)}
|
||||
{listing.isUsed && (
|
||||
<span className={styles.usedBadge}>{t('storeItems.card.used')}</span>
|
||||
)}
|
||||
{listing.productTotalStock > 0 && (
|
||||
<span className={styles.stockBadge}>
|
||||
{t('storeItems.card.inStock', { count: listing.productTotalStock })}
|
||||
|
||||
@@ -895,6 +895,7 @@ const en = {
|
||||
'storeItems.minPrice': 'Min price (IRT)',
|
||||
'storeItems.maxPrice': 'Max price (IRT)',
|
||||
'storeItems.onlyDiscounted': 'Only discounted',
|
||||
'storeItems.onlyUsed': 'Only used products',
|
||||
'storeItems.search': 'Search',
|
||||
'storeItems.clearFilters': 'Clear filters',
|
||||
'storeItems.excel.export': 'Export Excel',
|
||||
@@ -923,6 +924,7 @@ const en = {
|
||||
'Remove "{name}" and all {variants} from your store? This cannot be undone.',
|
||||
'storeItems.card.inStock': '{count} in stock',
|
||||
'storeItems.card.festival': 'Festival',
|
||||
'storeItems.card.used': 'Used',
|
||||
'storeItems.card.variantOne': '1 variant',
|
||||
'storeItems.card.variantMany': '{count} variants',
|
||||
'storeItems.card.edit': 'Edit store items',
|
||||
@@ -935,6 +937,7 @@ const en = {
|
||||
'storeItems.create.title': 'Add Store Items',
|
||||
'storeItems.create.subtitle': "Create sellable variants from a product's variations.",
|
||||
'storeItems.create.productPlaceholder': 'Product — type 3+ characters to search',
|
||||
'storeItems.create.isUsed': 'This is a used product',
|
||||
'storeItems.create.loadingVariations': 'Loading product variations...',
|
||||
'storeItems.create.price': 'Price (IRT)',
|
||||
'storeItems.create.stock': 'Stock',
|
||||
@@ -3482,6 +3485,7 @@ const fa: Record<MessageKey, string> = {
|
||||
'storeItems.minPrice': 'حداقل قیمت (IRT)',
|
||||
'storeItems.maxPrice': 'حداکثر قیمت (IRT)',
|
||||
'storeItems.onlyDiscounted': 'فقط تخفیفدار',
|
||||
'storeItems.onlyUsed': 'فقط کالای دستدوم',
|
||||
'storeItems.search': 'جستجو',
|
||||
'storeItems.clearFilters': 'پاک کردن فیلترها',
|
||||
'storeItems.excel.export': 'خروجی اکسل',
|
||||
@@ -3510,6 +3514,7 @@ const fa: Record<MessageKey, string> = {
|
||||
'«{name}» و همه {variants} از فروشگاه حذف شوند؟ این عمل قابل بازگشت نیست.',
|
||||
'storeItems.card.inStock': '{count} موجود',
|
||||
'storeItems.card.festival': 'جشنواره',
|
||||
'storeItems.card.used': 'دستدوم',
|
||||
'storeItems.card.variantOne': '۱ تنوع',
|
||||
'storeItems.card.variantMany': '{count} تنوع',
|
||||
'storeItems.card.edit': 'ویرایش محصول فروشگاه',
|
||||
@@ -3522,6 +3527,7 @@ const fa: Record<MessageKey, string> = {
|
||||
'storeItems.create.title': 'افزودن محصول به فروشگاه',
|
||||
'storeItems.create.subtitle': 'تنوعهای قابل فروش را از تنوعهای یک محصول بسازید.',
|
||||
'storeItems.create.productPlaceholder': 'محصول — حداقل ۳ کاراکتر برای جستجو',
|
||||
'storeItems.create.isUsed': 'این محصول دستدوم است',
|
||||
'storeItems.create.loadingVariations': 'در حال بارگذاری تنوعهای محصول...',
|
||||
'storeItems.create.price': 'قیمت (IRT)',
|
||||
'storeItems.create.stock': 'موجودی',
|
||||
|
||||
@@ -76,6 +76,7 @@ function StoreItemsPageContent() {
|
||||
const [draftMinPrice, setDraftMinPrice] = useState('')
|
||||
const [draftMaxPrice, setDraftMaxPrice] = useState('')
|
||||
const [draftOnlyDiscounted, setDraftOnlyDiscounted] = useState(false)
|
||||
const [draftOnlyUsed, setDraftOnlyUsed] = useState(false)
|
||||
const [appliedFilters, setAppliedFilters] = useState<StoreListingFilters>(
|
||||
EMPTY_STORE_LISTING_FILTERS,
|
||||
)
|
||||
@@ -156,6 +157,7 @@ function StoreItemsPageContent() {
|
||||
minPrice: parseIrtInput(draftMinPrice),
|
||||
maxPrice: parseIrtInput(draftMaxPrice),
|
||||
onlyDiscounted: draftOnlyDiscounted,
|
||||
onlyUsed: draftOnlyUsed,
|
||||
})
|
||||
writePage(1)
|
||||
}
|
||||
@@ -165,6 +167,7 @@ function StoreItemsPageContent() {
|
||||
setDraftMinPrice('')
|
||||
setDraftMaxPrice('')
|
||||
setDraftOnlyDiscounted(false)
|
||||
setDraftOnlyUsed(false)
|
||||
setAppliedFilters(EMPTY_STORE_LISTING_FILTERS)
|
||||
writePage(1)
|
||||
}
|
||||
@@ -415,6 +418,22 @@ function StoreItemsPageContent() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${filterStyles.switchField} ${filterStyles.fieldCol3}`}>
|
||||
<div className={filterStyles.switchInline}>
|
||||
<ToggleSwitch
|
||||
checked={draftOnlyUsed}
|
||||
onChange={setDraftOnlyUsed}
|
||||
disabled={isLoading}
|
||||
ariaLabel={t('storeItems.onlyUsed')}
|
||||
/>
|
||||
<span
|
||||
className={filterStyles.switchLabel}
|
||||
onClick={() => !isLoading && setDraftOnlyUsed(!draftOnlyUsed)}
|
||||
>
|
||||
{t('storeItems.onlyUsed')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={filterStyles.filterActions}>
|
||||
<input
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface StoreItem {
|
||||
stockQuantity: number | null
|
||||
rewardPoints: number | null
|
||||
isFestival: boolean
|
||||
isUsed: boolean
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
@@ -48,6 +49,7 @@ export interface CreateStoreItemPayload {
|
||||
|
||||
export interface BatchCreateStoreItemsPayload {
|
||||
productId: string
|
||||
isUsed?: boolean
|
||||
items: CreateStoreItemPayload[]
|
||||
}
|
||||
|
||||
@@ -74,6 +76,7 @@ export interface SyncStoreItemRowPayload {
|
||||
|
||||
export interface SyncProductStoreItemsPayload {
|
||||
productId: string
|
||||
isUsed?: boolean
|
||||
items: SyncStoreItemRowPayload[]
|
||||
removedIds: string[]
|
||||
}
|
||||
@@ -95,7 +98,7 @@ export async function listStoreItems(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
signal?: AbortSignal,
|
||||
filters?: { name?: string },
|
||||
filters?: { name?: string; isUsed?: boolean },
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
@@ -104,6 +107,9 @@ export async function listStoreItems(
|
||||
if (filters?.name?.trim()) {
|
||||
params.set('name', filters.name.trim())
|
||||
}
|
||||
if (filters?.isUsed) {
|
||||
params.set('isUsed', 'true')
|
||||
}
|
||||
|
||||
return apiRequest<StoreItemsListResponse>(
|
||||
`${businessPath()}?${params.toString()}`,
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface StoreSpecialStoreItem {
|
||||
productNameFa: string
|
||||
productImage: string | null
|
||||
isActive: boolean
|
||||
isUsed?: boolean
|
||||
sortOrder: number
|
||||
variants: StoreSpecialVariant[]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface StoreListingFilters {
|
||||
minPrice: number | null
|
||||
maxPrice: number | null
|
||||
onlyDiscounted: boolean
|
||||
onlyUsed: boolean
|
||||
}
|
||||
|
||||
export const EMPTY_STORE_LISTING_FILTERS: StoreListingFilters = {
|
||||
@@ -13,6 +14,7 @@ export const EMPTY_STORE_LISTING_FILTERS: StoreListingFilters = {
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
onlyDiscounted: false,
|
||||
onlyUsed: false,
|
||||
}
|
||||
|
||||
function listingEffectivePrice(listing: StoreProductListing): number | null {
|
||||
@@ -50,6 +52,10 @@ export function filterStoreListings(
|
||||
if (!hasDiscount) return false
|
||||
}
|
||||
|
||||
if (filters.onlyUsed && !listing.isUsed) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface StoreProductListing {
|
||||
displayPrice: number | null
|
||||
displayDiscountedPrice: number | null
|
||||
showFestival: boolean
|
||||
isUsed: boolean
|
||||
}
|
||||
|
||||
function effectivePrice(item: StoreItem): number | null {
|
||||
@@ -103,6 +104,7 @@ export function groupStoreItemsByProduct(
|
||||
showFestival: sorted.some(
|
||||
(variant) => variant.isFestival || (variant.rewardPoints ?? 0) > 0,
|
||||
),
|
||||
isUsed: sorted.some((variant) => variant.isUsed),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ function specialStoreItemToVariants(item: StoreSpecialStoreItem): StoreItem[] {
|
||||
stockQuantity: variant.stockQuantity,
|
||||
rewardPoints: variant.rewardPoints,
|
||||
isFestival: variant.isFestival,
|
||||
isUsed: item.isUsed ?? false,
|
||||
sortOrder: variant.sortOrder,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
@@ -55,6 +56,7 @@ function listingFromStoreItemWithoutVariants(
|
||||
stockQuantity: null,
|
||||
rewardPoints: null,
|
||||
isFestival: false,
|
||||
isUsed: item.isUsed ?? false,
|
||||
sortOrder: item.sortOrder,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
@@ -72,6 +74,7 @@ function listingFromStoreItemWithoutVariants(
|
||||
displayPrice: null,
|
||||
displayDiscountedPrice: null,
|
||||
showFestival: false,
|
||||
isUsed: item.isUsed ?? false,
|
||||
storeItemId: item.id,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user