@mohasinac/appkit 4.11.2 → 4.11.3
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/_internal/server/features/checkout/actions.d.ts +4 -0
- package/dist/_internal/server/features/checkout/actions.js +111 -4
- package/dist/_internal/server/features/checkout/data.d.ts +10 -0
- package/dist/_internal/server/features/orders/adapters.js +7 -0
- package/dist/_internal/server/features/products/data.d.ts +12 -3
- package/dist/_internal/server/features/products/data.js +55 -4
- package/dist/_internal/server/features/products/index.d.ts +1 -1
- package/dist/_internal/server/features/products/index.js +1 -1
- package/dist/_internal/server/features/products/list-public.d.ts +19 -0
- package/dist/_internal/server/features/products/list-public.js +39 -6
- package/dist/_internal/server/features/reviews/data.d.ts +1 -1
- package/dist/_internal/server/features/reviews/data.js +3 -2
- package/dist/_internal/server/features/tester/visibility.js +3 -3
- package/dist/_internal/server/jobs/core/testerSandboxCleanup.d.ts +1 -1
- package/dist/_internal/server/jobs/core/testerSandboxCleanup.js +4 -1
- package/dist/_internal/server/jobs/core/testerSandboxRefresh.js +2 -0
- package/dist/_internal/server/jobs/handlers/_helpers.js +4 -1
- package/dist/_internal/shared/features/promotions/schema.d.ts +0 -16
- package/dist/_internal/shared/features/promotions/schema.js +0 -2
- package/dist/_internal/shared/features/reviews/config.d.ts +8 -1
- package/dist/_internal/shared/features/reviews/config.js +8 -1
- package/dist/_internal/shared/listing-types/_registry.d.ts +88 -1
- package/dist/_internal/shared/listing-types/_registry.js +63 -1
- package/dist/_internal/shared/listing-types/art/config.d.ts +59 -0
- package/dist/_internal/shared/listing-types/art/config.js +12 -0
- package/dist/_internal/shared/listing-types/auction/config.d.ts +53 -0
- package/dist/_internal/shared/listing-types/auction/config.js +17 -0
- package/dist/_internal/shared/listing-types/classified/config.d.ts +59 -0
- package/dist/_internal/shared/listing-types/classified/config.js +14 -0
- package/dist/_internal/shared/listing-types/digital-code/config.d.ts +59 -0
- package/dist/_internal/shared/listing-types/digital-code/config.js +12 -0
- package/dist/_internal/shared/listing-types/live/config.d.ts +59 -0
- package/dist/_internal/shared/listing-types/live/config.js +14 -0
- package/dist/_internal/shared/listing-types/pre-order/config.d.ts +110 -0
- package/dist/_internal/shared/listing-types/pre-order/config.js +14 -0
- package/dist/_internal/shared/listing-types/prize-draw/config.d.ts +44 -0
- package/dist/_internal/shared/listing-types/prize-draw/config.js +10 -0
- package/dist/_internal/shared/listing-types/standard/config.d.ts +59 -0
- package/dist/_internal/shared/listing-types/standard/config.js +9 -0
- package/dist/_internal/shared/listing-types/stickers/config.d.ts +59 -0
- package/dist/_internal/shared/listing-types/stickers/config.js +12 -0
- package/dist/client.d.ts +5 -1
- package/dist/client.js +4 -1
- package/dist/constants/field-names.d.ts +12 -0
- package/dist/constants/field-names.js +12 -0
- package/dist/constants/table-keys.d.ts +2 -0
- package/dist/constants/table-keys.js +2 -0
- package/dist/features/account/components/UserOrdersView.js +27 -5
- package/dist/features/admin/components/AdminAddressesView.js +3 -1
- package/dist/features/admin/components/AdminCouponEditorView.js +1 -4
- package/dist/features/admin/components/AdminPaymentMethodsView.js +3 -1
- package/dist/features/admin/components/AdminProductsView.js +34 -18
- package/dist/features/admin/constants/filter-tabs.d.ts +26 -9
- package/dist/features/admin/constants/filter-tabs.js +22 -9
- package/dist/features/auctions/components/AuctionBidsTable.js +1 -1
- package/dist/features/auctions/components/AuctionBottomActions.js +1 -0
- package/dist/features/auctions/components/AuctionDetailPageView.js +13 -11
- package/dist/features/cart/schemas/firestore.d.ts +0 -2
- package/dist/features/categories/components/BrandDetailTabs.js +31 -11
- package/dist/features/categories/components/CategoryDetailTabs.js +27 -7
- package/dist/features/categories/components/CategoryProductsListing.d.ts +9 -1
- package/dist/features/categories/components/CategoryProductsListing.js +5 -2
- package/dist/features/classified/components/ClassifiedDetailPageView.js +2 -1
- package/dist/features/contact/email.js +5 -1
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +17 -16
- 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/layout/AppLayoutShell.js +9 -2
- package/dist/features/layout/BottomActions.js +26 -3
- package/dist/features/layout/BottomActionsContext.d.ts +14 -0
- package/dist/features/layout/BottomActionsContext.js +13 -1
- package/dist/features/layout/hooks/useBottomActions.d.ts +6 -1
- package/dist/features/layout/hooks/useBottomActions.js +4 -1
- package/dist/features/layout/index.d.ts +1 -1
- package/dist/features/live/components/LiveItemDetailPageView.js +7 -6
- package/dist/features/orders/repository/orders.repository.d.ts +4 -0
- package/dist/features/orders/repository/orders.repository.js +4 -0
- package/dist/features/orders/types/index.d.ts +16 -0
- package/dist/features/pre-orders/components/PreOrderBottomActions.d.ts +1 -1
- package/dist/features/pre-orders/components/PreOrderBottomActions.js +3 -19
- package/dist/features/products/components/AuctionsIndexListing.js +1 -2
- package/dist/features/products/components/ListingBottomActions.d.ts +28 -0
- package/dist/features/products/components/ListingBottomActions.js +25 -0
- package/dist/features/products/components/PrizeDrawBottomActions.d.ts +1 -1
- package/dist/features/products/components/PrizeDrawBottomActions.js +3 -21
- package/dist/features/products/components/ProductDetailActions.js +4 -0
- package/dist/features/products/components/ProductDetailPageView.js +7 -7
- package/dist/features/products/constants/listing-tabs.d.ts +43 -198
- package/dist/features/products/constants/listing-tabs.js +71 -58
- package/dist/features/products/constants/sieve.d.ts +104 -1
- package/dist/features/products/constants/sieve.js +27 -7
- package/dist/features/products/repository/products.repository.d.ts +45 -1
- package/dist/features/products/repository/products.repository.js +26 -3
- package/dist/features/products/utils/listing-badge-variant.d.ts +20 -1
- package/dist/features/products/utils/listing-badge-variant.js +26 -1
- package/dist/features/products/utils/listing-type.d.ts +24 -0
- package/dist/features/products/utils/listing-type.js +48 -0
- package/dist/features/promotions/actions/coupon-actions.d.ts +7 -0
- package/dist/features/promotions/actions/coupon-actions.js +12 -1
- package/dist/features/promotions/actions/coupon-stacking.d.ts +24 -0
- package/dist/features/promotions/actions/coupon-stacking.js +21 -0
- package/dist/features/promotions/actions/index.d.ts +1 -0
- package/dist/features/promotions/actions/index.js +1 -0
- package/dist/features/promotions/actions/seller-coupon-actions.d.ts +0 -1
- package/dist/features/promotions/actions/seller-coupon-actions.js +0 -1
- package/dist/features/promotions/components/CouponHelpDetails.d.ts +20 -0
- package/dist/features/promotions/components/CouponHelpDetails.js +17 -0
- package/dist/features/promotions/components/CouponsIndexListing.js +2 -1
- package/dist/features/promotions/components/index.d.ts +2 -0
- package/dist/features/promotions/components/index.js +1 -0
- package/dist/features/promotions/constants/coupon-help.d.ts +27 -0
- package/dist/features/promotions/constants/coupon-help.js +42 -0
- package/dist/features/promotions/repository/coupons.repository.d.ts +10 -1
- package/dist/features/promotions/repository/coupons.repository.js +22 -8
- package/dist/features/promotions/schemas/firestore.d.ts +0 -1
- package/dist/features/promotions/schemas/firestore.js +1 -1
- package/dist/features/promotions/schemas/index.d.ts +0 -20
- package/dist/features/promotions/schemas/index.js +0 -1
- package/dist/features/reviews/actions/review-actions.js +5 -1
- package/dist/features/reviews/components/ReviewDetailPageView.js +2 -1
- package/dist/features/reviews/components/ReviewFilters.d.ts +7 -1
- package/dist/features/reviews/components/ReviewFilters.js +4 -3
- package/dist/features/reviews/components/ReviewsIndexListing.d.ts +6 -0
- package/dist/features/reviews/components/ReviewsIndexListing.js +9 -109
- package/dist/features/reviews/components/ReviewsListingPanel.d.ts +41 -0
- package/dist/features/reviews/components/ReviewsListingPanel.js +226 -0
- package/dist/features/reviews/components/index.d.ts +2 -0
- package/dist/features/reviews/components/index.js +1 -0
- package/dist/features/reviews/hooks/useReviews.d.ts +8 -0
- package/dist/features/reviews/hooks/useReviews.js +14 -6
- package/dist/features/reviews/repository/reviews.repository.d.ts +29 -1
- package/dist/features/reviews/repository/reviews.repository.js +77 -41
- package/dist/features/reviews/types/index.d.ts +8 -1
- package/dist/features/seller/components/SellerProductsCards.js +2 -2
- package/dist/features/seller/components/SellerProductsView.js +21 -18
- package/dist/features/stores/actions/store-query-actions.js +1 -1
- package/dist/features/stores/components/StoreAuctionsListing.js +5 -11
- package/dist/features/stores/components/StorePreOrdersListing.js +6 -11
- package/dist/features/stores/components/StoreReviewsListing.d.ts +4 -0
- package/dist/features/stores/components/StoreReviewsListing.js +7 -108
- package/dist/features/stores/schemas/index.d.ts +2 -2
- package/dist/features/tester/seed-data/coupons-tester-seed-data.js +3 -3
- package/dist/features/tester/seed-data/index.d.ts +1 -0
- package/dist/features/tester/seed-data/index.js +1 -0
- package/dist/features/tester/seed-data/offers-tester-seed-data.d.ts +2 -0
- package/dist/features/tester/seed-data/offers-tester-seed-data.js +61 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +337 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.js +11 -2
- package/dist/react/hooks/useListingTypeFlags.d.ts +12 -19
- package/dist/react/hooks/useListingTypeFlags.js +4 -9
- package/dist/schemas/registry.d.ts +0 -12
- package/dist/seed/claimed-coupons-seed-data.js +3 -3
- package/dist/seed/coupons-seed-data.js +29 -10
- package/dist/seed/faq-seed-data.js +2 -2
- package/dist/seed/manifest.js +2 -2
- package/dist/styles.css +44 -39
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/FilterChipGroup.d.ts +21 -3
- package/dist/ui/components/FilterChipGroup.js +33 -4
- package/dist/ui/components/HorizontalScroller.d.ts +1 -2
- package/dist/ui/components/HorizontalScroller.js +9 -5
- package/dist/ui/components/HorizontalScroller.style.css +43 -38
- package/package.json +1 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useMemo, useState } from "react";
|
|
4
|
+
import { useUrlTable } from "../../../react/hooks/useUrlTable";
|
|
5
|
+
import { Div, ListingFilterDrawer, ListingToolbar, Pagination, Row, Span, Stack, StickyToolbar, Text, } from "../../../ui";
|
|
6
|
+
import { ReviewCard } from "./ReviewsList";
|
|
7
|
+
import { ReviewFilters, REVIEW_PUBLIC_SORT_OPTIONS } from "./ReviewFilters";
|
|
8
|
+
import { useReviews } from "../hooks/useReviews";
|
|
9
|
+
import { useStoreReviews } from "../../stores/hooks/useStores";
|
|
10
|
+
import { TABLE_KEYS, VIEW_MODE } from "../../../constants/table-keys";
|
|
11
|
+
import { sortBy } from "../../../constants/sort";
|
|
12
|
+
import { REVIEW_FIELDS } from "../../../constants/field-names";
|
|
13
|
+
import { REVIEWS_DETAIL_PAGE_SIZE, REVIEWS_PAGE_SIZE, } from "../../../_internal/shared/features/reviews/config";
|
|
14
|
+
const __P = {
|
|
15
|
+
p4: "p-[var(--appkit-space-4)]",
|
|
16
|
+
};
|
|
17
|
+
const __O = {
|
|
18
|
+
hidden: "overflow-hidden",
|
|
19
|
+
};
|
|
20
|
+
const DEFAULT_SORT = sortBy(REVIEW_FIELDS.CREATED_AT);
|
|
21
|
+
const SORT_OPTION_LABELS = {
|
|
22
|
+
sortNewest: "Newest First",
|
|
23
|
+
sortOldest: "Oldest First",
|
|
24
|
+
sortHighestRated: "Highest Rated",
|
|
25
|
+
sortLowestRated: "Lowest Rated",
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Filter keys per source — these are the filters the panel actually applies, and
|
|
29
|
+
* `ReviewFilters` is handed the same list so the drawer can never render a control
|
|
30
|
+
* that silently does nothing.
|
|
31
|
+
*
|
|
32
|
+
* `product` drops the helpful-vote range: that is a Firestore inequality, and an
|
|
33
|
+
* inequality filter forces the first `orderBy` onto the same field, which would break
|
|
34
|
+
* every sort this panel offers. `hasImages` is only offered on `store`, whose route
|
|
35
|
+
* filters in memory — the `productId`-scoped query has no index for it.
|
|
36
|
+
*/
|
|
37
|
+
const FILTER_KEYS_BY_KIND = {
|
|
38
|
+
product: [TABLE_KEYS.RATING, TABLE_KEYS.DATE_FROM, TABLE_KEYS.DATE_TO],
|
|
39
|
+
store: [TABLE_KEYS.RATING, TABLE_KEYS.DATE_FROM, TABLE_KEYS.DATE_TO, "hasImages"],
|
|
40
|
+
all: [TABLE_KEYS.RATING, TABLE_KEYS.DATE_FROM, TABLE_KEYS.DATE_TO, "hasImages"],
|
|
41
|
+
};
|
|
42
|
+
/** Sorts that keep `createdAt` as the first ordering field. */
|
|
43
|
+
const CREATED_AT_SORTS = [
|
|
44
|
+
sortBy(REVIEW_FIELDS.CREATED_AT),
|
|
45
|
+
sortBy(REVIEW_FIELDS.CREATED_AT, "ASC"),
|
|
46
|
+
];
|
|
47
|
+
export function ReviewsListingPanel({ source, pageSize, context = "general", stateMode = "url", initialData, showSummary = true, showSearch, variant = "public", emptyLabel = "No reviews found.", className = "", }) {
|
|
48
|
+
const filterKeys = FILTER_KEYS_BY_KIND[source.kind];
|
|
49
|
+
const effectivePageSize = pageSize ?? (stateMode === "local" ? REVIEWS_DETAIL_PAGE_SIZE : REVIEWS_PAGE_SIZE);
|
|
50
|
+
const searchEnabled = showSearch ?? source.kind !== "product";
|
|
51
|
+
// --- state backing: URL or local ------------------------------------------
|
|
52
|
+
const urlTable = useUrlTable({
|
|
53
|
+
defaults: { pageSize: String(effectivePageSize), sort: DEFAULT_SORT },
|
|
54
|
+
});
|
|
55
|
+
const [localState, setLocalState] = useState({});
|
|
56
|
+
const localTable = useMemo(() => {
|
|
57
|
+
const get = (key) => localState[key] ?? "";
|
|
58
|
+
const setMany = (updates) => setLocalState((prev) => ({ ...prev, ...updates }));
|
|
59
|
+
return {
|
|
60
|
+
get,
|
|
61
|
+
getNumber: (key, fallback = 0) => {
|
|
62
|
+
const raw = localState[key];
|
|
63
|
+
if (!raw)
|
|
64
|
+
return fallback;
|
|
65
|
+
const n = Number(raw);
|
|
66
|
+
return Number.isNaN(n) ? fallback : n;
|
|
67
|
+
},
|
|
68
|
+
// Mirrors useUrlTable's NON_RESETTING_KEYS behaviour so paging semantics match
|
|
69
|
+
// between the two state modes.
|
|
70
|
+
set: (key, value) => setLocalState((prev) => ({
|
|
71
|
+
...prev,
|
|
72
|
+
[key]: value,
|
|
73
|
+
...(key === TABLE_KEYS.PAGE ||
|
|
74
|
+
key === TABLE_KEYS.PAGE_SIZE ||
|
|
75
|
+
key === TABLE_KEYS.VIEW
|
|
76
|
+
? {}
|
|
77
|
+
: { [TABLE_KEYS.PAGE]: "1" }),
|
|
78
|
+
})),
|
|
79
|
+
setMany,
|
|
80
|
+
clear: (keys) => {
|
|
81
|
+
const ks = keys ?? filterKeys;
|
|
82
|
+
setMany(Object.fromEntries(ks.map((k) => [k, ""])));
|
|
83
|
+
},
|
|
84
|
+
setPage: (page) => setLocalState((prev) => ({ ...prev, [TABLE_KEYS.PAGE]: String(page) })),
|
|
85
|
+
setPageSize: (_) => { },
|
|
86
|
+
setSort: (value) => setLocalState((prev) => ({ ...prev, [TABLE_KEYS.SORT]: value, [TABLE_KEYS.PAGE]: "1" })),
|
|
87
|
+
buildSieveParams: () => "",
|
|
88
|
+
buildSearchParams: () => "",
|
|
89
|
+
params: new URLSearchParams(),
|
|
90
|
+
};
|
|
91
|
+
}, [localState, filterKeys]);
|
|
92
|
+
const table = stateMode === "url" ? urlTable : localTable;
|
|
93
|
+
const [searchInput, setSearchInput] = useState(() => table.get(TABLE_KEYS.QUERY) || "");
|
|
94
|
+
const [filterOpen, setFilterOpen] = useState(false);
|
|
95
|
+
const [view, setView] = useState(table.get(TABLE_KEYS.VIEW) || VIEW_MODE.GRID);
|
|
96
|
+
const sort = table.get(TABLE_KEYS.SORT) || DEFAULT_SORT;
|
|
97
|
+
const currentPage = table.getNumber(TABLE_KEYS.PAGE, 1);
|
|
98
|
+
const query = table.get(TABLE_KEYS.QUERY) || undefined;
|
|
99
|
+
const ratingRaw = table.get(TABLE_KEYS.RATING);
|
|
100
|
+
const dateFrom = table.get(TABLE_KEYS.DATE_FROM) || undefined;
|
|
101
|
+
const dateTo = table.get(TABLE_KEYS.DATE_TO) || undefined;
|
|
102
|
+
const hasImages = table.get("hasImages") === "true" ? true : undefined;
|
|
103
|
+
const isStore = source.kind === "store";
|
|
104
|
+
/**
|
|
105
|
+
* A date range is a Firestore inequality on `createdAt`, and Firestore requires the
|
|
106
|
+
* first `orderBy` to be that same field — so "Highest/Lowest rated" cannot be served
|
|
107
|
+
* while a date range is active. Withdraw those options and fall back to newest-first
|
|
108
|
+
* instead of letting the query throw FAILED_PRECONDITION. Store-sourced reviews are
|
|
109
|
+
* sorted in memory by their route, so they are exempt.
|
|
110
|
+
*/
|
|
111
|
+
const dateRangeActive = !isStore && (!!dateFrom || !!dateTo);
|
|
112
|
+
const effectiveSort = dateRangeActive && !CREATED_AT_SORTS.includes(sort) ? DEFAULT_SORT : sort;
|
|
113
|
+
const activeFilterCount = filterKeys.filter((k) => !!table.get(k)).length;
|
|
114
|
+
const hasActiveState = !!query || sort !== DEFAULT_SORT || activeFilterCount > 0;
|
|
115
|
+
/**
|
|
116
|
+
* `useReviews` pins `staleTime: Infinity` whenever `initialData` is present, and its
|
|
117
|
+
* query key includes the full query string — so handing SSR page-1 rows to a filtered
|
|
118
|
+
* or later page would freeze the wrong rows into that key forever (Root Cause #30).
|
|
119
|
+
* Only seed the exact query the SSR builder actually ran.
|
|
120
|
+
*/
|
|
121
|
+
const isDefaultQuery = currentPage === 1 && sort === DEFAULT_SORT && activeFilterCount === 0 && !query;
|
|
122
|
+
const seed = isDefaultQuery ? initialData : undefined;
|
|
123
|
+
const handleViewToggle = (next) => {
|
|
124
|
+
if (next === VIEW_MODE.TABLE)
|
|
125
|
+
return;
|
|
126
|
+
setView(next);
|
|
127
|
+
table.set(TABLE_KEYS.VIEW, next);
|
|
128
|
+
};
|
|
129
|
+
// --- pending (buffered) filter state for the drawer -----------------------
|
|
130
|
+
const [pendingFilters, setPendingFilters] = useState(() => Object.fromEntries(filterKeys.map((k) => [k, table.get(k)])));
|
|
131
|
+
const pendingTable = useMemo(() => ({
|
|
132
|
+
get: (key) => pendingFilters[key] ?? "",
|
|
133
|
+
getNumber: (key, fallback = 0) => {
|
|
134
|
+
const raw = pendingFilters[key];
|
|
135
|
+
if (!raw)
|
|
136
|
+
return fallback;
|
|
137
|
+
const n = Number(raw);
|
|
138
|
+
return Number.isNaN(n) ? fallback : n;
|
|
139
|
+
},
|
|
140
|
+
set: (key, value) => setPendingFilters((p) => ({ ...p, [key]: value })),
|
|
141
|
+
setMany: (updates) => setPendingFilters((p) => ({ ...p, ...updates })),
|
|
142
|
+
clear: (keys) => {
|
|
143
|
+
const ks = keys ?? filterKeys;
|
|
144
|
+
setPendingFilters((p) => ({
|
|
145
|
+
...p,
|
|
146
|
+
...Object.fromEntries(ks.map((k) => [k, ""])),
|
|
147
|
+
}));
|
|
148
|
+
},
|
|
149
|
+
setPage: (_) => { },
|
|
150
|
+
setPageSize: (_) => { },
|
|
151
|
+
setSort: (_) => { },
|
|
152
|
+
buildSieveParams: () => "",
|
|
153
|
+
buildSearchParams: () => "",
|
|
154
|
+
params: new URLSearchParams(),
|
|
155
|
+
}), [pendingFilters, filterKeys]);
|
|
156
|
+
const openFilters = useCallback(() => {
|
|
157
|
+
setPendingFilters(Object.fromEntries(filterKeys.map((k) => [k, table.get(k)])));
|
|
158
|
+
setFilterOpen(true);
|
|
159
|
+
}, [table, filterKeys]);
|
|
160
|
+
const applyFilters = useCallback(() => {
|
|
161
|
+
const updates = { [TABLE_KEYS.PAGE]: "1" };
|
|
162
|
+
for (const k of filterKeys)
|
|
163
|
+
updates[k] = pendingFilters[k] ?? "";
|
|
164
|
+
table.setMany(updates);
|
|
165
|
+
setFilterOpen(false);
|
|
166
|
+
}, [pendingFilters, table, filterKeys]);
|
|
167
|
+
const clearFilters = useCallback(() => {
|
|
168
|
+
setPendingFilters(Object.fromEntries(filterKeys.map((k) => [k, ""])));
|
|
169
|
+
}, [filterKeys]);
|
|
170
|
+
const resetAll = useCallback(() => {
|
|
171
|
+
const updates = {
|
|
172
|
+
[TABLE_KEYS.QUERY]: "",
|
|
173
|
+
[TABLE_KEYS.SORT]: "",
|
|
174
|
+
[TABLE_KEYS.PAGE]: "1",
|
|
175
|
+
};
|
|
176
|
+
for (const k of filterKeys)
|
|
177
|
+
updates[k] = "";
|
|
178
|
+
table.setMany(updates);
|
|
179
|
+
setSearchInput("");
|
|
180
|
+
}, [table, filterKeys]);
|
|
181
|
+
const commitSearch = useCallback(() => {
|
|
182
|
+
table.set(TABLE_KEYS.QUERY, searchInput.trim());
|
|
183
|
+
}, [searchInput, table]);
|
|
184
|
+
// --- data -----------------------------------------------------------------
|
|
185
|
+
// Both hooks are always called (hooks can't be conditional); the inactive one is
|
|
186
|
+
// disabled, so react-query never fires its request.
|
|
187
|
+
const reviewsQuery = useReviews({
|
|
188
|
+
productId: source.kind === "product" ? source.productId : undefined,
|
|
189
|
+
status: source.kind === "product" ? "approved" : undefined,
|
|
190
|
+
q: query,
|
|
191
|
+
rating: ratingRaw || undefined,
|
|
192
|
+
dateFrom,
|
|
193
|
+
dateTo,
|
|
194
|
+
minVotes: table.get("minVotes") ? Number(table.get("minVotes")) : undefined,
|
|
195
|
+
maxVotes: table.get("maxVotes") ? Number(table.get("maxVotes")) : undefined,
|
|
196
|
+
hasImages,
|
|
197
|
+
sort: effectiveSort,
|
|
198
|
+
page: currentPage,
|
|
199
|
+
pageSize: effectivePageSize,
|
|
200
|
+
}, { initialData: seed, enabled: !isStore });
|
|
201
|
+
const storeQuery = useStoreReviews(isStore ? source.storeSlug : "", {
|
|
202
|
+
rating: ratingRaw ? Number(ratingRaw.split("|")[0]) : undefined,
|
|
203
|
+
page: currentPage,
|
|
204
|
+
pageSize: effectivePageSize,
|
|
205
|
+
sort,
|
|
206
|
+
q: query,
|
|
207
|
+
dateFrom,
|
|
208
|
+
dateTo,
|
|
209
|
+
hasImages,
|
|
210
|
+
}, { enabled: isStore });
|
|
211
|
+
const reviews = (isStore ? storeQuery.reviews : reviewsQuery.reviews);
|
|
212
|
+
const totalPages = isStore ? storeQuery.totalPages : reviewsQuery.totalPages;
|
|
213
|
+
const isLoading = isStore ? storeQuery.isLoading : reviewsQuery.isLoading;
|
|
214
|
+
const averageRating = isStore ? storeQuery.averageRating : reviewsQuery.averageRating;
|
|
215
|
+
const totalReviews = isStore
|
|
216
|
+
? storeQuery.totalReviews
|
|
217
|
+
: (reviewsQuery.totalApproved ?? reviewsQuery.total);
|
|
218
|
+
const sortOptions = REVIEW_PUBLIC_SORT_OPTIONS.filter((opt) => !dateRangeActive || CREATED_AT_SORTS.includes(opt.value)).map((opt) => ({
|
|
219
|
+
value: opt.value,
|
|
220
|
+
label: SORT_OPTION_LABELS[opt.key] ?? opt.key,
|
|
221
|
+
}));
|
|
222
|
+
const paginationBlock = totalPages > 1 ? (_jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: currentPage, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) })) : null;
|
|
223
|
+
return (_jsxs(Div, { className: className, children: [showSummary && (totalReviews ?? 0) > 0 && (_jsxs(Row, { border: "default", align: "center", gap: "sm", className: "border-b", padding: "inline", children: [_jsx(Span, { weight: "bold", size: "2xl", color: "primary", children: (averageRating ?? 0).toFixed(1) }), _jsxs(Span, { size: "sm", color: "muted", children: ["/ 5 \u00B7 ", totalReviews, " review", totalReviews === 1 ? "" : "s"] })] })), _jsx(ListingToolbar, { filterCount: activeFilterCount, onFiltersClick: openFilters, searchValue: searchEnabled ? searchInput : undefined, searchPlaceholder: "Search reviews by product name...", onSearchChange: searchEnabled ? setSearchInput : undefined, onSearchCommit: searchEnabled ? commitSearch : undefined, sortValue: effectiveSort, sortOptions: sortOptions, onSortChange: (v) => {
|
|
224
|
+
table.set(TABLE_KEYS.SORT, v);
|
|
225
|
+
}, view: view, onViewChange: handleViewToggle, onResetAll: resetAll, hasActiveState: hasActiveState }), stateMode === "url" && paginationBlock && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: paginationBlock })), _jsx(Div, { padding: "y-lg", children: isLoading ? (_jsx(Div, { layout: "grid", gap: "6", className: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3", children: Array.from({ length: 6 }).map((_, i) => (_jsx(Div, { className: `${__O.hidden} animate-pulse`, border: "subtle", rounded: "xl", children: _jsxs(Stack, { className: `${__P.p4}`, gap: "3", children: [_jsx(Div, { className: "h-4 w-3/4", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-full", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-2/3", surface: "subtle", rounded: "default" })] }) }, i))) })) : reviews.length === 0 ? (_jsx(Text, { paddingY: "3xl", color: "muted", size: "sm", align: "start", children: emptyLabel })) : view === "list" ? (_jsx(Stack, { className: "divide-y divide-zinc-100 divide-[var(--appkit-color-border-subtle)]", border: "subtle", rounded: "xl", children: reviews.map((review) => (_jsx(ReviewCard, { review: review, context: context }, review.id))) })) : (_jsx(Div, { layout: "grid", gap: "6", className: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3", children: reviews.map((review) => (_jsx(ReviewCard, { review: review, context: context }, review.id))) })) }), stateMode === "local" && paginationBlock, _jsx(ListingFilterDrawer, { open: filterOpen, onClose: () => setFilterOpen(false), onApply: applyFilters, onClear: clearFilters, activeCount: activeFilterCount, children: _jsx(ReviewFilters, { table: pendingTable, variant: variant, keys: filterKeys }) })] }));
|
|
226
|
+
}
|
|
@@ -8,4 +8,6 @@ export type { ViewReviewModalProps } from "./ReviewModal";
|
|
|
8
8
|
export { ViewReviewModal } from "./ReviewModal";
|
|
9
9
|
export { ReviewsIndexListing } from "./ReviewsIndexListing";
|
|
10
10
|
export type { ReviewsIndexListingProps } from "./ReviewsIndexListing";
|
|
11
|
+
export { ReviewsListingPanel } from "./ReviewsListingPanel";
|
|
12
|
+
export type { ReviewsListingPanelProps, ReviewsSource } from "./ReviewsListingPanel";
|
|
11
13
|
export type { ReviewDetailPageViewProps } from "./ReviewDetailPageView";
|
|
@@ -3,3 +3,4 @@ export { ReviewFilters, REVIEW_SORT_OPTIONS, REVIEW_FILTER_KEYS, REVIEW_ADMIN_SO
|
|
|
3
3
|
export { ReviewSummary } from "./ReviewSummary";
|
|
4
4
|
export { ViewReviewModal } from "./ReviewModal";
|
|
5
5
|
export { ReviewsIndexListing } from "./ReviewsIndexListing";
|
|
6
|
+
export { ReviewsListingPanel } from "./ReviewsListingPanel";
|
|
@@ -12,6 +12,7 @@ export declare function useReviews(params?: ReviewListParams, opts?: UseReviewsO
|
|
|
12
12
|
hasMore: boolean;
|
|
13
13
|
averageRating: number | undefined;
|
|
14
14
|
ratingDistribution: Record<number, number> | undefined;
|
|
15
|
+
totalApproved: number | undefined;
|
|
15
16
|
isLoading: boolean;
|
|
16
17
|
error: Error | null;
|
|
17
18
|
refetch: (options?: import("@tanstack/query-core").RefetchOptions) => Promise<import("@tanstack/query-core").QueryObserverResult<NoInfer<ReviewListResponse>, Error>>;
|
|
@@ -19,7 +20,13 @@ export declare function useReviews(params?: ReviewListParams, opts?: UseReviewsO
|
|
|
19
20
|
interface UseProductReviewsOptions {
|
|
20
21
|
page?: number;
|
|
21
22
|
pageSize?: number;
|
|
23
|
+
sort?: string;
|
|
24
|
+
rating?: number | string;
|
|
25
|
+
dateFrom?: string;
|
|
26
|
+
dateTo?: string;
|
|
27
|
+
hasImages?: boolean;
|
|
22
28
|
enabled?: boolean;
|
|
29
|
+
initialData?: ReviewListResponse;
|
|
23
30
|
}
|
|
24
31
|
export declare function useProductReviews(productId: string, opts?: UseProductReviewsOptions): {
|
|
25
32
|
reviews: import("..").Review[];
|
|
@@ -28,6 +35,7 @@ export declare function useProductReviews(productId: string, opts?: UseProductRe
|
|
|
28
35
|
hasMore: boolean;
|
|
29
36
|
averageRating: number | undefined;
|
|
30
37
|
ratingDistribution: Record<number, number> | undefined;
|
|
38
|
+
totalApproved: number | undefined;
|
|
31
39
|
isLoading: boolean;
|
|
32
40
|
error: Error | null;
|
|
33
41
|
refetch: (options?: import("@tanstack/query-core").RefetchOptions) => Promise<import("@tanstack/query-core").QueryObserverResult<NoInfer<ReviewListResponse>, Error>>;
|
|
@@ -15,8 +15,8 @@ export function useReviews(params = {}, opts) {
|
|
|
15
15
|
sp.set("featured", String(params.featured));
|
|
16
16
|
if (params.page)
|
|
17
17
|
sp.set("page", String(params.page));
|
|
18
|
-
if (params.
|
|
19
|
-
sp.set("
|
|
18
|
+
if (params.pageSize)
|
|
19
|
+
sp.set("pageSize", String(params.pageSize));
|
|
20
20
|
if (params.sort)
|
|
21
21
|
sp.set("sort", params.sort);
|
|
22
22
|
if (params.q)
|
|
@@ -31,8 +31,10 @@ export function useReviews(params = {}, opts) {
|
|
|
31
31
|
sp.set("maxVotes", String(params.maxVotes));
|
|
32
32
|
if (params.hasImages)
|
|
33
33
|
sp.set("hasImages", "true");
|
|
34
|
-
// General listing mode (no productId)
|
|
35
|
-
|
|
34
|
+
// General listing mode (no productId). Store-scoped reads go through
|
|
35
|
+
// useStoreReviews / `/api/stores/[slug]/reviews`, not this endpoint — `/api/reviews`
|
|
36
|
+
// has no storeId branch, so a storeId here would 400 on the productId guard.
|
|
37
|
+
if (!params.productId && !params.userId) {
|
|
36
38
|
sp.set("latest", "true");
|
|
37
39
|
}
|
|
38
40
|
const qs = sp.toString();
|
|
@@ -50,6 +52,7 @@ export function useReviews(params = {}, opts) {
|
|
|
50
52
|
hasMore: query.data?.hasMore ?? false,
|
|
51
53
|
averageRating: query.data?.averageRating,
|
|
52
54
|
ratingDistribution: query.data?.ratingDistribution,
|
|
55
|
+
totalApproved: query.data?.totalApproved,
|
|
53
56
|
isLoading: query.isLoading,
|
|
54
57
|
error: query.error,
|
|
55
58
|
refetch: query.refetch,
|
|
@@ -60,6 +63,11 @@ export function useProductReviews(productId, opts) {
|
|
|
60
63
|
productId,
|
|
61
64
|
status: "approved",
|
|
62
65
|
page: opts?.page,
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
pageSize: opts?.pageSize,
|
|
67
|
+
sort: opts?.sort,
|
|
68
|
+
rating: opts?.rating,
|
|
69
|
+
dateFrom: opts?.dateFrom,
|
|
70
|
+
dateTo: opts?.dateTo,
|
|
71
|
+
hasImages: opts?.hasImages,
|
|
72
|
+
}, { enabled: opts?.enabled, initialData: opts?.initialData });
|
|
65
73
|
}
|
|
@@ -14,7 +14,16 @@ declare class ReviewRepository extends BaseRepository<ReviewDocument> {
|
|
|
14
14
|
create(input: ReviewCreateInput): Promise<ReviewDocument>;
|
|
15
15
|
update(reviewId: string, data: Partial<ReviewDocument>): Promise<ReviewDocument>;
|
|
16
16
|
findByProduct(productId: string): Promise<ReviewDocument[]>;
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Approved reviews for a product, newest first.
|
|
19
|
+
*
|
|
20
|
+
* `limit` is optional and deliberately has no default — callers that page or aggregate
|
|
21
|
+
* should use `listForProduct` / `getApprovedRatingSummary` instead, and the remaining
|
|
22
|
+
* callers each pass their own explicit bound. Silently capping here would truncate
|
|
23
|
+
* consumers that expect the full set (Rule #6 wants the bound at the call site, where
|
|
24
|
+
* it can be reasoned about).
|
|
25
|
+
*/
|
|
26
|
+
findApprovedByProduct(productId: string, limit?: number): Promise<ReviewDocument[]>;
|
|
18
27
|
findApprovedByStore(storeId: string): Promise<ReviewDocument[]>;
|
|
19
28
|
findByUser(userId: string): Promise<ReviewDocument[]>;
|
|
20
29
|
findApprovedByUser(userId: string): Promise<ReviewDocument[]>;
|
|
@@ -25,10 +34,25 @@ declare class ReviewRepository extends BaseRepository<ReviewDocument> {
|
|
|
25
34
|
reject(reviewId: string, moderatorId: string, rejectionReason: string, moderatorNote?: string): Promise<ReviewDocument>;
|
|
26
35
|
incrementHelpful(reviewId: string): Promise<void>;
|
|
27
36
|
incrementReportCount(reviewId: string): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Approved-review count, average and per-star distribution for one product.
|
|
39
|
+
*
|
|
40
|
+
* Built from Firestore *aggregation* queries (one `count()` per star bucket, run in
|
|
41
|
+
* parallel) rather than reading the documents. Ratings are `z.number().int().min(1).max(5)`
|
|
42
|
+
* at the write path, so the five buckets are exhaustive and the average derived from them
|
|
43
|
+
* is exact. This replaces an unbounded `findApprovedByProduct()` scan that used to sit on
|
|
44
|
+
* the hot path of every product render and every `/api/reviews?productId=` call (Rule #6).
|
|
45
|
+
*/
|
|
46
|
+
getApprovedRatingSummary(productId: string): Promise<{
|
|
47
|
+
total: number;
|
|
48
|
+
averageRating: number;
|
|
49
|
+
ratingDistribution: Record<number, number>;
|
|
50
|
+
}>;
|
|
28
51
|
getAverageRating(productId: string): Promise<number>;
|
|
29
52
|
getRatingDistribution(productId: string): Promise<Record<number, number>>;
|
|
30
53
|
/**
|
|
31
54
|
* Cloud Functions compatibility: approved review count + average by product.
|
|
55
|
+
* Return shape is load-bearing for the Functions callers — do not change it.
|
|
32
56
|
*/
|
|
33
57
|
getApprovedRatingAggregate(productId: string): Promise<ReviewRatingAggregate>;
|
|
34
58
|
/**
|
|
@@ -92,6 +116,10 @@ declare class ReviewRepository extends BaseRepository<ReviewDocument> {
|
|
|
92
116
|
canFilter: boolean;
|
|
93
117
|
canSort: boolean;
|
|
94
118
|
};
|
|
119
|
+
hasImages: {
|
|
120
|
+
canFilter: boolean;
|
|
121
|
+
canSort: boolean;
|
|
122
|
+
};
|
|
95
123
|
helpfulCount: {
|
|
96
124
|
canFilter: boolean;
|
|
97
125
|
canSort: boolean;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
|
|
1
|
+
import { BaseRepository, getFirestoreCount, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
|
|
2
|
+
import { REVIEW_MAX_RATING, REVIEW_MIN_RATING } from "../../../_internal/shared/features/reviews/config";
|
|
2
3
|
import { addPiiIndices, decryptPiiFields, encryptPiiFields, REVIEW_PII_FIELDS, REVIEW_PII_INDEX_MAP, } from "../../../security";
|
|
3
4
|
import { createReviewId, REVIEW_COLLECTION, } from "../schemas";
|
|
4
5
|
const REVIEW_FIELDS = {
|
|
@@ -10,6 +11,7 @@ const REVIEW_FIELDS = {
|
|
|
10
11
|
CREATED_AT: "createdAt",
|
|
11
12
|
REVIEWEE_ID: "revieweeId",
|
|
12
13
|
REVIEWER_ROLE: "reviewerRole",
|
|
14
|
+
RATING: "rating",
|
|
13
15
|
};
|
|
14
16
|
class ReviewRepository extends BaseRepository {
|
|
15
17
|
constructor() {
|
|
@@ -64,13 +66,23 @@ class ReviewRepository extends BaseRepository {
|
|
|
64
66
|
async findByProduct(productId) {
|
|
65
67
|
return this.findBy(REVIEW_FIELDS.PRODUCT_ID, productId);
|
|
66
68
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Approved reviews for a product, newest first.
|
|
71
|
+
*
|
|
72
|
+
* `limit` is optional and deliberately has no default — callers that page or aggregate
|
|
73
|
+
* should use `listForProduct` / `getApprovedRatingSummary` instead, and the remaining
|
|
74
|
+
* callers each pass their own explicit bound. Silently capping here would truncate
|
|
75
|
+
* consumers that expect the full set (Rule #6 wants the bound at the call site, where
|
|
76
|
+
* it can be reasoned about).
|
|
77
|
+
*/
|
|
78
|
+
async findApprovedByProduct(productId, limit) {
|
|
79
|
+
let query = this.getCollection()
|
|
70
80
|
.where(REVIEW_FIELDS.PRODUCT_ID, "==", productId)
|
|
71
81
|
.where(REVIEW_FIELDS.STATUS, "==", "approved")
|
|
72
|
-
.orderBy(REVIEW_FIELDS.CREATED_AT, "desc")
|
|
73
|
-
|
|
82
|
+
.orderBy(REVIEW_FIELDS.CREATED_AT, "desc");
|
|
83
|
+
if (limit !== undefined)
|
|
84
|
+
query = query.limit(limit);
|
|
85
|
+
const snapshot = await query.get();
|
|
74
86
|
return snapshot.docs.map((doc) => this.mapDoc(doc));
|
|
75
87
|
}
|
|
76
88
|
async findApprovedByStore(storeId) {
|
|
@@ -147,57 +159,77 @@ class ReviewRepository extends BaseRepository {
|
|
|
147
159
|
});
|
|
148
160
|
}
|
|
149
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Approved-review count, average and per-star distribution for one product.
|
|
164
|
+
*
|
|
165
|
+
* Built from Firestore *aggregation* queries (one `count()` per star bucket, run in
|
|
166
|
+
* parallel) rather than reading the documents. Ratings are `z.number().int().min(1).max(5)`
|
|
167
|
+
* at the write path, so the five buckets are exhaustive and the average derived from them
|
|
168
|
+
* is exact. This replaces an unbounded `findApprovedByProduct()` scan that used to sit on
|
|
169
|
+
* the hot path of every product render and every `/api/reviews?productId=` call (Rule #6).
|
|
170
|
+
*/
|
|
171
|
+
async getApprovedRatingSummary(productId) {
|
|
172
|
+
const base = this.getCollection()
|
|
173
|
+
.where(REVIEW_FIELDS.PRODUCT_ID, "==", productId)
|
|
174
|
+
.where(REVIEW_FIELDS.STATUS, "==", "approved");
|
|
175
|
+
const buckets = [];
|
|
176
|
+
for (let r = REVIEW_MIN_RATING; r <= REVIEW_MAX_RATING; r++)
|
|
177
|
+
buckets.push(r);
|
|
178
|
+
const counts = await Promise.all(buckets.map((rating) => getFirestoreCount(base.where(REVIEW_FIELDS.RATING, "==", rating))));
|
|
179
|
+
const ratingDistribution = {};
|
|
180
|
+
let total = 0;
|
|
181
|
+
let weighted = 0;
|
|
182
|
+
buckets.forEach((rating, i) => {
|
|
183
|
+
const count = counts[i] ?? 0;
|
|
184
|
+
ratingDistribution[rating] = count;
|
|
185
|
+
total += count;
|
|
186
|
+
weighted += count * rating;
|
|
187
|
+
});
|
|
188
|
+
return {
|
|
189
|
+
total,
|
|
190
|
+
averageRating: total > 0 ? weighted / total : 0,
|
|
191
|
+
ratingDistribution,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
150
194
|
async getAverageRating(productId) {
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
return 0;
|
|
154
|
-
const sum = reviews.reduce((acc, review) => acc + review.rating, 0);
|
|
155
|
-
return sum / reviews.length;
|
|
195
|
+
const { averageRating } = await this.getApprovedRatingSummary(productId);
|
|
196
|
+
return averageRating;
|
|
156
197
|
}
|
|
157
198
|
async getRatingDistribution(productId) {
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
1: 0,
|
|
161
|
-
2: 0,
|
|
162
|
-
3: 0,
|
|
163
|
-
4: 0,
|
|
164
|
-
5: 0,
|
|
165
|
-
};
|
|
166
|
-
reviews.forEach((review) => {
|
|
167
|
-
distribution[review.rating]++;
|
|
168
|
-
});
|
|
169
|
-
return distribution;
|
|
199
|
+
const { ratingDistribution } = await this.getApprovedRatingSummary(productId);
|
|
200
|
+
return ratingDistribution;
|
|
170
201
|
}
|
|
171
202
|
/**
|
|
172
203
|
* Cloud Functions compatibility: approved review count + average by product.
|
|
204
|
+
* Return shape is load-bearing for the Functions callers — do not change it.
|
|
173
205
|
*/
|
|
174
206
|
async getApprovedRatingAggregate(productId) {
|
|
175
|
-
const
|
|
176
|
-
if (
|
|
207
|
+
const { total, averageRating } = await this.getApprovedRatingSummary(productId);
|
|
208
|
+
if (total === 0)
|
|
177
209
|
return { count: 0, avgRating: 0 };
|
|
178
|
-
}
|
|
179
|
-
const sum = reviews.reduce((acc, review) => acc + review.rating, 0);
|
|
180
|
-
const count = reviews.length;
|
|
181
|
-
const avgRating = Math.round((sum / count) * 10) / 10;
|
|
182
|
-
return { count, avgRating };
|
|
210
|
+
return { count: total, avgRating: Math.round(averageRating * 10) / 10 };
|
|
183
211
|
}
|
|
184
212
|
/**
|
|
185
213
|
* Cloud Functions compatibility: approved review count + average by store.
|
|
186
214
|
*/
|
|
187
215
|
async getApprovedRatingAggregateByStore(storeId) {
|
|
188
|
-
|
|
189
|
-
|
|
216
|
+
// Same aggregation-query approach as getApprovedRatingSummary — no document reads.
|
|
217
|
+
const base = this.getCollection()
|
|
190
218
|
.where(REVIEW_FIELDS.STORE_ID, "==", storeId)
|
|
191
|
-
.where(REVIEW_FIELDS.STATUS, "==", "approved")
|
|
192
|
-
|
|
193
|
-
|
|
219
|
+
.where(REVIEW_FIELDS.STATUS, "==", "approved");
|
|
220
|
+
const buckets = [];
|
|
221
|
+
for (let r = REVIEW_MIN_RATING; r <= REVIEW_MAX_RATING; r++)
|
|
222
|
+
buckets.push(r);
|
|
223
|
+
const counts = await Promise.all(buckets.map((rating) => getFirestoreCount(base.where(REVIEW_FIELDS.RATING, "==", rating))));
|
|
224
|
+
let count = 0;
|
|
225
|
+
let weighted = 0;
|
|
226
|
+
buckets.forEach((rating, i) => {
|
|
227
|
+
count += counts[i] ?? 0;
|
|
228
|
+
weighted += (counts[i] ?? 0) * rating;
|
|
229
|
+
});
|
|
230
|
+
if (count === 0)
|
|
194
231
|
return { count: 0, avgRating: 0 };
|
|
195
|
-
}
|
|
196
|
-
const reviews = snapshot.docs.map((doc) => this.mapDoc(doc));
|
|
197
|
-
const sum = reviews.reduce((acc, review) => acc + review.rating, 0);
|
|
198
|
-
const count = reviews.length;
|
|
199
|
-
const avgRating = Math.round((sum / count) * 10) / 10;
|
|
200
|
-
return { count, avgRating };
|
|
232
|
+
return { count, avgRating: Math.round((weighted / count) * 10) / 10 };
|
|
201
233
|
}
|
|
202
234
|
/** Find reviews where this user is the reviewee (seller→buyer reviews received by a buyer). */
|
|
203
235
|
async findByReviewee(revieweeId) {
|
|
@@ -259,6 +291,10 @@ ReviewRepository.SIEVE_FIELDS = {
|
|
|
259
291
|
status: { canFilter: true, canSort: true },
|
|
260
292
|
rating: { canFilter: true, canSort: true },
|
|
261
293
|
verified: { canFilter: true, canSort: false },
|
|
294
|
+
// Denormalised flag that exists specifically to make `hasImages==true` queryable —
|
|
295
|
+
// it was missing here, so the sieve whitelist silently dropped the clause and the
|
|
296
|
+
// "with photos only" filter did nothing.
|
|
297
|
+
hasImages: { canFilter: true, canSort: false },
|
|
262
298
|
helpfulCount: { canFilter: true, canSort: true },
|
|
263
299
|
featured: { canFilter: true, canSort: false },
|
|
264
300
|
reportCount: { canFilter: true, canSort: true },
|
|
@@ -43,6 +43,12 @@ export interface ReviewListResponse {
|
|
|
43
43
|
hasMore: boolean;
|
|
44
44
|
averageRating?: number;
|
|
45
45
|
ratingDistribution?: Record<number, number>;
|
|
46
|
+
/**
|
|
47
|
+
* Total approved reviews ignoring any active filters — `total` above is the filtered
|
|
48
|
+
* count and changes as the user narrows by rating/date, which makes it wrong for the
|
|
49
|
+
* "N reviews" summary line next to the average.
|
|
50
|
+
*/
|
|
51
|
+
totalApproved?: number;
|
|
46
52
|
}
|
|
47
53
|
export interface ReviewListParams {
|
|
48
54
|
productId?: string;
|
|
@@ -52,7 +58,8 @@ export interface ReviewListParams {
|
|
|
52
58
|
rating?: number | string;
|
|
53
59
|
featured?: boolean;
|
|
54
60
|
page?: number;
|
|
55
|
-
|
|
61
|
+
/** Must stay named `pageSize` — that is the query param `/api/reviews` actually reads. */
|
|
62
|
+
pageSize?: number;
|
|
56
63
|
sort?: string;
|
|
57
64
|
q?: string;
|
|
58
65
|
dateFrom?: string;
|
|
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { Badge, Button, Checkbox, Div, Grid, Row, Stack, Text, TextLink } from "../../../ui";
|
|
4
4
|
import { MediaImage } from "../../media/MediaImage";
|
|
5
5
|
import { ROW_ACTION_META, ROW_ACTION_ID } from "../../../features/products/constants/action-defs";
|
|
6
|
-
import {
|
|
6
|
+
import { listingBadgeVariant } from "../../products/utils/listing-badge-variant";
|
|
7
7
|
const __P = {
|
|
8
8
|
p3: "p-[var(--appkit-space-3)]",
|
|
9
9
|
};
|
|
@@ -25,6 +25,6 @@ export function SellerProductsCards({ view, rows, isLoading, listingKind, select
|
|
|
25
25
|
return (_jsxs(Container, { ...containerProps, children: [rows.length === 0 && !isLoading && (_jsx(Text, { className: "text-[var(--appkit-color-text-muted)] col-span-full", size: "sm", children: listingKind !== "all" ? `No ${listingKind} listings found` : "No products listed yet" })), rows.map((row) => {
|
|
26
26
|
const isSelected = selectedIds.has(row.id);
|
|
27
27
|
const href = buildHref(row);
|
|
28
|
-
return view === "grid" ? (_jsxs(Div, { className: "group relative bg-[var(--appkit-color-surface)] overflow-hidden", shadow: "hover-md", rounded: "xl", border: isSelected ? "strong" : "default", children: [_jsx(Div, { className: "absolute top-2 left-2 z-10", children: _jsx(Checkbox, { bare: true, checked: isSelected, onChange: () => toggle(row.id), className: "h-4 w-4 rounded border-[var(--appkit-color-border)]", onClick: (e) => e.stopPropagation(), "aria-label": "Select" }) }), _jsxs(TextLink, { href: href, className: "block", children: [_jsx(Div, { className: "w-full aspect-square", children: _jsx(MediaImage, { src: row.imageUrl, alt: row.primary, size: "card" }) }), _jsxs(Stack, { className: `${__P.p3}`, gap: "xs", children: [_jsx(Text, { className: "line-clamp-1", size: "sm", weight: "medium", children: row.primary }), _jsxs(Row, { gap: "sm", children: [_jsx(Badge, { variant:
|
|
28
|
+
return view === "grid" ? (_jsxs(Div, { className: "group relative bg-[var(--appkit-color-surface)] overflow-hidden", shadow: "hover-md", rounded: "xl", border: isSelected ? "strong" : "default", children: [_jsx(Div, { className: "absolute top-2 left-2 z-10", children: _jsx(Checkbox, { bare: true, checked: isSelected, onChange: () => toggle(row.id), className: "h-4 w-4 rounded border-[var(--appkit-color-border)]", onClick: (e) => e.stopPropagation(), "aria-label": "Select" }) }), _jsxs(TextLink, { href: href, className: "block", children: [_jsx(Div, { className: "w-full aspect-square", children: _jsx(MediaImage, { src: row.imageUrl, alt: row.primary, size: "card" }) }), _jsxs(Stack, { className: `${__P.p3}`, gap: "xs", children: [_jsx(Text, { className: "line-clamp-1", size: "sm", weight: "medium", children: row.primary }), _jsxs(Row, { gap: "sm", children: [_jsx(Badge, { variant: listingBadgeVariant(row.listingKind), children: row.listingKind }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)] line-clamp-1", size: "xs", children: row.secondary })] })] })] }), _jsxs(Row, { className: "border-t border-[var(--appkit-color-border)]", justify: "end", gap: "xs", padding: "xs", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); onEdit(row); }, "aria-label": "Edit", children: ROW_ACTION_META[ROW_ACTION_ID.EDIT].label }), _jsx(Button, { variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); onDuplicate(row); }, "aria-label": "Duplicate", children: ROW_ACTION_META[ROW_ACTION_ID.DUPLICATE].label }), onDelete && (_jsx(Button, { variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); onDelete(row); }, "aria-label": "Delete", children: ROW_ACTION_META[ROW_ACTION_ID.DELETE].label }))] })] }, row.id)) : (_jsxs(Row, { className: "bg-[var(--appkit-color-surface)] hover:bg-[var(--appkit-color-surface-raised)]", align: "center", gap: "3", rounded: "lg", padding: "inlineSm", border: isSelected ? "strong" : "default", children: [_jsx(Checkbox, { bare: true, checked: isSelected, onChange: () => toggle(row.id), className: "h-4 w-4 rounded border-[var(--appkit-color-border)]", "aria-label": "Select" }), _jsx(Div, { className: `w-12 h-12 ${__O.hidden}`, rounded: "default", children: _jsx(MediaImage, { src: row.imageUrl, alt: row.primary, size: "thumbnail" }) }), _jsxs(TextLink, { href: href, className: "flex-1 min-w-0", children: [_jsx(Text, { className: "line-clamp-1", size: "sm", weight: "medium", children: row.primary }), _jsxs(Row, { gap: "sm", children: [_jsx(Badge, { variant: listingBadgeVariant(row.listingKind), children: row.listingKind }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)] line-clamp-1", size: "xs", children: row.secondary })] })] }), _jsxs(Row, { className: "flex-shrink-0", gap: "xs", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => onEdit(row), "aria-label": "Edit", children: ROW_ACTION_META[ROW_ACTION_ID.EDIT].label }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => onDuplicate(row), "aria-label": "Duplicate", children: ROW_ACTION_META[ROW_ACTION_ID.DUPLICATE].label }), onDelete && (_jsx(Button, { variant: "ghost", size: "sm", onClick: () => onDelete(row), "aria-label": "Delete", children: ROW_ACTION_META[ROW_ACTION_ID.DELETE].label }))] })] }, row.id));
|
|
29
29
|
})] }));
|
|
30
30
|
}
|