@mohasinac/appkit 2.7.46 → 2.7.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/constants/field-names.d.ts +1 -0
  2. package/dist/constants/field-names.js +1 -0
  3. package/dist/core/unit-of-work.js +3 -4
  4. package/dist/features/auth/actions/profile-actions.js +4 -1
  5. package/dist/features/auth/repository/user.repository.d.ts +1 -0
  6. package/dist/features/auth/repository/user.repository.js +3 -0
  7. package/dist/features/blog/api/route.js +3 -0
  8. package/dist/features/blog/components/BlogIndexListing.js +2 -0
  9. package/dist/features/blog/components/BlogPostView.js +2 -1
  10. package/dist/features/categories/api/route.js +1 -1
  11. package/dist/features/categories/components/BundleDetailView.js +5 -3
  12. package/dist/features/categories/components/CategoryBundlesListing.js +77 -41
  13. package/dist/features/categories/components/CategoryDetailPageView.js +12 -9
  14. package/dist/features/categories/components/CategoryProductsListing.js +3 -5
  15. package/dist/features/classified/components/ClassifiedIndexListing.js +3 -4
  16. package/dist/features/digital-codes/components/DigitalCodesIndexListing.js +3 -4
  17. package/dist/features/events/actions/event-actions.d.ts +2 -3
  18. package/dist/features/events/actions/event-actions.js +3 -5
  19. package/dist/features/events/api/route.js +7 -7
  20. package/dist/features/events/components/AdminEventEditorView.js +80 -6
  21. package/dist/features/events/components/AdminEventEntriesView.js +33 -4
  22. package/dist/features/events/components/EventFilters.d.ts +3 -0
  23. package/dist/features/events/components/EventFilters.js +1 -0
  24. package/dist/features/events/components/EventLeaderboard.js +1 -1
  25. package/dist/features/events/components/EventsIndexListing.js +10 -7
  26. package/dist/features/events/repository/event-entry.repository.d.ts +2 -2
  27. package/dist/features/events/repository/event-entry.repository.js +32 -7
  28. package/dist/features/events/types/index.d.ts +3 -1
  29. package/dist/features/homepage/components/BrandsSection.js +2 -2
  30. package/dist/features/homepage/components/CustomCardsSection.js +1 -1
  31. package/dist/features/homepage/components/CustomerReviewsSection.js +1 -1
  32. package/dist/features/homepage/components/FeaturedResultsSection.js +1 -1
  33. package/dist/features/homepage/components/SectionCarousel.js +1 -1
  34. package/dist/features/homepage/components/ShopByCategorySection.js +1 -1
  35. package/dist/features/live/components/LiveItemsIndexListing.js +3 -4
  36. package/dist/features/pre-orders/components/PreOrdersIndexListing.js +3 -4
  37. package/dist/features/products/components/AuctionsIndexListing.js +3 -4
  38. package/dist/features/products/components/PrizeDrawsIndexListing.js +3 -8
  39. package/dist/features/products/components/ProductDetailPageView.js +17 -2
  40. package/dist/features/products/components/ProductsIndexListing.js +10 -6
  41. package/dist/features/products/schemas/firestore.d.ts +2 -0
  42. package/dist/features/reviews/actions/review-actions.js +2 -0
  43. package/dist/features/reviews/components/ReviewDetailShell.js +3 -2
  44. package/dist/features/reviews/components/ReviewFilters.js +1 -1
  45. package/dist/features/reviews/components/ReviewsIndexListing.js +9 -2
  46. package/dist/features/reviews/hooks/useReviews.js +2 -0
  47. package/dist/features/reviews/schemas/firestore.d.ts +4 -0
  48. package/dist/features/reviews/types/index.d.ts +3 -0
  49. package/dist/features/scams/components/ScamRegistryView.js +41 -8
  50. package/dist/features/stores/components/InteractiveStoreCard.js +1 -1
  51. package/dist/features/stores/components/StoreAuctionsListing.js +3 -4
  52. package/dist/features/stores/components/StoreDetailLayoutView.js +13 -5
  53. package/dist/features/stores/components/StorePreOrdersListing.js +3 -4
  54. package/dist/features/stores/components/StoreProductsListing.js +3 -4
  55. package/dist/index.d.ts +1 -0
  56. package/dist/providers/db-firebase/helpers.d.ts +4 -6
  57. package/dist/providers/db-firebase/helpers.js +18 -5
  58. package/dist/providers/db-firebase/sieve.js +26 -1
  59. package/dist/react/hooks/usePendingFilters.d.ts +2 -0
  60. package/dist/react/hooks/usePendingFilters.js +9 -0
  61. package/dist/react/hooks/usePendingTable.d.ts +5 -0
  62. package/dist/react/hooks/usePendingTable.js +1 -0
  63. package/dist/seed/homepage-sections-seed-data.js +2 -2
  64. package/dist/seed/products-standard-seed-data.js +1 -0
  65. package/dist/tailwind-utilities.css +1 -1
  66. package/dist/ui/components/Drawer.style.css +13 -0
  67. package/dist/ui/components/FilterDrawer.d.ts +4 -2
  68. package/dist/ui/components/FilterDrawer.js +2 -2
  69. package/package.json +1 -1
@@ -123,6 +123,13 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
123
123
  const reviewCount = typeof p.reviewCount === "number" ? p.reviewCount : null;
124
124
  const category = typeof p.category === "string" ? p.category : null;
125
125
  const categoryName = typeof p.categoryName === "string" ? p.categoryName : null;
126
+ // categorySlugs/categoryNames: multi-category support (S-uni-formshell-prep)
127
+ const categorySlugs = Array.isArray(p.categorySlugs)
128
+ ? p.categorySlugs
129
+ : category ? [category] : [];
130
+ const categoryNames = Array.isArray(p.categoryNames)
131
+ ? p.categoryNames
132
+ : categoryName ? [categoryName] : [];
126
133
  const subcategory = typeof p.subcategory === "string" ? p.subcategory : null;
127
134
  const brand = typeof p.brand === "string" ? p.brand : null;
128
135
  const brandSlug = typeof p.brandSlug === "string" ? p.brandSlug : null;
@@ -152,6 +159,14 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
152
159
  const specs = Array.isArray(p.specifications)
153
160
  ? p.specifications
154
161
  : [];
162
+ const customFields = Array.isArray(p.customFields)
163
+ ? p.customFields.map((f) => ({
164
+ name: f.key,
165
+ value: String(f.value),
166
+ unit: f.unit,
167
+ }))
168
+ : [];
169
+ const allSpecs = [...specs, ...customFields];
155
170
  const customSections = Array.isArray(p.customSections)
156
171
  ? p.customSections
157
172
  : [];
@@ -234,7 +249,7 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
234
249
  ? "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
235
250
  : "bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400"}`, children: [inStock ? "✓ In Stock" : "✗ Out of Stock", inStock && effectiveStock !== null && effectiveStock <= 10
236
251
  ? ` — only ${effectiveStock} left`
237
- : ""] })), (categoryName || category || subcategory || brand) && (_jsxs(Row, { gap: "sm", wrap: true, children: [category && (_jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: "inline-flex items-center rounded-full border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 px-2.5 py-1 text-xs font-medium text-zinc-600 dark:text-zinc-300 transition-colors hover:border-primary-300 hover:bg-primary-50 hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:bg-primary-900/20 dark:hover:text-primary-400", children: categoryName || category })), subcategory && (_jsx(Span, { className: "inline-flex items-center rounded-full border border-zinc-100 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-800/50 px-2.5 py-1 text-xs font-medium text-zinc-500 dark:text-zinc-400 capitalize", children: subcategory })), brand && brandSlug && (_jsx(Link, { href: String(ROUTES.PUBLIC.BRAND_DETAIL(brandSlug)), className: "inline-flex items-center rounded-full border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 px-2.5 py-1 text-xs font-medium text-zinc-600 dark:text-zinc-300 transition-colors hover:border-primary-300 hover:bg-primary-50 hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:bg-primary-900/20 dark:hover:text-primary-400", children: brand })), brand && !brandSlug && (_jsx(Span, { className: "inline-flex items-center rounded-full border border-zinc-100 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-800/50 px-2.5 py-1 text-xs font-medium text-zinc-500 dark:text-zinc-400", children: brand }))] })), bundleMemberships.length > 0 && (_jsx(Row, { gap: "sm", wrap: true, children: bundleMemberships.map((b) => (_jsxs(Link, { href: String(ROUTES.PUBLIC.BUNDLE_DETAIL(b.id)), className: "inline-flex items-center gap-1 rounded-full border border-teal-300 bg-teal-50 px-2.5 py-1 text-xs font-semibold text-teal-700 transition-colors hover:border-teal-500 hover:bg-teal-100 dark:border-teal-800/60 dark:bg-teal-900/30 dark:text-teal-300 dark:hover:border-teal-600 dark:hover:bg-teal-900/50", children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDCE6" }), "In bundle: ", b.title] }, b.id))) })), _jsx(ProductFeatureBadges, { featured: featured, freeShipping: freeShipping, condition: condition ?? undefined, returnable: returnPolicy != null && returnPolicy.length > 0, labels: {
252
+ : ""] })), (categorySlugs.length > 0 || subcategory || brand) && (_jsxs(Row, { gap: "sm", wrap: true, children: [categorySlugs.map((slug, i) => (_jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(slug)), className: "inline-flex items-center rounded-full border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 px-2.5 py-1 text-xs font-medium text-zinc-600 dark:text-zinc-300 transition-colors hover:border-primary-300 hover:bg-primary-50 hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:bg-primary-900/20 dark:hover:text-primary-400", children: categoryNames[i] ?? slug }, slug))), subcategory && (_jsx(Span, { className: "inline-flex items-center rounded-full border border-zinc-100 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-800/50 px-2.5 py-1 text-xs font-medium text-zinc-500 dark:text-zinc-400 capitalize", children: subcategory })), brand && brandSlug && (_jsx(Link, { href: String(ROUTES.PUBLIC.BRAND_DETAIL(brandSlug)), className: "inline-flex items-center rounded-full border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 px-2.5 py-1 text-xs font-medium text-zinc-600 dark:text-zinc-300 transition-colors hover:border-primary-300 hover:bg-primary-50 hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:bg-primary-900/20 dark:hover:text-primary-400", children: brand })), brand && !brandSlug && (_jsx(Span, { className: "inline-flex items-center rounded-full border border-zinc-100 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-800/50 px-2.5 py-1 text-xs font-medium text-zinc-500 dark:text-zinc-400", children: brand }))] })), bundleMemberships.length > 0 && (_jsx(Row, { gap: "sm", wrap: true, children: bundleMemberships.map((b) => (_jsxs(Link, { href: String(ROUTES.PUBLIC.BUNDLE_DETAIL(b.id)), className: "inline-flex items-center gap-1 rounded-full border border-teal-300 bg-teal-50 px-2.5 py-1 text-xs font-semibold text-teal-700 transition-colors hover:border-teal-500 hover:bg-teal-100 dark:border-teal-800/60 dark:bg-teal-900/30 dark:text-teal-300 dark:hover:border-teal-600 dark:hover:bg-teal-900/50", children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDCE6" }), "In bundle: ", b.title] }, b.id))) })), _jsx(ProductFeatureBadges, { featured: featured, freeShipping: freeShipping, condition: condition ?? undefined, returnable: returnPolicy != null && returnPolicy.length > 0, labels: {
238
253
  featured: "Featured",
239
254
  fasterDelivery: "Faster Delivery",
240
255
  ratedSeller: "Rated Seller",
@@ -270,7 +285,7 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
270
285
  ? () => (_jsx(SublistingCarouselSection, { sublistingCategoryId: sublistingCategoryId, currentListingId: String(product.id) }))
271
286
  : undefined, renderGroupSection: groupId
272
287
  ? () => (_jsx(ShowGroupSection, { groupId: groupId, currentSlug: String(p.slug ?? product.id), isParent: isGroupParent, groupTitle: groupTitle }))
273
- : undefined, renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-zinc-700 dark:text-zinc-300" })) : undefined, specsContent: specs.length > 0 ? (_jsx("dl", { className: "divide-y divide-zinc-100 dark:divide-zinc-800 rounded-xl border border-zinc-100 dark:border-zinc-800 overflow-hidden text-sm", children: specs.map((s, i) => (_jsxs("div", { className: "flex gap-4 px-4 py-3 bg-white dark:bg-zinc-900 even:bg-zinc-50 dark:even:bg-zinc-800/50", children: [_jsx("dt", { className: "w-36 flex-shrink-0 font-medium text-zinc-700 dark:text-zinc-300", children: s.name }), _jsxs("dd", { className: "flex-1 text-zinc-600 dark:text-zinc-400", children: [s.value, s.unit ? ` ${s.unit}` : ""] })] }, i))) })) : undefined, ingredientsContent: ingredients.length > 0 ? (_jsx("ul", { className: "space-y-2", children: ingredients.map((item, i) => (_jsxs("li", { className: "flex items-start gap-2 text-sm text-zinc-700 dark:text-zinc-300", children: [_jsx(Span, { className: "mt-1 flex-shrink-0 h-1.5 w-1.5 rounded-full bg-primary-400" }), item] }, i))) })) : undefined, howToUseContent: howToUse.length > 0 ? (_jsx("ol", { className: "space-y-3", children: howToUse.map((step, i) => (_jsxs("li", { className: "flex items-start gap-3 text-sm text-zinc-700 dark:text-zinc-300", children: [_jsx(Span, { className: "flex-shrink-0 flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30 text-xs font-bold text-primary-700 dark:text-primary-300", children: i + 1 }), step] }, i))) })) : undefined, reviewsContent: _jsx(ReviewsList, { reviews: reviews, context: "listing", emptyLabel: "No reviews yet \u2014 be the first to review this product." }), customTabs: customSections.map((s) => ({
288
+ : undefined, renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-zinc-700 dark:text-zinc-300" })) : undefined, specsContent: allSpecs.length > 0 ? (_jsx("dl", { className: "divide-y divide-zinc-100 dark:divide-zinc-800 rounded-xl border border-zinc-100 dark:border-zinc-800 overflow-hidden text-sm", children: allSpecs.map((s, i) => (_jsxs("div", { className: "flex gap-4 px-4 py-3 bg-white dark:bg-zinc-900 even:bg-zinc-50 dark:even:bg-zinc-800/50", children: [_jsx("dt", { className: "w-36 flex-shrink-0 font-medium text-zinc-700 dark:text-zinc-300", children: s.name }), _jsxs("dd", { className: "flex-1 text-zinc-600 dark:text-zinc-400", children: [s.value, s.unit ? ` ${s.unit}` : ""] })] }, i))) })) : undefined, ingredientsContent: ingredients.length > 0 ? (_jsx("ul", { className: "space-y-2", children: ingredients.map((item, i) => (_jsxs("li", { className: "flex items-start gap-2 text-sm text-zinc-700 dark:text-zinc-300", children: [_jsx(Span, { className: "mt-1 flex-shrink-0 h-1.5 w-1.5 rounded-full bg-primary-400" }), item] }, i))) })) : undefined, howToUseContent: howToUse.length > 0 ? (_jsx("ol", { className: "space-y-3", children: howToUse.map((step, i) => (_jsxs("li", { className: "flex items-start gap-3 text-sm text-zinc-700 dark:text-zinc-300", children: [_jsx(Span, { className: "flex-shrink-0 flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30 text-xs font-bold text-primary-700 dark:text-primary-300", children: i + 1 }), step] }, i))) })) : undefined, reviewsContent: _jsx(ReviewsList, { reviews: reviews, context: "listing", emptyLabel: "No reviews yet \u2014 be the first to review this product." }), customTabs: customSections.map((s) => ({
274
289
  id: s.id,
275
290
  label: s.title,
276
291
  content: _jsx(CustomSectionTabContent, { section: s }),
@@ -17,11 +17,13 @@ import { useGuestWishlist } from "../../wishlist/hooks/useGuestWishlist";
17
17
  import { pushCartOp, pushWishlistOp } from "../../cart/utils/pending-ops";
18
18
  import { useBulkSelection } from "../../../react/hooks/useBulkSelection";
19
19
  import { useCategoryTree, categoriesToFacetOptions } from "../../categories/hooks/useCategoryTree";
20
+ import { useBrands } from "../hooks/useBrands";
21
+ import { useProductFeatures } from "./ProductFeaturesContext";
20
22
  import { TABLE_KEYS, VIEW_MODE } from "../../../constants/table-keys";
21
23
  import { sortBy } from "../../../constants/sort";
22
24
  import { PRODUCT_FIELDS } from "../../../constants/field-names";
23
25
  const DEFAULT_SORT = sortBy(PRODUCT_FIELDS.CREATED_AT);
24
- const FILTER_KEYS = [TABLE_KEYS.CATEGORY, TABLE_KEYS.CONDITION, TABLE_KEYS.MIN_PRICE, TABLE_KEYS.MAX_PRICE, TABLE_KEYS.BRAND, TABLE_KEYS.STORE_ID, TABLE_KEYS.FREE_SHIPPING, TABLE_KEYS.TAGS];
26
+ const FILTER_KEYS = [TABLE_KEYS.CATEGORY, TABLE_KEYS.CONDITION, TABLE_KEYS.MIN_PRICE, TABLE_KEYS.MAX_PRICE, TABLE_KEYS.BRAND, TABLE_KEYS.STORE_ID, TABLE_KEYS.FREE_SHIPPING, TABLE_KEYS.TAGS, TABLE_KEYS.FEATURES, TABLE_KEYS.IS_PART_OF_BUNDLE];
25
27
  export function ProductsIndexListing({ initialData }) {
26
28
  const router = useRouter();
27
29
  const table = useUrlTable({ defaults: { pageSize: "24", sort: DEFAULT_SORT } });
@@ -32,7 +34,7 @@ export function ProductsIndexListing({ initialData }) {
32
34
  const [compareIds, setCompareIds] = useState([]);
33
35
  const showSold = table.get(TABLE_KEYS.SHOW_SOLD) === "true";
34
36
  const [view, setView] = useState(table.get(TABLE_KEYS.VIEW) || VIEW_MODE.GRID);
35
- const { pendingTable, filterActiveCount, onFilterApply, onFilterClear, onFilterReset } = usePendingTable(table, FILTER_KEYS);
37
+ const { pendingTable, filterActiveCount, onFilterApply, onFilterClear, onResetAll, onFilterReset } = usePendingTable(table, FILTER_KEYS);
36
38
  const openFilters = useCallback(() => {
37
39
  onFilterReset();
38
40
  setFilterOpen(true);
@@ -42,10 +44,9 @@ export function ProductsIndexListing({ initialData }) {
42
44
  setFilterOpen(false);
43
45
  }, [onFilterApply]);
44
46
  const resetAll = useCallback(() => {
45
- table.setMany({ [TABLE_KEYS.QUERY]: "", [TABLE_KEYS.SORT]: "", [TABLE_KEYS.SHOW_SOLD]: "" });
46
- onFilterClear();
47
+ onResetAll({ [TABLE_KEYS.QUERY]: "", [TABLE_KEYS.SORT]: "", [TABLE_KEYS.SHOW_SOLD]: "" });
47
48
  setSearchInput("");
48
- }, [table, onFilterClear]);
49
+ }, [onResetAll]);
49
50
  const hasActiveState = !!table.get(TABLE_KEYS.QUERY) ||
50
51
  table.get(TABLE_KEYS.SHOW_SOLD) === "true" ||
51
52
  table.get(TABLE_KEYS.SORT) !== DEFAULT_SORT ||
@@ -55,6 +56,9 @@ export function ProductsIndexListing({ initialData }) {
55
56
  const wishlistedIds = new Set(localWishlist.items.filter((i) => i.type === "product").map((i) => i.itemId));
56
57
  const { categories } = useCategoryTree();
57
58
  const categoryOptions = categoriesToFacetOptions(categories);
59
+ const { brandOptions } = useBrands();
60
+ const features = useProductFeatures() ?? [];
61
+ const featureOptions = features.map((f) => ({ value: f.id, label: f.label }));
58
62
  const params = {
59
63
  q: table.get(TABLE_KEYS.QUERY) || undefined,
60
64
  category: table.get(TABLE_KEYS.CATEGORY) || undefined,
@@ -169,5 +173,5 @@ export function ProductsIndexListing({ initialData }) {
169
173
  ] }), totalPages > 1 && (_jsx("div", { className: "sticky top-[calc(var(--header-height,0px)+44px)] z-10 flex justify-center bg-white/95 dark:bg-slate-900/95 backdrop-blur-sm border-b border-zinc-200 dark:border-slate-700 px-3 py-1.5", children: _jsx(Pagination, { currentPage: page, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) })), _jsx("div", { className: "py-6", children: isLoading ? (_jsx("div", { className: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4", children: Array.from({ length: 10 }).map((_, i) => (_jsxs("div", { className: "rounded-xl border border-zinc-100 dark:border-slate-700 overflow-hidden animate-pulse", children: [_jsx("div", { className: "aspect-square bg-zinc-200 dark:bg-slate-700" }), _jsxs("div", { className: "p-3 space-y-2", children: [_jsx("div", { className: "h-3 bg-zinc-200 dark:bg-slate-700 rounded w-3/4" }), _jsx("div", { className: "h-3 bg-zinc-200 dark:bg-slate-700 rounded w-1/2" }), _jsx("div", { className: "h-4 bg-zinc-200 dark:bg-slate-700 rounded w-1/3" })] })] }, i))) })) : (_jsx(ProductGrid, { products: products, getProductHref: (p) => String(ROUTES.PUBLIC.PRODUCT_DETAIL(p.slug || p.id)), view: view === "grid" ? "card" : "list", onWishlistToggle: handleWishlistToggle, wishlistedIds: wishlistedIds, onAddToCart: handleAddToCart, onBuyNow: handleBuyNow, selectionMode: selection.isSelecting, selectedIds: selection.selectedIdSet, onToggleSelect: selection.toggle })) }), _jsx(CompareOverlay, { isOpen: compareIds.length > 0, productIds: compareIds, productType: "product", onClose: () => {
170
174
  setCompareIds([]);
171
175
  selection.clearSelection();
172
- }, onRemove: (id) => setCompareIds((ids) => ids.filter((i) => i !== id)) }), _jsx(FilterDrawer, { open: filterOpen, onOpen: openFilters, onClose: () => setFilterOpen(false), onApply: applyFilters, onReset: onFilterClear, activeCount: filterActiveCount, hideTrigger: true, children: _jsx(ProductFilters, { table: pendingTable, currencyPrefix: "\u20B9", categoryOptions: categoryOptions }) }), _jsx(LoginRequiredModal, { isOpen: modalOpen, onClose: closeModal, message: modalMessage })] }));
176
+ }, onRemove: (id) => setCompareIds((ids) => ids.filter((i) => i !== id)) }), _jsx(FilterDrawer, { open: filterOpen, onOpen: openFilters, onClose: () => setFilterOpen(false), onApply: applyFilters, onReset: onFilterClear, activeCount: filterActiveCount, hideTrigger: true, children: _jsx(ProductFilters, { table: pendingTable, currencyPrefix: "\u20B9", categoryOptions: categoryOptions, brandOptions: brandOptions, featureOptions: featureOptions }) }), _jsx(LoginRequiredModal, { isOpen: modalOpen, onClose: closeModal, message: modalMessage })] }));
173
177
  }
@@ -206,6 +206,8 @@ export interface ProductDocument {
206
206
  /** Reverse pointers — bundle ids that include this product. */
207
207
  partOfBundleIds?: string[];
208
208
  partOfBundleTitles?: string[];
209
+ /** Denormalised flag — true when partOfBundleIds has at least one entry. Enables Firestore isPartOfBundle==true query. */
210
+ isPartOfBundle?: boolean;
209
211
  prizeDrawItems?: PrizeDrawItem[];
210
212
  pricePerEntry?: number;
211
213
  prizeMaxEntries?: number;
@@ -33,12 +33,14 @@ export async function createReview(userId, input) {
33
33
  storeId: product.storeId,
34
34
  storeName: product.storeName,
35
35
  userId,
36
+ userSlug: profile?.id,
36
37
  userName: profile?.displayName ?? "Anonymous",
37
38
  userAvatar: profile?.photoURL ?? "",
38
39
  rating: input.rating,
39
40
  title: input.title,
40
41
  comment: input.comment,
41
42
  images,
43
+ hasImages: images.length > 0,
42
44
  video: finalVideoUrl
43
45
  ? {
44
46
  url: finalVideoUrl,
@@ -72,8 +72,9 @@ export function ReviewDetailShell({ review, storeHref }) {
72
72
  ? String(ROUTES.PUBLIC.PRODUCT_DETAIL(review.productId))
73
73
  : null;
74
74
  const sellerHref = storeHref ?? null;
75
- const reviewerHref = !review.isAnonymous && review.userId
76
- ? String(ROUTES.PUBLIC.PROFILE(review.userId))
75
+ const reviewerProfileId = review.userSlug ?? review.userId;
76
+ const reviewerHref = !review.isAnonymous && reviewerProfileId
77
+ ? String(ROUTES.PUBLIC.PROFILE(reviewerProfileId))
77
78
  : null;
78
79
  const currentImage = lightboxIdx !== null ? images[lightboxIdx] : null;
79
80
  return (_jsxs(_Fragment, { children: [_jsx("div", { className: "border-b border-neutral-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 pb-8 pt-10", children: _jsxs("div", { className: "mx-auto max-w-3xl px-4", children: [_jsxs("div", { className: "mb-4 flex items-center gap-3", children: [_jsx(StarRating, { value: review.rating, size: "lg", readOnly: true }), _jsxs("span", { className: "text-2xl font-bold text-neutral-900 dark:text-white", children: [review.rating, ".0"] }), review.verified && (_jsx("span", { className: "inline-flex items-center gap-1 rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400", children: "\u2713 Verified Purchase" })), review.featured && (_jsx("span", { className: "inline-flex items-center gap-1 rounded-full bg-yellow-100 px-3 py-1 text-xs font-semibold text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400", children: "\u2605 Featured" }))] }), review.title && (_jsx(Heading, { level: 1, className: "text-2xl font-bold text-neutral-900 dark:text-white mb-4 leading-snug", children: review.title })), _jsxs("div", { className: "flex items-center gap-3", children: [review.userAvatar ? (_jsx("div", { role: "img", "aria-label": displayName, className: "h-11 w-11 flex-shrink-0 rounded-full bg-center bg-cover ring-2 ring-white dark:ring-zinc-800", style: { backgroundImage: `url(${review.userAvatar})` } })) : (_jsx("div", { className: "flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full bg-primary/10 text-base font-bold text-primary ring-2 ring-white dark:ring-zinc-800", children: initials })), _jsxs("div", { className: "min-w-0", children: [reviewerHref ? (_jsx(Link, { href: reviewerHref, className: "text-sm font-semibold text-neutral-900 dark:text-white hover:text-primary transition-colors", children: displayName })) : (_jsx("span", { className: "text-sm font-semibold text-neutral-900 dark:text-white", children: review.isAnonymous ? "Anonymous" : displayName })), date && (_jsx(Text, { className: "text-xs text-neutral-400 dark:text-zinc-500 mt-0.5", children: date }))] })] })] }) }), _jsxs("div", { className: "mx-auto max-w-3xl px-4 py-8 space-y-8", children: [review.comment && (_jsx(Section, { children: _jsx(RichText, { html: normalizeRichTextHtml(review.comment), proseClass: "prose prose-neutral dark:prose-invert max-w-none prose-p:leading-relaxed prose-headings:font-semibold prose-img:rounded-lg prose-a:text-primary", className: "text-neutral-700 dark:text-zinc-300" }) })), images.length > 0 && (_jsxs(Section, { children: [_jsxs(Heading, { level: 2, className: "text-sm font-semibold uppercase tracking-wide text-neutral-400 dark:text-zinc-500 mb-3", children: ["Photos (", images.length, ")"] }), _jsx("div", { className: "grid grid-cols-3 sm:grid-cols-4 gap-2", children: images.map((img, i) => (_jsxs("button", { type: "button", onClick: () => setLightboxIdx(i), "aria-label": `View photo ${i + 1}`, className: "group relative aspect-square overflow-hidden rounded-xl border border-neutral-200 dark:border-zinc-700 bg-neutral-100 dark:bg-zinc-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary", children: [_jsx("div", { className: "h-full w-full bg-center bg-cover transition-transform duration-300 group-hover:scale-105", style: { backgroundImage: `url(${img.thumbnailUrl ?? img.url})` } }), _jsx("div", { className: "absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/30", children: _jsx("span", { className: "text-white text-xl", children: "\uD83D\uDD0D" }) })] }, i))) })] })), review.video && (_jsxs(Section, { children: [_jsx(Heading, { level: 2, className: "text-sm font-semibold uppercase tracking-wide text-neutral-400 dark:text-zinc-500 mb-3", children: "Video" }), _jsx("div", { className: "overflow-hidden rounded-xl border border-neutral-200 dark:border-zinc-700 bg-black aspect-video", children: _jsx("video", { src: review.video.url, poster: review.video.thumbnailUrl, controls: true, className: "h-full w-full", preload: "metadata" }) })] })), _jsxs(Section, { className: "flex items-center gap-4 py-4 border-t border-neutral-100 dark:border-zinc-800", children: [_jsx("div", { className: "text-sm text-neutral-500 dark:text-zinc-400", children: helpfulCount > 0 && (_jsxs("span", { children: [_jsx("strong", { className: "text-neutral-900 dark:text-white", children: helpfulCount }), " ", helpfulCount === 1 ? "person" : "people", " found this helpful"] })) }), _jsxs("button", { type: "button", onClick: handleVote, disabled: voted || voting, className: `ml-auto flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors ${voted
@@ -60,5 +60,5 @@ export function ReviewFilters({ table, variant = "admin", brandOptions, }) {
60
60
  : [];
61
61
  const isAdmin = variant === "admin";
62
62
  const showStatus = variant !== "public";
63
- return (_jsxs(Div, { children: [showStatus && (_jsx(FilterFacetSection, { title: t("status"), options: statusOptions, selected: selectedStatus, onChange: (vals) => table.set("status", vals.join("|")), searchable: false, defaultCollapsed: false })), _jsx(FilterFacetSection, { title: t("rating"), options: ratingOptions, selected: selectedRating, onChange: (vals) => table.set("rating", vals.join("|")), searchable: false, defaultCollapsed: variant === "admin" }), brandOptions && brandOptions.length > 0 && (_jsx(FilterFacetSection, { title: t("brand"), options: brandOptions, selected: table.get("brand") ? [table.get("brand")] : [], onChange: (vals) => table.set("brand", vals[0] ?? ""), searchable: brandOptions.length > 6, selectionMode: "single", defaultCollapsed: true })), _jsx(RangeFilter, { title: t("dateRange"), type: "date", minValue: table.get("dateFrom"), maxValue: table.get("dateTo"), onMinChange: (v) => table.set("dateFrom", v), onMaxChange: (v) => table.set("dateTo", v), minPlaceholder: t("minDate"), maxPlaceholder: t("maxDate"), defaultCollapsed: true }), isAdmin && (_jsxs(_Fragment, { children: [_jsx(SwitchFilter, { title: t("verified"), label: t("showVerifiedOnly"), checked: table.get("verified") === "true", onChange: (v) => table.set("verified", v ? "true" : "") }), _jsx(SwitchFilter, { title: t("featured"), label: t("showFeaturedOnly"), checked: table.get("featured") === "true", onChange: (v) => table.set("featured", v ? "true" : "") })] })), _jsx(RangeFilter, { title: t("votesRange"), minValue: table.get("minVotes"), maxValue: table.get("maxVotes"), onMinChange: (v) => table.set("minVotes", v), onMaxChange: (v) => table.set("maxVotes", v), minBound: 0, maxBound: 10000, step: 1, minPlaceholder: t("minVotes"), maxPlaceholder: t("maxVotes"), defaultCollapsed: true })] }));
63
+ return (_jsxs(Div, { children: [showStatus && (_jsx(FilterFacetSection, { title: t("status"), options: statusOptions, selected: selectedStatus, onChange: (vals) => table.set("status", vals.join("|")), searchable: false, defaultCollapsed: false })), _jsx(FilterFacetSection, { title: t("rating"), options: ratingOptions, selected: selectedRating, onChange: (vals) => table.set("rating", vals.join("|")), searchable: false, defaultCollapsed: variant === "admin" }), brandOptions && brandOptions.length > 0 && (_jsx(FilterFacetSection, { title: t("brand"), options: brandOptions, selected: table.get("brand") ? [table.get("brand")] : [], onChange: (vals) => table.set("brand", vals[0] ?? ""), searchable: brandOptions.length > 6, selectionMode: "single", defaultCollapsed: true })), _jsx(RangeFilter, { title: t("dateRange"), type: "date", minValue: table.get("dateFrom"), maxValue: table.get("dateTo"), onMinChange: (v) => table.set("dateFrom", v), onMaxChange: (v) => table.set("dateTo", v), minPlaceholder: t("minDate"), maxPlaceholder: t("maxDate"), defaultCollapsed: true }), isAdmin && (_jsxs(_Fragment, { children: [_jsx(SwitchFilter, { title: t("verified"), label: t("showVerifiedOnly"), checked: table.get("verified") === "true", onChange: (v) => table.set("verified", v ? "true" : "") }), _jsx(SwitchFilter, { title: t("featured"), label: t("showFeaturedOnly"), checked: table.get("featured") === "true", onChange: (v) => table.set("featured", v ? "true" : "") })] })), _jsx(RangeFilter, { title: t("votesRange"), minValue: table.get("minVotes"), maxValue: table.get("maxVotes"), onMinChange: (v) => table.set("minVotes", v), onMaxChange: (v) => table.set("maxVotes", v), minBound: 0, maxBound: 10000, step: 1, minPlaceholder: t("minVotes"), maxPlaceholder: t("maxVotes"), defaultCollapsed: true }), _jsx(SwitchFilter, { title: "Media", label: "Show reviews with photos only", checked: table.get("hasImages") === "true", onChange: (v) => table.set("hasImages", v ? "true" : "") })] }));
64
64
  }
@@ -12,7 +12,13 @@ import { sortBy } from "../../../constants/sort";
12
12
  import { REVIEW_FIELDS } from "../../../constants/field-names";
13
13
  const PAGE_SIZE = 12;
14
14
  const DEFAULT_SORT = sortBy(REVIEW_FIELDS.CREATED_AT);
15
- const FILTER_KEYS = ["rating", TABLE_KEYS.DATE_FROM, TABLE_KEYS.DATE_TO, "minVotes", "maxVotes"];
15
+ const FILTER_KEYS = ["rating", TABLE_KEYS.DATE_FROM, TABLE_KEYS.DATE_TO, "minVotes", "maxVotes", "hasImages"];
16
+ const SORT_OPTION_LABELS = {
17
+ sortNewest: "Newest First",
18
+ sortOldest: "Oldest First",
19
+ sortHighestRated: "Highest Rated",
20
+ sortLowestRated: "Lowest Rated",
21
+ };
16
22
  export function ReviewsIndexListing({ initialData, variant = "public", }) {
17
23
  const table = useUrlTable({ defaults: { pageSize: String(PAGE_SIZE), sort: DEFAULT_SORT } });
18
24
  const [searchInput, setSearchInput] = useState(table.get(TABLE_KEYS.QUERY) || "");
@@ -88,13 +94,14 @@ export function ReviewsIndexListing({ initialData, variant = "public", }) {
88
94
  dateTo: table.get(TABLE_KEYS.DATE_TO) || undefined,
89
95
  minVotes: table.get("minVotes") ? Number(table.get("minVotes")) : undefined,
90
96
  maxVotes: table.get("maxVotes") ? Number(table.get("maxVotes")) : undefined,
97
+ hasImages: table.get("hasImages") === "true" ? true : undefined,
91
98
  sort,
92
99
  page: currentPage,
93
100
  perPage: PAGE_SIZE,
94
101
  }, { initialData });
95
102
  const sortOptions = REVIEW_PUBLIC_SORT_OPTIONS.map((opt) => ({
96
103
  value: opt.value,
97
- label: opt.key,
104
+ label: SORT_OPTION_LABELS[opt.key] ?? opt.key,
98
105
  }));
99
106
  return (_jsxs("div", { className: "min-h-screen", children: [_jsx(ListingToolbar, { filterCount: activeFilterCount, onFiltersClick: openFilters, searchValue: searchInput, searchPlaceholder: "Search reviews by product name...", onSearchChange: setSearchInput, onSearchCommit: commitSearch, sortValue: sort, sortOptions: sortOptions, onSortChange: (v) => { table.set(TABLE_KEYS.SORT, v); }, view: view, onViewChange: handleViewToggle, onResetAll: resetAll, hasActiveState: hasActiveState }), totalPages > 1 && (_jsx("div", { className: "sticky top-[calc(var(--header-height,0px)+44px)] z-10 flex justify-center bg-white/95 dark:bg-slate-900/95 backdrop-blur-sm border-b border-zinc-200 dark:border-slate-700 px-3 py-1.5", children: _jsx(Pagination, { currentPage: currentPage, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) })), _jsx("div", { className: "py-6", children: isLoading ? (_jsx("div", { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6", children: Array.from({ length: 6 }).map((_, i) => (_jsx("div", { className: "rounded-xl border border-zinc-100 dark:border-slate-700 overflow-hidden animate-pulse", children: _jsxs("div", { className: "p-4 space-y-3", children: [_jsx("div", { className: "h-4 bg-zinc-200 dark:bg-slate-700 rounded w-3/4" }), _jsx("div", { className: "h-3 bg-zinc-200 dark:bg-slate-700 rounded w-full" }), _jsx("div", { className: "h-3 bg-zinc-200 dark:bg-slate-700 rounded w-2/3" })] }) }, i))) })) : reviews.length === 0 ? (_jsx(Text, { className: "py-12 text-center text-sm text-zinc-500 dark:text-zinc-400", children: "No reviews found." })) : view === "list" ? (_jsx("div", { className: "flex flex-col divide-y divide-zinc-100 dark:divide-zinc-800 rounded-xl border border-zinc-100 dark:border-zinc-800", children: reviews.map((review) => (_jsx(ReviewCard, { review: review }, review.id))) })) : (_jsx("div", { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6", children: reviews.map((review) => (_jsx(ReviewCard, { review: review }, review.id))) })) }), filterOpen && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-40 bg-black/40", "aria-hidden": "true", onClick: () => setFilterOpen(false) }), _jsxs("div", { className: "fixed inset-y-0 left-0 z-50 flex w-80 flex-col bg-white dark:bg-slate-900 shadow-2xl", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-zinc-200 dark:border-slate-700 px-4 py-3.5", children: [_jsx("span", { className: "flex items-center gap-2 text-base font-semibold text-zinc-900 dark:text-zinc-100", children: "Filters" }), _jsxs("div", { className: "flex items-center gap-2", children: [activeFilterCount > 0 && (_jsx("button", { type: "button", onClick: clearFilters, className: "text-xs text-zinc-500 hover:text-rose-500 dark:text-zinc-400 transition-colors", children: "Clear all" })), _jsx("button", { type: "button", onClick: () => setFilterOpen(false), "aria-label": "Close filters", className: "rounded-lg p-1.5 text-zinc-500 hover:bg-zinc-100 dark:hover:bg-slate-800 transition-colors", children: _jsx(X, { className: "h-5 w-5" }) })] })] }), _jsx("div", { className: "flex-1 overflow-y-auto px-4 py-4", children: _jsx(ReviewFilters, { table: pendingTable, variant: variant }) }), _jsx("div", { className: "border-t border-zinc-200 dark:border-slate-700 px-4 py-3.5", children: _jsxs("button", { type: "button", onClick: applyFilters, className: "w-full rounded-lg bg-primary py-2.5 text-sm font-semibold text-white hover:bg-primary-600 transition-colors active:scale-[0.98]", children: ["Apply Filters", activeFilterCount > 0 ? ` (${activeFilterCount})` : ""] }) })] })] }))] }));
100
107
  }
@@ -29,6 +29,8 @@ export function useReviews(params = {}, opts) {
29
29
  sp.set("minVotes", String(params.minVotes));
30
30
  if (params.maxVotes !== undefined)
31
31
  sp.set("maxVotes", String(params.maxVotes));
32
+ if (params.hasImages)
33
+ sp.set("hasImages", "true");
32
34
  // General listing mode (no productId)
33
35
  if (!params.productId && !params.userId && !params.storeId) {
34
36
  sp.set("latest", "true");
@@ -22,12 +22,16 @@ export interface ReviewDocument {
22
22
  storeId?: string;
23
23
  storeName?: string;
24
24
  userId: string;
25
+ /** Firestore document ID of the reviewer (slug like `user-mohsin-c`). Populated on create; used to build profile links. */
26
+ userSlug?: string;
25
27
  userName: string;
26
28
  userAvatar?: string;
27
29
  rating: number;
28
30
  title: string;
29
31
  comment: string;
30
32
  images?: string[];
33
+ /** Denormalized flag — true when images array is non-empty. Enables hasImages==true Firestore query. */
34
+ hasImages?: boolean;
31
35
  video?: ReviewVideoField;
32
36
  status: ReviewStatus;
33
37
  moderatorId?: string;
@@ -17,6 +17,8 @@ export interface Review {
17
17
  storeSlug?: string;
18
18
  storeName?: string;
19
19
  userId: string;
20
+ /** Firestore document slug for the reviewer — use for profile links instead of userId (Auth UID). */
21
+ userSlug?: string;
20
22
  userName: string;
21
23
  userAvatar?: string;
22
24
  rating: 1 | 2 | 3 | 4 | 5;
@@ -58,6 +60,7 @@ export interface ReviewListParams {
58
60
  dateTo?: string;
59
61
  minVotes?: number;
60
62
  maxVotes?: number;
63
+ hasImages?: boolean;
61
64
  }
62
65
  export interface CreateReviewInput {
63
66
  productId: string;
@@ -5,8 +5,14 @@ import { Shield, Phone, Wallet, Mail, ChevronRight, Search } from "lucide-react"
5
5
  import { Container, Heading, Main, Section, Text, Grid, Stack, Row, Card, Badge, Alert, EmptyState, Input, } from "../../../ui";
6
6
  import { listVerifiedScammers } from "../actions/scam-actions";
7
7
  import { SCAM_PLATFORM_LABELS } from "../schemas/firestore";
8
- import { SCAM_TYPE_LABELS } from "../constants/scam-types";
8
+ import { SCAM_TYPES, SCAM_TYPE_LABELS } from "../constants/scam-types";
9
9
  import { ROUTES } from "../../../next/routing/route-map";
10
+ const SORT_OPTIONS = [
11
+ { value: "-createdAt", label: "Newest Reports" },
12
+ { value: "createdAt", label: "Oldest Reports" },
13
+ { value: "-incidentCount", label: "Most Victims" },
14
+ { value: "-amountLost", label: "Highest Loss" },
15
+ ];
10
16
  function sp(params, key) {
11
17
  const v = params[key];
12
18
  return Array.isArray(v) ? (v[0] ?? "") : (v ?? "");
@@ -26,17 +32,44 @@ function ScammerCard({ scammer }) {
26
32
  }
27
33
  export async function ScamRegistryView({ searchParams = {} }) {
28
34
  const query = sp(searchParams, "q");
29
- const result = await listVerifiedScammers(searchParams).catch(() => ({
35
+ const sort = sp(searchParams, "sort") || "-createdAt";
36
+ const scamType = sp(searchParams, "scamType");
37
+ const pageSize = 20;
38
+ const result = await listVerifiedScammers({ ...searchParams, sort, pageSize: String(pageSize) }).catch(() => ({
30
39
  items: [],
31
40
  total: 0,
32
41
  page: 1,
33
- pageSize: 20,
42
+ pageSize,
34
43
  hasMore: false,
35
44
  }));
45
+ const totalPages = Math.max(1, Math.ceil(result.total / pageSize));
36
46
  const reportHref = String(ROUTES.PUBLIC.SCAM_REPORT);
37
- return (_jsxs(Main, { children: [_jsx("div", { className: "border-b appkit-alert--warning rounded-none", children: _jsx(Container, { size: "xl", className: "py-2.5", children: _jsxs(Alert, { variant: "warning", compact: true, children: ["All profiles on this page have been verified by our moderation team. If you recognise a scammer not listed here,", " ", _jsx(Link, { href: reportHref, className: "font-medium underline hover:no-underline", children: "report them" }), "."] }) }) }), _jsx(Section, { className: "py-10", children: _jsx(Container, { size: "xl", children: _jsxs(Stack, { gap: "xl", children: [_jsxs(Row, { justify: "between", gap: "md", align: "end", className: "flex-wrap", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 1, className: "text-3xl font-semibold", children: "Scam Registry" }), _jsx(Text, { variant: "secondary", className: "text-sm", children: "Verified scammers active in India's collectibles community. Search by name, phone, or UPI." })] }), _jsxs(Link, { href: reportHref, className: "appkit-button appkit-button--danger appkit-button--md shrink-0", children: [_jsx(Shield, { className: "h-4 w-4" }), "Report a Scammer"] })] }), _jsx("form", { method: "GET", className: "max-w-xl", children: _jsx(Input, { type: "search", name: "q", defaultValue: query, placeholder: "Search name, phone number, UPI ID, or email\u2026", icon: _jsx(Search, { className: "h-4 w-4" }) }) }), result.items.length === 0 ? (_jsx(EmptyState, { icon: _jsx(Shield, { className: "h-12 w-12" }), title: query
38
- ? "No verified scammers matched your search"
39
- : "No verified scammers in the registry yet", description: query
40
- ? "Try a different name, phone number, or UPI ID."
41
- : "Verified reports will appear here once reviewed by our moderation team." })) : (_jsxs(Stack, { gap: "md", children: [result.total > 0 && (_jsxs(Text, { variant: "secondary", className: "text-sm", children: [result.total, " verified profile", result.total !== 1 ? "s" : ""] })), _jsx(Grid, { cols: 3, gap: "md", children: result.items.map((scammer) => (_jsx(ScammerCard, { scammer: scammer }, scammer.id))) })] })), result.hasMore && (_jsx(Row, { justify: "center", children: _jsx(Link, { href: `/scams?page=${result.page + 1}${query ? `&q=${encodeURIComponent(query)}` : ""}`, className: "appkit-button appkit-button--outline appkit-button--md", children: "Load more" }) }))] }) }) })] }));
47
+ function buildHref(overrides) {
48
+ const p = new URLSearchParams();
49
+ if (query)
50
+ p.set("q", query);
51
+ if (sort !== "-createdAt")
52
+ p.set("sort", sort);
53
+ if (scamType)
54
+ p.set("scamType", scamType);
55
+ p.set("page", String(result.page));
56
+ for (const [k, v] of Object.entries(overrides)) {
57
+ if (v)
58
+ p.set(k, v);
59
+ else
60
+ p.delete(k);
61
+ }
62
+ const qs = p.toString();
63
+ return `/scams${qs ? `?${qs}` : ""}`;
64
+ }
65
+ return (_jsxs(Main, { children: [_jsx("div", { className: "border-b appkit-alert--warning rounded-none", children: _jsx(Container, { size: "xl", className: "py-2.5", children: _jsxs(Alert, { variant: "warning", compact: true, children: ["All profiles on this page have been verified by our moderation team. If you recognise a scammer not listed here,", " ", _jsx(Link, { href: reportHref, className: "font-medium underline hover:no-underline", children: "report them" }), "."] }) }) }), _jsx(Section, { className: "py-10", children: _jsx(Container, { size: "xl", children: _jsxs(Stack, { gap: "xl", children: [_jsxs(Row, { justify: "between", gap: "md", align: "end", className: "flex-wrap", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 1, className: "text-3xl font-semibold", children: "Scam Registry" }), _jsx(Text, { variant: "secondary", className: "text-sm", children: "Verified scammers active in India's collectibles community. Search by name, phone, or UPI." })] }), _jsxs(Link, { href: reportHref, className: "appkit-button appkit-button--danger appkit-button--md shrink-0", children: [_jsx(Shield, { className: "h-4 w-4" }), "Report a Scammer"] })] }), _jsxs("form", { method: "GET", className: "flex flex-wrap gap-3", children: [_jsx("div", { className: "flex-1 min-w-48", children: _jsx(Input, { type: "search", name: "q", defaultValue: query, placeholder: "Search name, phone, UPI ID, or email\u2026", icon: _jsx(Search, { className: "h-4 w-4" }) }) }), _jsxs("select", { name: "scamType", defaultValue: scamType, className: "rounded-lg border border-zinc-300 dark:border-slate-600 bg-white dark:bg-slate-800 px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-primary", children: [_jsx("option", { value: "", children: "All scam types" }), SCAM_TYPES.map((t) => (_jsx("option", { value: t.id, children: t.label }, t.id)))] }), _jsx("select", { name: "sort", defaultValue: sort, className: "rounded-lg border border-zinc-300 dark:border-slate-600 bg-white dark:bg-slate-800 px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-primary", children: SORT_OPTIONS.map((o) => (_jsx("option", { value: o.value, children: o.label }, o.value))) }), _jsx("button", { type: "submit", className: "rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition-colors", children: "Search" }), (query || scamType || sort !== "-createdAt") && (_jsx(Link, { href: "/scams", className: "rounded-lg border border-zinc-300 dark:border-slate-600 px-4 py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-slate-800 transition-colors", children: "Clear" }))] }), scamType && SCAM_TYPE_LABELS[scamType] && (_jsxs(Row, { gap: "sm", align: "center", children: [_jsx(Text, { variant: "secondary", className: "text-xs", children: "Filtering by:" }), _jsxs(Link, { href: buildHref({ scamType: "" }), className: "inline-flex items-center gap-1 rounded-full bg-rose-100 dark:bg-rose-900/30 px-2.5 py-0.5 text-xs font-medium text-rose-700 dark:text-rose-300 hover:bg-rose-200 transition-colors", children: [SCAM_TYPE_LABELS[scamType], _jsx("span", { "aria-hidden": "true", children: "\u00D7" })] })] })), result.items.length === 0 ? (_jsx(EmptyState, { icon: _jsx(Shield, { className: "h-12 w-12" }), title: query || scamType
66
+ ? "No verified scammers matched your filters"
67
+ : "No verified scammers in the registry yet", description: query || scamType
68
+ ? "Try a different search or clear your filters."
69
+ : "Verified reports will appear here once reviewed by our moderation team." })) : (_jsxs(Stack, { gap: "md", children: [_jsxs(Text, { variant: "secondary", className: "text-sm", children: [result.total, " verified profile", result.total !== 1 ? "s" : "", scamType && SCAM_TYPE_LABELS[scamType]
70
+ ? ` · ${SCAM_TYPE_LABELS[scamType]}`
71
+ : ""] }), _jsx(Grid, { cols: 3, gap: "md", children: result.items.map((scammer) => (_jsx(ScammerCard, { scammer: scammer }, scammer.id))) })] })), totalPages > 1 && (_jsxs(Row, { justify: "center", gap: "sm", className: "flex-wrap", children: [result.page > 1 && (_jsx(Link, { href: buildHref({ page: String(result.page - 1) }), className: "appkit-button appkit-button--outline appkit-button--sm", children: "\u2190 Prev" })), Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
72
+ const p = i + 1;
73
+ return (_jsx(Link, { href: buildHref({ page: String(p) }), className: `appkit-button appkit-button--sm ${p === result.page ? "appkit-button--primary" : "appkit-button--outline"}`, children: p }, p));
74
+ }), result.page < totalPages && (_jsx(Link, { href: buildHref({ page: String(result.page + 1) }), className: "appkit-button appkit-button--outline appkit-button--sm", children: "Next \u2192" }))] }))] }) }) })] }));
42
75
  }
@@ -19,5 +19,5 @@ export function InteractiveStoreCard({ store, href, selectable, isSelected = fal
19
19
  ].join(" "), onMouseDown: !isSelected ? longPress.onMouseDown : undefined, onMouseUp: !isSelected ? longPress.onMouseUp : undefined, onMouseLeave: !isSelected ? longPress.onMouseLeave : undefined, onTouchStart: !isSelected ? longPress.onTouchStart : undefined, onTouchEnd: !isSelected ? longPress.onTouchEnd : undefined, children: [onSelect && (_jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: (e) => {
20
20
  e.preventDefault();
21
21
  onSelect(store.id, !isSelected);
22
- }, label: isSelected ? "Deselect store" : "Select store", position: "top-2 left-2", className: selectable || isSelected ? "opacity-100" : "opacity-0 group-hover:opacity-100 transition-opacity" })), _jsxs(Link, { href: href, className: "flex flex-col flex-1 min-h-0", children: [_jsxs(Div, { className: "relative aspect-video w-full overflow-hidden bg-gradient-to-br from-zinc-100 via-zinc-200 to-zinc-300 dark:from-zinc-800 dark:via-zinc-700 dark:to-zinc-900 flex-shrink-0", children: [hasBanner ? (_jsx(MediaImage, { src: store.storeBannerURL, alt: `${store.storeName} banner`, size: "banner", className: "absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" })) : (_jsx(Div, { className: "absolute inset-0 flex items-center justify-center", children: _jsx(Span, { className: "text-5xl opacity-20 select-none", "aria-hidden": "true", children: "\uD83C\uDFEA" }) })), _jsx(Div, { className: "absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-black/40 to-transparent pointer-events-none" }), store.averageRating != null && store.averageRating > 0 && (_jsxs(Div, { className: "absolute top-2 right-2 flex items-center gap-1 rounded-full bg-black/50 backdrop-blur-sm px-2 py-0.5 text-xs font-semibold text-yellow-300", children: ["\u2605 ", store.averageRating.toFixed(1)] }))] }), _jsxs(Div, { className: "flex flex-col flex-1 px-4 pb-4", children: [_jsx(Div, { className: "-mt-5 mb-2 flex items-end justify-between", children: _jsx(Div, { className: "flex-shrink-0", children: hasLogo ? (_jsx(MediaImage, { src: store.storeLogoURL, alt: store.storeName, size: "avatar", className: "h-10 w-10 rounded-lg border-2 border-white dark:border-zinc-800 shadow-md object-cover" })) : (_jsx(Div, { className: "flex h-10 w-10 items-center justify-center rounded-lg border-2 border-white dark:border-zinc-800 bg-primary/10 dark:bg-primary/20 text-base font-bold text-primary shadow-md", children: initial })) }) }), _jsx(Heading, { level: 3, className: `${THEME_CONSTANTS.utilities.textClamp1} text-sm font-bold text-zinc-900 dark:text-zinc-100 group-hover:text-primary transition-colors`, children: store.storeName }), store.storeDescription ? (_jsx(RichText, { html: normalizeRichTextHtml(store.storeDescription), proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: `mt-1 ${THEME_CONSTANTS.utilities.textClamp2} text-xs text-zinc-500 dark:text-zinc-400 flex-1` })) : (_jsx(Div, { className: "flex-1" })), _jsxs(Row, { gap: "sm", className: "mt-2.5 flex-wrap text-xs text-zinc-500 dark:text-zinc-400", children: [store.totalProducts != null && store.totalProducts > 0 && (_jsxs(Span, { className: "flex items-center gap-0.5", children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDCE6" }), " ", store.totalProducts, " ", labels.products ?? "products"] })), store.itemsSold != null && store.itemsSold > 0 && (_jsxs(Span, { className: "flex items-center gap-0.5", children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDECD\uFE0F" }), " ", store.itemsSold, " ", labels.sold ?? "sold"] }))] }), _jsxs(Div, { className: "mt-3 pt-2.5 border-t border-zinc-100 dark:border-zinc-800 flex items-center justify-between", children: [_jsxs(Span, { className: "text-xs font-semibold text-primary group-hover:underline transition-colors", children: [labels.visitStore ?? "Visit store", " \u2192"] }), store.totalReviews != null && store.totalReviews > 0 && (_jsxs(Span, { className: "text-xs text-zinc-400 dark:text-zinc-500", children: [store.totalReviews, " ", labels.reviews ?? "reviews"] }))] })] })] })] }));
22
+ }, label: isSelected ? "Deselect store" : "Select store", position: "top-2 left-2", className: selectable || isSelected ? "opacity-100" : "opacity-0 group-hover:opacity-100 transition-opacity" })), _jsxs(Link, { href: href, className: "flex flex-col flex-1 min-h-0", children: [_jsxs(Div, { className: "relative aspect-video w-full overflow-hidden bg-gradient-to-br from-zinc-100 via-zinc-200 to-zinc-300 dark:from-zinc-800 dark:via-zinc-700 dark:to-zinc-900 flex-shrink-0", children: [hasBanner ? (_jsx(MediaImage, { src: store.storeBannerURL, alt: `${store.storeName} banner`, size: "banner", className: "h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" })) : (_jsx(Div, { className: "absolute inset-0 flex items-center justify-center", children: _jsx(Span, { className: "text-5xl opacity-20 select-none", "aria-hidden": "true", children: "\uD83C\uDFEA" }) })), _jsx(Div, { className: "absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-black/40 to-transparent pointer-events-none" }), store.averageRating != null && store.averageRating > 0 && (_jsxs(Div, { className: "absolute top-2 right-2 flex items-center gap-1 rounded-full bg-black/50 backdrop-blur-sm px-2 py-0.5 text-xs font-semibold text-yellow-300", children: ["\u2605 ", store.averageRating.toFixed(1)] }))] }), _jsxs(Div, { className: "flex flex-col flex-1 px-4 pb-4", children: [_jsx(Div, { className: "-mt-5 mb-2 flex items-end justify-between", children: _jsx(Div, { className: "flex-shrink-0", children: hasLogo ? (_jsx(MediaImage, { src: store.storeLogoURL, alt: store.storeName, size: "avatar", className: "h-10 w-10 rounded-lg border-2 border-white dark:border-zinc-800 shadow-md object-cover" })) : (_jsx(Div, { className: "flex h-10 w-10 items-center justify-center rounded-lg border-2 border-white dark:border-zinc-800 bg-primary/10 dark:bg-primary/20 text-base font-bold text-primary shadow-md", children: initial })) }) }), _jsx(Heading, { level: 3, className: `${THEME_CONSTANTS.utilities.textClamp1} text-sm font-bold text-zinc-900 dark:text-zinc-100 group-hover:text-primary transition-colors`, children: store.storeName }), store.storeDescription ? (_jsx(RichText, { html: normalizeRichTextHtml(store.storeDescription), proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: `mt-1 ${THEME_CONSTANTS.utilities.textClamp2} text-xs text-zinc-500 dark:text-zinc-400 flex-1` })) : (_jsx(Div, { className: "flex-1" })), _jsxs(Row, { gap: "sm", className: "mt-2.5 flex-wrap text-xs text-zinc-500 dark:text-zinc-400", children: [store.totalProducts != null && store.totalProducts > 0 && (_jsxs(Span, { className: "flex items-center gap-0.5", children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDCE6" }), " ", store.totalProducts, " ", labels.products ?? "products"] })), store.itemsSold != null && store.itemsSold > 0 && (_jsxs(Span, { className: "flex items-center gap-0.5", children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDECD\uFE0F" }), " ", store.itemsSold, " ", labels.sold ?? "sold"] }))] }), _jsxs(Div, { className: "mt-3 pt-2.5 border-t border-zinc-100 dark:border-zinc-800 flex items-center justify-between", children: [_jsxs(Span, { className: "text-xs font-semibold text-primary group-hover:underline transition-colors", children: [labels.visitStore ?? "Visit store", " \u2192"] }), store.totalReviews != null && store.totalReviews > 0 && (_jsxs(Span, { className: "text-xs text-zinc-400 dark:text-zinc-500", children: [store.totalReviews, " ", labels.reviews ?? "reviews"] }))] })] })] })] }));
23
23
  }
@@ -32,7 +32,7 @@ export function StoreAuctionsListing({ storeId, initialData }) {
32
32
  const [view, setView] = useState(table.get("view") || "grid");
33
33
  const localWishlist = useGuestWishlist();
34
34
  const wishlistedIds = new Set(localWishlist.items.filter((i) => i.type === "auction").map((i) => i.itemId));
35
- const { pendingTable, filterActiveCount, onFilterApply, onFilterClear, onFilterReset } = usePendingTable(table, FILTER_KEYS);
35
+ const { pendingTable, filterActiveCount, onFilterApply, onFilterClear, onResetAll, onFilterReset } = usePendingTable(table, FILTER_KEYS);
36
36
  const openFilters = useCallback(() => {
37
37
  onFilterReset();
38
38
  setFilterOpen(true);
@@ -42,10 +42,9 @@ export function StoreAuctionsListing({ storeId, initialData }) {
42
42
  setFilterOpen(false);
43
43
  }, [onFilterApply]);
44
44
  const resetAll = useCallback(() => {
45
- table.setMany({ q: "", sort: "" });
46
- onFilterClear();
45
+ onResetAll({ q: "", sort: "" });
47
46
  setSearchInput("");
48
- }, [table, onFilterClear]);
47
+ }, [onResetAll]);
49
48
  const hasActiveState = !!table.get("q") ||
50
49
  table.get("sort") !== DEFAULT_SORT ||
51
50
  filterActiveCount > 0;
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cache } from "react";
3
- import { storeRepository, productRepository, categoriesRepository, siteSettingsRepository } from "../../../repositories";
3
+ import { storeRepository, productRepository, categoriesRepository, siteSettingsRepository, reviewRepository, couponsRepository } from "../../../repositories";
4
4
  import { ROUTES } from "../../../next";
5
5
  import { Container, Main, Section, Text } from "../../../ui";
6
6
  import { STORE_PAGE_TABS } from "../../products/constants/listing-tabs";
@@ -27,7 +27,7 @@ export async function StoreDetailLayoutView({ storeSlug, activeTab, children, })
27
27
  }
28
28
  const storeId = store?.id;
29
29
  const settings = await siteSettingsRepository.findById("global").catch(() => null);
30
- const [productsCount, auctionsCount, preOrdersCount, prizeDrawsCount, bundlesCount] = storeId
30
+ const [productsCount, auctionsCount, preOrdersCount, prizeDrawsCount, bundlesCount, couponsCount, reviewsCount] = storeId
31
31
  ? await Promise.all([
32
32
  productRepository
33
33
  .list({ filters: `storeId==${storeId},status==published,listingType==standard`, page: 1, pageSize: 1 })
@@ -51,8 +51,16 @@ export async function StoreDetailLayoutView({ storeSlug, activeTab, children, })
51
51
  .listByType("bundle", { activeOnly: true, limit: 100 })
52
52
  .then((rows) => rows.filter((c) => c.createdByStoreId === storeId).length)
53
53
  .catch(() => 0),
54
+ couponsRepository
55
+ .list({ filters: `sellerId==${storeId},validity.isActive==true`, page: 1, pageSize: 1 })
56
+ .then((r) => r.total)
57
+ .catch(() => 0),
58
+ reviewRepository
59
+ .listAll({ filters: `storeId==${storeId},status==approved`, page: 1, pageSize: 1 })
60
+ .then((r) => r.total)
61
+ .catch(() => 0),
54
62
  ])
55
- : [0, 0, 0, 0, 0];
63
+ : [0, 0, 0, 0, 0, 0, 0];
56
64
  const listingCounts = {
57
65
  products: productsCount,
58
66
  auctions: auctionsCount,
@@ -78,8 +86,8 @@ export async function StoreDetailLayoutView({ storeSlug, activeTab, children, })
78
86
  label: tabLabel(tab.label, listingCounts[tab.id]),
79
87
  href: STORE_LISTING_HREF[tab.id](storeSlug),
80
88
  })),
81
- { value: "coupons", label: "Coupons", href: String(ROUTES.PUBLIC.STORE_COUPONS(storeSlug)) },
82
- { value: "reviews", label: "Reviews", href: String(ROUTES.PUBLIC.STORE_REVIEWS(storeSlug)) },
89
+ { value: "coupons", label: tabLabel("Coupons", couponsCount), href: String(ROUTES.PUBLIC.STORE_COUPONS(storeSlug)) },
90
+ { value: "reviews", label: tabLabel("Reviews", reviewsCount), href: String(ROUTES.PUBLIC.STORE_REVIEWS(storeSlug)) },
83
91
  { value: "about", label: "About", href: String(ROUTES.PUBLIC.STORE_ABOUT(storeSlug)) },
84
92
  ];
85
93
  return (_jsxs(Main, { children: [_jsx(StoreHeader, { store: store }), _jsxs(Container, { size: "xl", className: "mt-6", children: [_jsx(StoreNavTabs, { tabs: tabs, activeValue: activeTab }), _jsx(Section, { className: "pt-6", children: children })] })] }));
@@ -24,7 +24,7 @@ export function StorePreOrdersListing({ storeId, initialData }) {
24
24
  const [searchInput, setSearchInput] = useState(table.get("q") || "");
25
25
  const [filterOpen, setFilterOpen] = useState(false);
26
26
  const [view, setView] = useState(table.get("view") || "grid");
27
- const { pendingTable, filterActiveCount, onFilterApply, onFilterClear, onFilterReset } = usePendingTable(table, FILTER_KEYS);
27
+ const { pendingTable, filterActiveCount, onFilterApply, onFilterClear, onResetAll, onFilterReset } = usePendingTable(table, FILTER_KEYS);
28
28
  const openFilters = useCallback(() => {
29
29
  onFilterReset();
30
30
  setFilterOpen(true);
@@ -34,10 +34,9 @@ export function StorePreOrdersListing({ storeId, initialData }) {
34
34
  setFilterOpen(false);
35
35
  }, [onFilterApply]);
36
36
  const resetAll = useCallback(() => {
37
- table.setMany({ q: "", sort: "" });
38
- onFilterClear();
37
+ onResetAll({ q: "", sort: "" });
39
38
  setSearchInput("");
40
- }, [table, onFilterClear]);
39
+ }, [onResetAll]);
41
40
  const hasActiveState = !!table.get("q") ||
42
41
  table.get("sort") !== DEFAULT_SORT ||
43
42
  filterActiveCount > 0;
@@ -24,7 +24,7 @@ export function StoreProductsListing({ storeId, initialData }) {
24
24
  const localCart = useGuestCart();
25
25
  const localWishlist = useGuestWishlist();
26
26
  const wishlistedIds = new Set(localWishlist.items.filter((i) => i.type === "product").map((i) => i.itemId));
27
- const { pendingTable, filterActiveCount, onFilterApply, onFilterClear, onFilterReset } = usePendingTable(table, FILTER_KEYS);
27
+ const { pendingTable, filterActiveCount, onFilterApply, onFilterClear, onResetAll, onFilterReset } = usePendingTable(table, FILTER_KEYS);
28
28
  const openFilters = useCallback(() => {
29
29
  onFilterReset();
30
30
  setFilterOpen(true);
@@ -34,10 +34,9 @@ export function StoreProductsListing({ storeId, initialData }) {
34
34
  setFilterOpen(false);
35
35
  }, [onFilterApply]);
36
36
  const resetAll = useCallback(() => {
37
- table.setMany({ q: "", sort: "" });
38
- onFilterClear();
37
+ onResetAll({ q: "", sort: "" });
39
38
  setSearchInput("");
40
- }, [table, onFilterClear]);
39
+ }, [onResetAll]);
41
40
  const hasActiveState = !!table.get("q") ||
42
41
  table.get("sort") !== "-createdAt" ||
43
42
  filterActiveCount > 0;
package/dist/index.d.ts CHANGED
@@ -1761,6 +1761,7 @@ export { EventRaffleWinnerView } from "./features/events/index";
1761
1761
  export type { EventRaffleWinnerViewProps } from "./features/events/index";
1762
1762
  export { SpinWheelView } from "./features/events/index";
1763
1763
  export type { SpinWheelViewProps } from "./features/events/index";
1764
+ export type { SpinPrize } from "./features/events/index";
1764
1765
  export { EventsListView } from "./features/events/index";
1765
1766
  export { EventsIndexListing } from "./features/events/index";
1766
1767
  export type { EventsIndexListingProps } from "./features/events/index";
@@ -1,13 +1,11 @@
1
1
  import type { CollectionReference, DocumentData, Query } from "firebase-admin/firestore";
2
- /**
3
- * Firestore serialisation helpers.
4
- *
5
- * These utilities are the single adapter boundary between raw Firestore
6
- * documents and application-level objects.
7
- */
8
2
  /**
9
3
  * Recursively remove `undefined` values from an object.
10
4
  * Firestore rejects writes that contain `undefined` fields.
5
+ *
6
+ * Non-plain values (Date, FieldValue sentinels, class instances, Arrays)
7
+ * are passed through as-is so that Firestore transform operations
8
+ * (increment, arrayUnion, deleteField, serverTimestamp) survive the strip.
11
9
  */
12
10
  export declare function removeUndefined<T extends Record<string, unknown>>(obj: T): Partial<T>;
13
11
  /**
@@ -5,9 +5,26 @@
5
5
  * documents and application-level objects.
6
6
  */
7
7
  // --- Undefined removal --------------------------------------------------------
8
+ /**
9
+ * Returns true only for plain objects ({}) — NOT class instances, Dates,
10
+ * Arrays, FieldValue sentinels, or Timestamps. Only plain objects are safe
11
+ * to recurse into; everything else must pass through unchanged so Firestore
12
+ * FieldValue sentinels (increment, arrayUnion, deleteField, etc.) are not
13
+ * stripped.
14
+ */
15
+ function isPlainObject(v) {
16
+ if (v === null || typeof v !== "object" || Array.isArray(v))
17
+ return false;
18
+ const proto = Object.getPrototypeOf(v);
19
+ return proto === Object.prototype || proto === null;
20
+ }
8
21
  /**
9
22
  * Recursively remove `undefined` values from an object.
10
23
  * Firestore rejects writes that contain `undefined` fields.
24
+ *
25
+ * Non-plain values (Date, FieldValue sentinels, class instances, Arrays)
26
+ * are passed through as-is so that Firestore transform operations
27
+ * (increment, arrayUnion, deleteField, serverTimestamp) survive the strip.
11
28
  */
12
29
  export function removeUndefined(obj) {
13
30
  const result = {};
@@ -15,11 +32,7 @@ export function removeUndefined(obj) {
15
32
  const value = obj[key];
16
33
  if (value === undefined)
17
34
  continue;
18
- if (value !== null &&
19
- typeof value === "object" &&
20
- !Array.isArray(value) &&
21
- !(value instanceof Date) &&
22
- !value.constructor?.name?.includes("Timestamp")) {
35
+ if (isPlainObject(value)) {
23
36
  const cleaned = removeUndefined(value);
24
37
  if (Object.keys(cleaned).length > 0) {
25
38
  result[key] = cleaned;
@@ -19,6 +19,31 @@
19
19
  */
20
20
  import { SieveProcessorBase } from "@mohasinac/sievejs/services";
21
21
  import { createFirebaseAdapter } from "@mohasinac/sievejs/adapters/firebase";
22
+ /**
23
+ * Wraps the stock Firebase adapter and upgrades OR groups where all conditions
24
+ * are equality checks on the same field into a Firestore `in` query.
25
+ *
26
+ * Background: Sieve parses `field==v1|v2` as a single term with two values
27
+ * (OR semantics). The stock adapter tries `whereOr` which Firestore Admin SDK
28
+ * doesn't support. Firestore's `.where(field, "in", [v1, v2])` is the correct
29
+ * equivalent and supports up to 30 values.
30
+ */
31
+ function createEnhancedFirebaseAdapter() {
32
+ const base = createFirebaseAdapter();
33
+ return {
34
+ ...base,
35
+ applyFilterGroup(query, group) {
36
+ if (group.length > 1 &&
37
+ group.every((c) => c.parsedOperator === "equals" &&
38
+ !c.operatorIsNegated &&
39
+ !c.operatorIsCaseInsensitive &&
40
+ c.field === group[0].field)) {
41
+ return query.where(group[0].field, "in", group.map((c) => c.value));
42
+ }
43
+ return base.applyFilterGroup(query, group);
44
+ },
45
+ };
46
+ }
22
47
  import { FirebaseRepository } from "./base";
23
48
  import { deserializeTimestamps, getFirestoreCount } from "./helpers";
24
49
  import { expandFilterAliases as _expandFilterAliases, } from "./filter-aliases";
@@ -90,7 +115,7 @@ export class FirebaseSieveRepository extends FirebaseRepository {
90
115
  const base = baseQuery ?? this.getCollection();
91
116
  const effective = withAliasesExpanded(model, aliases);
92
117
  const processor = new SieveProcessorBase({
93
- adapter: createFirebaseAdapter(),
118
+ adapter: createEnhancedFirebaseAdapter(),
94
119
  autoLoadConfig: false,
95
120
  options: merged,
96
121
  fields,