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:
Alireza Hassani
2026-09-19 12:17:20 +03:30
co-authored by Cursor
parent 32f0dbab63
commit 61d145d92e
12 changed files with 110 additions and 2 deletions
@@ -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 })}
+6
View File
@@ -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()}`,
+1
View File
@@ -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,
}
}