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