@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.
- package/dist/constants/field-names.d.ts +1 -0
- package/dist/constants/field-names.js +1 -0
- package/dist/core/unit-of-work.js +3 -4
- package/dist/features/auth/actions/profile-actions.js +4 -1
- package/dist/features/auth/repository/user.repository.d.ts +1 -0
- package/dist/features/auth/repository/user.repository.js +3 -0
- package/dist/features/blog/api/route.js +3 -0
- package/dist/features/blog/components/BlogIndexListing.js +2 -0
- package/dist/features/blog/components/BlogPostView.js +2 -1
- package/dist/features/categories/api/route.js +1 -1
- package/dist/features/categories/components/BundleDetailView.js +5 -3
- package/dist/features/categories/components/CategoryBundlesListing.js +77 -41
- package/dist/features/categories/components/CategoryDetailPageView.js +12 -9
- package/dist/features/categories/components/CategoryProductsListing.js +3 -5
- package/dist/features/classified/components/ClassifiedIndexListing.js +3 -4
- package/dist/features/digital-codes/components/DigitalCodesIndexListing.js +3 -4
- package/dist/features/events/actions/event-actions.d.ts +2 -3
- package/dist/features/events/actions/event-actions.js +3 -5
- package/dist/features/events/api/route.js +7 -7
- package/dist/features/events/components/AdminEventEditorView.js +80 -6
- package/dist/features/events/components/AdminEventEntriesView.js +33 -4
- package/dist/features/events/components/EventFilters.d.ts +3 -0
- package/dist/features/events/components/EventFilters.js +1 -0
- package/dist/features/events/components/EventLeaderboard.js +1 -1
- package/dist/features/events/components/EventsIndexListing.js +10 -7
- package/dist/features/events/repository/event-entry.repository.d.ts +2 -2
- package/dist/features/events/repository/event-entry.repository.js +32 -7
- package/dist/features/events/types/index.d.ts +3 -1
- package/dist/features/homepage/components/BrandsSection.js +2 -2
- package/dist/features/homepage/components/CustomCardsSection.js +1 -1
- package/dist/features/homepage/components/CustomerReviewsSection.js +1 -1
- package/dist/features/homepage/components/FeaturedResultsSection.js +1 -1
- package/dist/features/homepage/components/SectionCarousel.js +1 -1
- package/dist/features/homepage/components/ShopByCategorySection.js +1 -1
- package/dist/features/live/components/LiveItemsIndexListing.js +3 -4
- package/dist/features/pre-orders/components/PreOrdersIndexListing.js +3 -4
- package/dist/features/products/components/AuctionsIndexListing.js +3 -4
- package/dist/features/products/components/PrizeDrawsIndexListing.js +3 -8
- package/dist/features/products/components/ProductDetailPageView.js +17 -2
- package/dist/features/products/components/ProductsIndexListing.js +10 -6
- package/dist/features/products/schemas/firestore.d.ts +2 -0
- package/dist/features/reviews/actions/review-actions.js +2 -0
- package/dist/features/reviews/components/ReviewDetailShell.js +3 -2
- package/dist/features/reviews/components/ReviewFilters.js +1 -1
- package/dist/features/reviews/components/ReviewsIndexListing.js +9 -2
- package/dist/features/reviews/hooks/useReviews.js +2 -0
- package/dist/features/reviews/schemas/firestore.d.ts +4 -0
- package/dist/features/reviews/types/index.d.ts +3 -0
- package/dist/features/scams/components/ScamRegistryView.js +41 -8
- package/dist/features/stores/components/InteractiveStoreCard.js +1 -1
- package/dist/features/stores/components/StoreAuctionsListing.js +3 -4
- package/dist/features/stores/components/StoreDetailLayoutView.js +13 -5
- package/dist/features/stores/components/StorePreOrdersListing.js +3 -4
- package/dist/features/stores/components/StoreProductsListing.js +3 -4
- package/dist/index.d.ts +1 -0
- package/dist/providers/db-firebase/helpers.d.ts +4 -6
- package/dist/providers/db-firebase/helpers.js +18 -5
- package/dist/providers/db-firebase/sieve.js +26 -1
- package/dist/react/hooks/usePendingFilters.d.ts +2 -0
- package/dist/react/hooks/usePendingFilters.js +9 -0
- package/dist/react/hooks/usePendingTable.d.ts +5 -0
- package/dist/react/hooks/usePendingTable.js +1 -0
- package/dist/seed/homepage-sections-seed-data.js +2 -2
- package/dist/seed/products-standard-seed-data.js +1 -0
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/Drawer.style.css +13 -0
- package/dist/ui/components/FilterDrawer.d.ts +4 -2
- package/dist/ui/components/FilterDrawer.js +2 -2
- 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
|
-
: ""] })), (
|
|
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:
|
|
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
|
-
|
|
46
|
-
onFilterClear();
|
|
47
|
+
onResetAll({ [TABLE_KEYS.QUERY]: "", [TABLE_KEYS.SORT]: "", [TABLE_KEYS.SHOW_SOLD]: "" });
|
|
47
48
|
setSearchInput("");
|
|
48
|
-
}, [
|
|
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
|
|
76
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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: "
|
|
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
|
-
|
|
46
|
-
onFilterClear();
|
|
45
|
+
onResetAll({ q: "", sort: "" });
|
|
47
46
|
setSearchInput("");
|
|
48
|
-
}, [
|
|
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
|
-
|
|
38
|
-
onFilterClear();
|
|
37
|
+
onResetAll({ q: "", sort: "" });
|
|
39
38
|
setSearchInput("");
|
|
40
|
-
}, [
|
|
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
|
-
|
|
38
|
-
onFilterClear();
|
|
37
|
+
onResetAll({ q: "", sort: "" });
|
|
39
38
|
setSearchInput("");
|
|
40
|
-
}, [
|
|
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
|
|
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:
|
|
118
|
+
adapter: createEnhancedFirebaseAdapter(),
|
|
94
119
|
autoLoadConfig: false,
|
|
95
120
|
options: merged,
|
|
96
121
|
fields,
|