From 61d145d92e59d1cfb5d7e9b6100ee03c2489fe35 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sat, 19 Sep 2026 12:17:20 +0330 Subject: [PATCH] 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 --- .../CreateStoreItemsModal.module.css | 25 +++++++++++++++++++ .../src/components/CreateStoreItemsModal.tsx | 14 +++++++++++ .../src/components/EditStoreItemsModal.tsx | 14 +++++++++++ .../src/components/StoreItemCard.module.css | 11 +++++++- .../business/src/components/StoreItemCard.tsx | 3 +++ apps/business/src/i18n/messages.ts | 6 +++++ apps/business/src/pages/StoreItemsPage.tsx | 19 ++++++++++++++ .../business/src/services/storeItemService.ts | 8 +++++- apps/business/src/types/storeSpecial.ts | 1 + .../business/src/utils/filterStoreListings.ts | 6 +++++ apps/business/src/utils/storeProductGroups.ts | 2 ++ .../src/utils/storeSpecialListings.ts | 3 +++ 12 files changed, 110 insertions(+), 2 deletions(-) diff --git a/apps/business/src/components/CreateStoreItemsModal.module.css b/apps/business/src/components/CreateStoreItemsModal.module.css index c91f6e5..46388dd 100644 --- a/apps/business/src/components/CreateStoreItemsModal.module.css +++ b/apps/business/src/components/CreateStoreItemsModal.module.css @@ -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; diff --git a/apps/business/src/components/CreateStoreItemsModal.tsx b/apps/business/src/components/CreateStoreItemsModal.tsx index 6abf0bc..5223034 100644 --- a/apps/business/src/components/CreateStoreItemsModal.tsx +++ b/apps/business/src/components/CreateStoreItemsModal.tsx @@ -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([]) const [rows, setRows] = useState([]) 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({ /> + + {productId && isLoadingVariations && (

{t('storeItems.create.loadingVariations')}

)} diff --git a/apps/business/src/components/EditStoreItemsModal.tsx b/apps/business/src/components/EditStoreItemsModal.tsx index f03192f..672d2b3 100644 --- a/apps/business/src/components/EditStoreItemsModal.tsx +++ b/apps/business/src/components/EditStoreItemsModal.tsx @@ -72,6 +72,7 @@ export function EditStoreItemsModal({ const [variations, setVariations] = useState([]) const [rows, setRows] = useState([]) const [removedIds, setRemovedIds] = useState([]) + 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({

{t('storeItems.create.loadingVariations')}

) : ( <> + +
{t('storeItems.card.festival')} )} + {listing.isUsed && ( + {t('storeItems.card.used')} + )} {listing.productTotalStock > 0 && ( {t('storeItems.card.inStock', { count: listing.productTotalStock })} diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index 663436b..5b650cd 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -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 = { 'storeItems.minPrice': 'حداقل قیمت (IRT)', 'storeItems.maxPrice': 'حداکثر قیمت (IRT)', 'storeItems.onlyDiscounted': 'فقط تخفیف‌دار', + 'storeItems.onlyUsed': 'فقط کالای دست‌دوم', 'storeItems.search': 'جستجو', 'storeItems.clearFilters': 'پاک کردن فیلترها', 'storeItems.excel.export': 'خروجی اکسل', @@ -3510,6 +3514,7 @@ const fa: Record = { '«{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 = { 'storeItems.create.title': 'افزودن محصول به فروشگاه', 'storeItems.create.subtitle': 'تنوع‌های قابل فروش را از تنوع‌های یک محصول بسازید.', 'storeItems.create.productPlaceholder': 'محصول — حداقل ۳ کاراکتر برای جستجو', + 'storeItems.create.isUsed': 'این محصول دست‌دوم است', 'storeItems.create.loadingVariations': 'در حال بارگذاری تنوع‌های محصول...', 'storeItems.create.price': 'قیمت (IRT)', 'storeItems.create.stock': 'موجودی', diff --git a/apps/business/src/pages/StoreItemsPage.tsx b/apps/business/src/pages/StoreItemsPage.tsx index 0f1367c..6d07d06 100644 --- a/apps/business/src/pages/StoreItemsPage.tsx +++ b/apps/business/src/pages/StoreItemsPage.tsx @@ -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( 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() {
+
+
+ + !isLoading && setDraftOnlyUsed(!draftOnlyUsed)} + > + {t('storeItems.onlyUsed')} + +
+
( `${businessPath()}?${params.toString()}`, diff --git a/apps/business/src/types/storeSpecial.ts b/apps/business/src/types/storeSpecial.ts index 2e3847b..0120bf5 100644 --- a/apps/business/src/types/storeSpecial.ts +++ b/apps/business/src/types/storeSpecial.ts @@ -19,6 +19,7 @@ export interface StoreSpecialStoreItem { productNameFa: string productImage: string | null isActive: boolean + isUsed?: boolean sortOrder: number variants: StoreSpecialVariant[] } diff --git a/apps/business/src/utils/filterStoreListings.ts b/apps/business/src/utils/filterStoreListings.ts index b31b387..2af270d 100644 --- a/apps/business/src/utils/filterStoreListings.ts +++ b/apps/business/src/utils/filterStoreListings.ts @@ -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 }) } diff --git a/apps/business/src/utils/storeProductGroups.ts b/apps/business/src/utils/storeProductGroups.ts index e8f13f8..9a35c00 100644 --- a/apps/business/src/utils/storeProductGroups.ts +++ b/apps/business/src/utils/storeProductGroups.ts @@ -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), } }) diff --git a/apps/business/src/utils/storeSpecialListings.ts b/apps/business/src/utils/storeSpecialListings.ts index 88708a9..4d78059 100644 --- a/apps/business/src/utils/storeSpecialListings.ts +++ b/apps/business/src/utils/storeSpecialListings.ts @@ -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, } }