@mohasinac/appkit 4.11.1 → 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.
Files changed (212) hide show
  1. package/dist/_internal/server/features/checkout/actions.d.ts +4 -0
  2. package/dist/_internal/server/features/checkout/actions.js +138 -27
  3. package/dist/_internal/server/features/checkout/data.d.ts +10 -0
  4. package/dist/_internal/server/features/checkout/locked-lines.d.ts +30 -0
  5. package/dist/_internal/server/features/checkout/locked-lines.js +116 -0
  6. package/dist/_internal/server/features/orders/adapters.js +7 -0
  7. package/dist/_internal/server/features/products/data.d.ts +12 -3
  8. package/dist/_internal/server/features/products/data.js +55 -4
  9. package/dist/_internal/server/features/products/index.d.ts +2 -1
  10. package/dist/_internal/server/features/products/index.js +2 -1
  11. package/dist/_internal/server/features/products/list-public.d.ts +130 -0
  12. package/dist/_internal/server/features/products/list-public.js +375 -0
  13. package/dist/_internal/server/features/reviews/data.d.ts +1 -1
  14. package/dist/_internal/server/features/reviews/data.js +3 -2
  15. package/dist/_internal/server/features/tester/visibility.js +3 -3
  16. package/dist/_internal/server/jobs/core/auctionSettlement.js +89 -16
  17. package/dist/_internal/server/jobs/core/offerExpiry.js +116 -4
  18. package/dist/_internal/server/jobs/core/testerSandboxCleanup.d.ts +1 -1
  19. package/dist/_internal/server/jobs/core/testerSandboxCleanup.js +4 -1
  20. package/dist/_internal/server/jobs/core/testerSandboxRefresh.js +2 -0
  21. package/dist/_internal/server/jobs/handlers/_helpers.js +4 -1
  22. package/dist/_internal/server/jobs/handlers/messages.d.ts +5 -0
  23. package/dist/_internal/server/jobs/handlers/messages.js +5 -0
  24. package/dist/_internal/shared/checkout/lanes.d.ts +41 -0
  25. package/dist/_internal/shared/checkout/lanes.js +106 -0
  26. package/dist/_internal/shared/checkout/order-math.d.ts +19 -0
  27. package/dist/_internal/shared/checkout/order-math.js +30 -6
  28. package/dist/_internal/shared/features/promotions/schema.d.ts +0 -16
  29. package/dist/_internal/shared/features/promotions/schema.js +0 -2
  30. package/dist/_internal/shared/features/reviews/config.d.ts +8 -1
  31. package/dist/_internal/shared/features/reviews/config.js +8 -1
  32. package/dist/_internal/shared/listing-types/_registry.d.ts +88 -1
  33. package/dist/_internal/shared/listing-types/_registry.js +63 -1
  34. package/dist/_internal/shared/listing-types/art/config.d.ts +59 -0
  35. package/dist/_internal/shared/listing-types/art/config.js +12 -0
  36. package/dist/_internal/shared/listing-types/auction/config.d.ts +53 -0
  37. package/dist/_internal/shared/listing-types/auction/config.js +17 -0
  38. package/dist/_internal/shared/listing-types/classified/config.d.ts +59 -0
  39. package/dist/_internal/shared/listing-types/classified/config.js +14 -0
  40. package/dist/_internal/shared/listing-types/digital-code/config.d.ts +59 -0
  41. package/dist/_internal/shared/listing-types/digital-code/config.js +12 -0
  42. package/dist/_internal/shared/listing-types/feature-flags.d.ts +1 -0
  43. package/dist/_internal/shared/listing-types/feature-flags.js +24 -10
  44. package/dist/_internal/shared/listing-types/live/config.d.ts +59 -0
  45. package/dist/_internal/shared/listing-types/live/config.js +14 -0
  46. package/dist/_internal/shared/listing-types/pre-order/config.d.ts +110 -0
  47. package/dist/_internal/shared/listing-types/pre-order/config.js +14 -0
  48. package/dist/_internal/shared/listing-types/prize-draw/config.d.ts +44 -0
  49. package/dist/_internal/shared/listing-types/prize-draw/config.js +10 -0
  50. package/dist/_internal/shared/listing-types/standard/config.d.ts +59 -0
  51. package/dist/_internal/shared/listing-types/standard/config.js +9 -0
  52. package/dist/_internal/shared/listing-types/stickers/config.d.ts +59 -0
  53. package/dist/_internal/shared/listing-types/stickers/config.js +12 -0
  54. package/dist/client.d.ts +7 -2
  55. package/dist/client.js +8 -2
  56. package/dist/constants/field-names.d.ts +12 -0
  57. package/dist/constants/field-names.js +12 -0
  58. package/dist/constants/table-keys.d.ts +2 -0
  59. package/dist/constants/table-keys.js +2 -0
  60. package/dist/features/account/components/NotificationBell.js +1 -0
  61. package/dist/features/account/components/UserOffersPanel.js +8 -1
  62. package/dist/features/account/components/UserOrdersView.js +27 -5
  63. package/dist/features/admin/actions/notification-actions.js +1 -1
  64. package/dist/features/admin/components/AdminAddressesView.js +3 -1
  65. package/dist/features/admin/components/AdminCouponEditorView.js +1 -4
  66. package/dist/features/admin/components/AdminPaymentMethodsView.js +3 -1
  67. package/dist/features/admin/components/AdminProductsView.js +34 -18
  68. package/dist/features/admin/constants/filter-tabs.d.ts +26 -9
  69. package/dist/features/admin/constants/filter-tabs.js +22 -9
  70. package/dist/features/admin/schemas/firestore.d.ts +2 -1
  71. package/dist/features/admin/schemas/firestore.js +1 -0
  72. package/dist/features/auctions/actions/bid-actions.d.ts +11 -1
  73. package/dist/features/auctions/actions/bid-actions.js +29 -15
  74. package/dist/features/auctions/components/AuctionBidsTable.js +6 -2
  75. package/dist/features/auctions/components/AuctionBottomActions.js +1 -0
  76. package/dist/features/auctions/components/AuctionDetailPageView.js +13 -11
  77. package/dist/features/auctions/components/AuctionsListView.js +12 -53
  78. package/dist/features/auctions/components/PlaceBidFormClient.js +10 -1
  79. package/dist/features/auctions/repository/bid.repository.d.ts +15 -0
  80. package/dist/features/auctions/repository/bid.repository.js +19 -0
  81. package/dist/features/auctions/schemas/firestore.d.ts +11 -1
  82. package/dist/features/auctions/schemas/firestore.js +1 -0
  83. package/dist/features/cart/actions/cart-actions.d.ts +11 -0
  84. package/dist/features/cart/actions/cart-actions.js +24 -0
  85. package/dist/features/cart/repository/cart.repository.d.ts +24 -1
  86. package/dist/features/cart/repository/cart.repository.js +71 -6
  87. package/dist/features/cart/schemas/firestore.d.ts +23 -4
  88. package/dist/features/categories/components/BrandDetailTabs.js +31 -11
  89. package/dist/features/categories/components/CategoryDetailTabs.js +27 -7
  90. package/dist/features/categories/components/CategoryProductsListing.d.ts +9 -1
  91. package/dist/features/categories/components/CategoryProductsListing.js +5 -2
  92. package/dist/features/classified/components/ClassifiedDetailPageView.js +2 -1
  93. package/dist/features/contact/email.js +56 -1
  94. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +17 -16
  95. package/dist/features/homepage/components/FeaturedResultsSection.js +1 -1
  96. package/dist/features/homepage/components/SectionCarousel.js +1 -1
  97. package/dist/features/homepage/components/ShopByCategorySection.js +1 -1
  98. package/dist/features/layout/AppLayoutShell.js +9 -2
  99. package/dist/features/layout/BottomActions.js +26 -3
  100. package/dist/features/layout/BottomActionsContext.d.ts +14 -0
  101. package/dist/features/layout/BottomActionsContext.js +13 -1
  102. package/dist/features/layout/hooks/useBottomActions.d.ts +6 -1
  103. package/dist/features/layout/hooks/useBottomActions.js +4 -1
  104. package/dist/features/layout/index.d.ts +1 -1
  105. package/dist/features/live/components/LiveItemDetailPageView.js +7 -6
  106. package/dist/features/orders/repository/orders.repository.d.ts +4 -15
  107. package/dist/features/orders/repository/orders.repository.js +4 -27
  108. package/dist/features/orders/types/index.d.ts +16 -0
  109. package/dist/features/orders/utils/order-splitter.js +14 -2
  110. package/dist/features/pre-orders/components/PreOrderBottomActions.d.ts +1 -1
  111. package/dist/features/pre-orders/components/PreOrderBottomActions.js +3 -19
  112. package/dist/features/pre-orders/components/PreOrdersListView.js +11 -39
  113. package/dist/features/products/components/ArtStickersListView.js +10 -49
  114. package/dist/features/products/components/AuctionsIndexListing.js +1 -2
  115. package/dist/features/products/components/ListingBottomActions.d.ts +28 -0
  116. package/dist/features/products/components/ListingBottomActions.js +25 -0
  117. package/dist/features/products/components/PrizeDrawBottomActions.d.ts +1 -1
  118. package/dist/features/products/components/PrizeDrawBottomActions.js +3 -21
  119. package/dist/features/products/components/ProductDetailActions.js +4 -0
  120. package/dist/features/products/components/ProductDetailPageView.js +7 -7
  121. package/dist/features/products/components/ProductsIndexPageView.js +9 -62
  122. package/dist/features/products/constants/listing-tabs.d.ts +43 -198
  123. package/dist/features/products/constants/listing-tabs.js +71 -58
  124. package/dist/features/products/constants/sieve.d.ts +104 -1
  125. package/dist/features/products/constants/sieve.js +27 -7
  126. package/dist/features/products/repository/products.repository.d.ts +45 -1
  127. package/dist/features/products/repository/products.repository.js +67 -8
  128. package/dist/features/products/utils/listing-badge-variant.d.ts +20 -1
  129. package/dist/features/products/utils/listing-badge-variant.js +26 -1
  130. package/dist/features/products/utils/listing-type.d.ts +24 -0
  131. package/dist/features/products/utils/listing-type.js +48 -0
  132. package/dist/features/promotions/actions/coupon-actions.d.ts +7 -0
  133. package/dist/features/promotions/actions/coupon-actions.js +12 -1
  134. package/dist/features/promotions/actions/coupon-stacking.d.ts +24 -0
  135. package/dist/features/promotions/actions/coupon-stacking.js +21 -0
  136. package/dist/features/promotions/actions/index.d.ts +1 -0
  137. package/dist/features/promotions/actions/index.js +1 -0
  138. package/dist/features/promotions/actions/seller-coupon-actions.d.ts +0 -1
  139. package/dist/features/promotions/actions/seller-coupon-actions.js +0 -1
  140. package/dist/features/promotions/components/CouponHelpDetails.d.ts +20 -0
  141. package/dist/features/promotions/components/CouponHelpDetails.js +17 -0
  142. package/dist/features/promotions/components/CouponsIndexListing.js +2 -1
  143. package/dist/features/promotions/components/index.d.ts +2 -0
  144. package/dist/features/promotions/components/index.js +1 -0
  145. package/dist/features/promotions/constants/coupon-help.d.ts +27 -0
  146. package/dist/features/promotions/constants/coupon-help.js +42 -0
  147. package/dist/features/promotions/repository/coupons.repository.d.ts +10 -1
  148. package/dist/features/promotions/repository/coupons.repository.js +22 -8
  149. package/dist/features/promotions/schemas/firestore.d.ts +0 -1
  150. package/dist/features/promotions/schemas/firestore.js +1 -1
  151. package/dist/features/promotions/schemas/index.d.ts +0 -20
  152. package/dist/features/promotions/schemas/index.js +0 -1
  153. package/dist/features/reviews/actions/review-actions.js +5 -1
  154. package/dist/features/reviews/components/ReviewDetailPageView.js +2 -1
  155. package/dist/features/reviews/components/ReviewFilters.d.ts +7 -1
  156. package/dist/features/reviews/components/ReviewFilters.js +4 -3
  157. package/dist/features/reviews/components/ReviewsIndexListing.d.ts +6 -0
  158. package/dist/features/reviews/components/ReviewsIndexListing.js +9 -109
  159. package/dist/features/reviews/components/ReviewsListingPanel.d.ts +41 -0
  160. package/dist/features/reviews/components/ReviewsListingPanel.js +226 -0
  161. package/dist/features/reviews/components/index.d.ts +2 -0
  162. package/dist/features/reviews/components/index.js +1 -0
  163. package/dist/features/reviews/hooks/useReviews.d.ts +8 -0
  164. package/dist/features/reviews/hooks/useReviews.js +14 -6
  165. package/dist/features/reviews/repository/reviews.repository.d.ts +29 -1
  166. package/dist/features/reviews/repository/reviews.repository.js +77 -41
  167. package/dist/features/reviews/types/index.d.ts +8 -1
  168. package/dist/features/seller/actions/offer-actions.d.ts +6 -0
  169. package/dist/features/seller/actions/offer-actions.js +8 -9
  170. package/dist/features/seller/components/SellerOffersView.d.ts +6 -1
  171. package/dist/features/seller/components/SellerOffersView.js +59 -8
  172. package/dist/features/seller/components/SellerProductsCards.js +2 -2
  173. package/dist/features/seller/components/SellerProductsView.js +21 -18
  174. package/dist/features/seller/repository/offer.repository.d.ts +18 -2
  175. package/dist/features/seller/repository/offer.repository.js +38 -2
  176. package/dist/features/seller/schemas/firestore.d.ts +7 -2
  177. package/dist/features/seller/schemas/firestore.js +3 -0
  178. package/dist/features/seller/schemas/offer-forms.d.ts +12 -0
  179. package/dist/features/seller/schemas/offer-forms.js +28 -0
  180. package/dist/features/stores/actions/store-query-actions.js +1 -1
  181. package/dist/features/stores/components/StoreAuctionsListing.js +5 -11
  182. package/dist/features/stores/components/StorePreOrdersListing.js +6 -11
  183. package/dist/features/stores/components/StoreReviewsListing.d.ts +4 -0
  184. package/dist/features/stores/components/StoreReviewsListing.js +7 -108
  185. package/dist/features/stores/schemas/index.d.ts +2 -2
  186. package/dist/features/tester/seed-data/coupons-tester-seed-data.js +3 -3
  187. package/dist/features/tester/seed-data/index.d.ts +1 -0
  188. package/dist/features/tester/seed-data/index.js +1 -0
  189. package/dist/features/tester/seed-data/offers-tester-seed-data.d.ts +2 -0
  190. package/dist/features/tester/seed-data/offers-tester-seed-data.js +61 -0
  191. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +337 -1
  192. package/dist/index.d.ts +11 -4
  193. package/dist/index.js +15 -3
  194. package/dist/react/hooks/useListingTypeFlags.d.ts +12 -19
  195. package/dist/react/hooks/useListingTypeFlags.js +4 -9
  196. package/dist/schemas/registry.d.ts +0 -12
  197. package/dist/seed/claimed-coupons-seed-data.js +3 -3
  198. package/dist/seed/coupons-seed-data.js +29 -10
  199. package/dist/seed/faq-seed-data.js +2 -2
  200. package/dist/seed/manifest.js +2 -2
  201. package/dist/server-entry.d.ts +1 -0
  202. package/dist/server-entry.js +1 -0
  203. package/dist/server.d.ts +1 -0
  204. package/dist/server.js +3 -0
  205. package/dist/styles.css +44 -39
  206. package/dist/tailwind-utilities.css +1 -1
  207. package/dist/ui/components/FilterChipGroup.d.ts +21 -3
  208. package/dist/ui/components/FilterChipGroup.js +33 -4
  209. package/dist/ui/components/HorizontalScroller.d.ts +1 -2
  210. package/dist/ui/components/HorizontalScroller.js +9 -5
  211. package/dist/ui/components/HorizontalScroller.style.css +43 -38
  212. package/package.json +1 -1
@@ -0,0 +1,130 @@
1
+ import type { JsonValue } from "../../../../schemas/types";
2
+ /** Vercel Hobby Fluid Compute ceiling (CLAUDE.md Rule #6) — never fetch more at once. */
3
+ export declare const PUBLIC_PRODUCT_MAX_PAGE_SIZE = 50;
4
+ /**
5
+ * Which "Show X" defaults apply to a set of listing types.
6
+ *
7
+ * THE POINT OF THIS FUNCTION is that SSR and the client refetch call the SAME
8
+ * implementation. `staleTime: Infinity` freezes SSR `initialData` forever, so
9
+ * if the SSR filter-builder computes a different default than the client's
10
+ * bare-URL state, the first paint and every later refetch disagree and one of
11
+ * them is wrong — permanently (Root Cause #30). Two mirrored literals is
12
+ * exactly how that bug is written; one shared function is how it isn't.
13
+ *
14
+ * `/products` now spans all nine listing types, which makes this load-bearing
15
+ * rather than theoretical: a mixed set includes both sold-out-able types
16
+ * (products, art) and time-boxed ones (auctions, pre-orders, prize draws), so
17
+ * BOTH toggles can apply at once.
18
+ */
19
+ export declare function defaultTogglesForListingTypes(types: readonly string[] | undefined): {
20
+ hideSoldByDefault: boolean;
21
+ hideEndedByDefault: boolean;
22
+ };
23
+ /**
24
+ * A product document as it comes back from either executor. Deliberately
25
+ * `JsonValue`-shaped rather than `ProductDocument`: the upstream listingProcessor
26
+ * Function returns already-serialised rows, and this module only ever reads a
27
+ * handful of scalar fields off them (stockQuantity, listingType, the sort key).
28
+ */
29
+ type Row = Record<string, JsonValue>;
30
+ export interface PublicProductListInput {
31
+ /**
32
+ * Listing types this page spans. Emitted as one pipe-joined OR-group, which
33
+ * the Firebase Sieve adapter upgrades to a `.where(…, "in", …)` query.
34
+ */
35
+ listingTypes?: readonly string[];
36
+ q?: string;
37
+ category?: string;
38
+ brand?: string;
39
+ /** Pipe-joined multi-select (`new|used`) — emitted as an OR-group, never as AND. */
40
+ condition?: string;
41
+ storeId?: string;
42
+ status?: string;
43
+ minPrice?: string;
44
+ maxPrice?: string;
45
+ minBid?: string;
46
+ maxBid?: string;
47
+ featured?: boolean;
48
+ isPromoted?: boolean;
49
+ freeShipping?: boolean;
50
+ isPartOfBundle?: boolean;
51
+ features?: readonly string[];
52
+ preOrderProductionStatus?: string;
53
+ prizeRevealStatus?: string;
54
+ /** Hide sold-out rows. Applied in memory — never pushed into Firestore. */
55
+ inStock?: boolean;
56
+ dateFrom?: string;
57
+ dateTo?: string;
58
+ /** Pre-validated raw Sieve string (callers must safelist fields themselves). */
59
+ rawFilters?: string | null;
60
+ page?: number;
61
+ pageSize?: number;
62
+ sorts?: string;
63
+ cursor?: string | null;
64
+ }
65
+ export interface PublicProductListResult {
66
+ items: Row[];
67
+ total: number;
68
+ page: number;
69
+ pageSize: number;
70
+ totalPages: number;
71
+ hasMore: boolean;
72
+ cursor: string | null;
73
+ /** The Sieve string actually sent to Firestore — surfaced for debugging. */
74
+ filters: string;
75
+ sorts: string;
76
+ }
77
+ export interface PublicProductQuery {
78
+ filters: string;
79
+ sorts: string;
80
+ page: number;
81
+ pageSize: number;
82
+ cursor?: string | null;
83
+ }
84
+ export interface ExecutorResult {
85
+ items: unknown[];
86
+ total: number;
87
+ page: number;
88
+ totalPages: number;
89
+ hasMore: boolean;
90
+ cursor?: string | null;
91
+ }
92
+ /**
93
+ * How the query actually runs. Defaults to `productRepository.list`. The consumer
94
+ * route overrides this to prefer the colocated `listingProcessor` Function and fall
95
+ * back to the repository — the seam exists so that preference never has to be
96
+ * duplicated into appkit, which has no access to the consumer's env.
97
+ */
98
+ export type PublicProductExecutor = (query: PublicProductQuery) => Promise<ExecutorResult>;
99
+ export interface PublicProductListOptions {
100
+ executor?: PublicProductExecutor;
101
+ /**
102
+ * When supplied, rows whose `listingType` is not in this set are stripped after
103
+ * the fetch. Callers that can read siteSettings pass `enabledListingTypes(...)`.
104
+ */
105
+ enabledListingTypes?: ReadonlySet<string>;
106
+ }
107
+ /**
108
+ * Read the shared public-listing query params. SSR views hand in Next's
109
+ * `searchParams` record; the API route hands in `url.searchParams`. Both produce
110
+ * the same typed input, which is the whole point — the two paths cannot drift.
111
+ */
112
+ export declare function parsePublicProductParams(params: URLSearchParams | Record<string, string | string[] | undefined>, defaults?: {
113
+ listingTypes?: readonly string[];
114
+ pageSize?: number;
115
+ sorts?: string;
116
+ /**
117
+ * Public browse pages hide sold-out rows until the user flips "Show sold".
118
+ * The API route leaves this off so non-browse callers (related items,
119
+ * homepage sections) keep their current behaviour.
120
+ */
121
+ hideSoldByDefault?: boolean;
122
+ /**
123
+ * Auctions / pre-orders hide already-ended rows until "Show ended" is on,
124
+ * by defaulting `dateFrom` to now. Must mirror the paired client listing
125
+ * component exactly or the SSR paint and the refetch disagree.
126
+ */
127
+ hideEndedByDefault?: boolean;
128
+ }): PublicProductListInput;
129
+ export declare function listPublicProducts(input: PublicProductListInput, opts?: PublicProductListOptions): Promise<PublicProductListResult | null>;
130
+ export {};
@@ -0,0 +1,375 @@
1
+ /*
2
+ * WHY: One implementation of "list products for a public listing page", shared by
3
+ * every SSR listing view AND `/api/products`. Before 2026-08-21 there were five
4
+ * hand-rolled copies of this filter logic (four SSR views + the route) and they
5
+ * had drifted: the route had learned — via `6fe4e0dd8` and `efb7d1b6a` — that an
6
+ * `inStock` (`stockQuantity>0`) or mismatched date-range inequality must NEVER be
7
+ * pushed into the Firestore query, while the SSR views still pushed it and
8
+ * swallowed the resulting FAILED_PRECONDITION as "no results". That is what made
9
+ * /art render empty until "Show sold" was clicked.
10
+ *
11
+ * WHAT: `parsePublicProductParams` (URL/searchParams -> typed input) and
12
+ * `listPublicProducts` (typed input -> page of documents). Firestore-safe
13
+ * clauses go into the query; `inStock` and unsafe date ranges are applied as
14
+ * in-memory predicates over one bounded fetch, then re-sorted into the caller's
15
+ * requested order and re-paginated.
16
+ *
17
+ * EXPORTS:
18
+ * PublicProductListInput, PublicProductListResult, PublicProductExecutor,
19
+ * PublicProductListOptions, parsePublicProductParams, listPublicProducts
20
+ *
21
+ * @tag domain:products
22
+ * @tag layer:server-data
23
+ * @tag pattern:none
24
+ * @tag access:server-only
25
+ * @tag consumers:ProductsIndexPageView,ArtStickersListView,AuctionsListView,PreOrdersListView,api/products
26
+ * @tag sideEffects:firestore-read
27
+ */
28
+ import { productRepository } from "../../../../repositories";
29
+ import { normalizeError } from "../../../../errors/normalize";
30
+ import { serverLogger } from "../../../../monitoring/server-logger";
31
+ import { PRODUCT_FIELDS } from "../../../../constants/field-names";
32
+ import { TABLE_KEYS } from "../../../../constants/table-keys";
33
+ import { sortBy } from "../../../../constants/sort";
34
+ import { SIEVE_OP, sieveAnd, sieveFilter, expandSieveParam, } from "../../../../utils/sieve-builder";
35
+ import { ALL_LISTING_TYPES } from "../../../shared/listing-types/feature-flags";
36
+ import { hideDefaultsFor } from "../../../shared/listing-types/_registry";
37
+ /** Vercel Hobby Fluid Compute ceiling (CLAUDE.md Rule #6) — never fetch more at once. */
38
+ export const PUBLIC_PRODUCT_MAX_PAGE_SIZE = 50;
39
+ /**
40
+ * Which "Show X" defaults apply to a set of listing types.
41
+ *
42
+ * THE POINT OF THIS FUNCTION is that SSR and the client refetch call the SAME
43
+ * implementation. `staleTime: Infinity` freezes SSR `initialData` forever, so
44
+ * if the SSR filter-builder computes a different default than the client's
45
+ * bare-URL state, the first paint and every later refetch disagree and one of
46
+ * them is wrong — permanently (Root Cause #30). Two mirrored literals is
47
+ * exactly how that bug is written; one shared function is how it isn't.
48
+ *
49
+ * `/products` now spans all nine listing types, which makes this load-bearing
50
+ * rather than theoretical: a mixed set includes both sold-out-able types
51
+ * (products, art) and time-boxed ones (auctions, pre-orders, prize draws), so
52
+ * BOTH toggles can apply at once.
53
+ */
54
+ export function defaultTogglesForListingTypes(types) {
55
+ // No explicit set = every type is in play, so both defaults apply.
56
+ const effective = types && types.length > 0 ? types : ALL_LISTING_TYPES;
57
+ const defaults = hideDefaultsFor(effective.filter((t) => ALL_LISTING_TYPES.includes(t)));
58
+ return {
59
+ hideSoldByDefault: defaults.includes("sold"),
60
+ // "ended" and "closed" are the same query mechanic (a date range against
61
+ // the type's own end field); they differ only in the toggle's wording.
62
+ hideEndedByDefault: defaults.includes("ended") || defaults.includes("closed"),
63
+ };
64
+ }
65
+ const DEFAULT_PAGE = 1;
66
+ const DEFAULT_PAGE_SIZE = 24;
67
+ const DEFAULT_SORTS = sortBy(PRODUCT_FIELDS.CREATED_AT);
68
+ // ---------------------------------------------------------------------------
69
+ // Param parsing
70
+ // ---------------------------------------------------------------------------
71
+ function first(params, key) {
72
+ if (params instanceof URLSearchParams)
73
+ return params.get(key) ?? "";
74
+ const v = params[key];
75
+ return (Array.isArray(v) ? v[0] : v) ?? "";
76
+ }
77
+ function num(raw) {
78
+ if (!raw)
79
+ return undefined;
80
+ const n = Number(raw);
81
+ return Number.isFinite(n) ? n : undefined;
82
+ }
83
+ /**
84
+ * Read the shared public-listing query params. SSR views hand in Next's
85
+ * `searchParams` record; the API route hands in `url.searchParams`. Both produce
86
+ * the same typed input, which is the whole point — the two paths cannot drift.
87
+ */
88
+ export function parsePublicProductParams(params, defaults) {
89
+ const get = (k) => first(params, k);
90
+ // The type filter is a multi-select checkbox group, so its URL value is a
91
+ // pipe-joined set (`auction|pre-order`). Selected types NARROW the page's
92
+ // span; anything outside the span is discarded rather than allowed to widen
93
+ // it (an /art URL must not be able to ask for auctions). Nothing selected —
94
+ // or nothing valid selected — means the page's full span.
95
+ const typeParam = get(TABLE_KEYS.LISTING_TYPE);
96
+ const span = defaults?.listingTypes;
97
+ const selected = typeParam
98
+ .split("|")
99
+ .map((t) => t.trim())
100
+ .filter((t) => t && t !== "All");
101
+ const narrowed = span ? selected.filter((t) => span.includes(t)) : selected;
102
+ const listingTypes = narrowed.length > 0 ? narrowed : span;
103
+ const featuresRaw = get(TABLE_KEYS.FEATURES);
104
+ return {
105
+ listingTypes,
106
+ q: get(TABLE_KEYS.QUERY) || undefined,
107
+ category: get(TABLE_KEYS.CATEGORY) || get(TABLE_KEYS.CATEGORY_SLUG) || undefined,
108
+ brand: get(TABLE_KEYS.BRAND) || undefined,
109
+ condition: get(TABLE_KEYS.CONDITION) || undefined,
110
+ storeId: get(TABLE_KEYS.STORE_ID) || get(TABLE_KEYS.SELLER) || undefined,
111
+ status: get(TABLE_KEYS.STATUS) || undefined,
112
+ minPrice: get(TABLE_KEYS.MIN_PRICE) || undefined,
113
+ maxPrice: get(TABLE_KEYS.MAX_PRICE) || undefined,
114
+ minBid: get(TABLE_KEYS.MIN_BID) || undefined,
115
+ maxBid: get(TABLE_KEYS.MAX_BID) || undefined,
116
+ featured: get(TABLE_KEYS.FEATURED) === "true" || undefined,
117
+ isPromoted: get("isPromoted") === "true" || undefined,
118
+ freeShipping: get(TABLE_KEYS.FREE_SHIPPING) === "true" || undefined,
119
+ isPartOfBundle: get(TABLE_KEYS.IS_PART_OF_BUNDLE) === "true" || undefined,
120
+ features: featuresRaw ? featuresRaw.split("|").filter(Boolean) : undefined,
121
+ preOrderProductionStatus: get(TABLE_KEYS.PREORDER_STATUS) || get("preOrderStatus") || undefined,
122
+ prizeRevealStatus: get(TABLE_KEYS.PRIZE_REVEAL_STATUS) || undefined,
123
+ // Three spellings of one intent — /products and /art call it "Show sold",
124
+ // /pre-orders calls it "Show closed", and the client hook sends `inStock`.
125
+ // Any of them turning the filter OFF wins, so SSR and the client refetch
126
+ // agree on what the default view means (Root Cause #30).
127
+ inStock: get(TABLE_KEYS.SHOW_SOLD) === "true" || get(TABLE_KEYS.SHOW_CLOSED) === "true"
128
+ ? undefined
129
+ : get(TABLE_KEYS.IN_STOCK)
130
+ ? get(TABLE_KEYS.IN_STOCK) === "true"
131
+ : defaults?.hideSoldByDefault
132
+ ? true
133
+ : undefined,
134
+ dateFrom: get(TABLE_KEYS.DATE_FROM) ||
135
+ (defaults?.hideEndedByDefault && get(TABLE_KEYS.SHOW_ENDED) !== "true"
136
+ ? new Date().toISOString()
137
+ : undefined),
138
+ dateTo: get(TABLE_KEYS.DATE_TO) || undefined,
139
+ page: num(get(TABLE_KEYS.PAGE)) ?? DEFAULT_PAGE,
140
+ pageSize: num(get(TABLE_KEYS.PAGE_SIZE)) ?? defaults?.pageSize ?? DEFAULT_PAGE_SIZE,
141
+ sorts: get(TABLE_KEYS.SORT) || defaults?.sorts || DEFAULT_SORTS,
142
+ };
143
+ }
144
+ // ---------------------------------------------------------------------------
145
+ // Filter building
146
+ // ---------------------------------------------------------------------------
147
+ /**
148
+ * Only clauses Firestore can satisfy alongside an arbitrary `orderBy`. Notably
149
+ * ABSENT: `inStock` (a `stockQuantity` range) and, unless the sort matches, the
150
+ * auction/pre-order date ranges. Firestore appends an inequality field to the
151
+ * orderBy implicitly, so pairing one with an unrelated sort demands a composite
152
+ * index in an order nobody declares — it fails with FAILED_PRECONDITION, which
153
+ * upstream reads as a bare "no results".
154
+ */
155
+ function buildFirestoreSafeFilters(input) {
156
+ const parts = [];
157
+ // Published-only unless the caller explicitly asks otherwise. Without this
158
+ // default, any refetch that omits `status` leaks drafts (Root Cause #30).
159
+ parts.push(sieveFilter(PRODUCT_FIELDS.STATUS, SIEVE_OP.EQ, input.status || PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED));
160
+ const types = input.listingTypes?.filter(Boolean) ?? [];
161
+ if (types.length > 0) {
162
+ parts.push(sieveFilter(PRODUCT_FIELDS.LISTING_TYPE, SIEVE_OP.EQ, types.join("|")));
163
+ }
164
+ if (input.category) {
165
+ parts.push(expandSieveParam(PRODUCT_FIELDS.CATEGORY_SLUGS, input.category, SIEVE_OP.CONTAINS));
166
+ }
167
+ if (input.brand)
168
+ parts.push(sieveFilter(PRODUCT_FIELDS.BRAND, SIEVE_OP.EQ, input.brand));
169
+ if (input.condition) {
170
+ // Pipe-joined OR-group, NOT `sieveMultiEq` — that emits
171
+ // `condition==new,condition==used`, an AND of two equalities on one field,
172
+ // which can never match a document.
173
+ const values = input.condition.split("|").filter(Boolean);
174
+ if (values.length > 0) {
175
+ parts.push(sieveFilter(PRODUCT_FIELDS.CONDITION, SIEVE_OP.EQ, values.join("|")));
176
+ }
177
+ }
178
+ if (input.storeId)
179
+ parts.push(sieveFilter(PRODUCT_FIELDS.STORE_ID, SIEVE_OP.EQ, input.storeId));
180
+ const minPrice = num(input.minPrice ?? "");
181
+ if (minPrice !== undefined)
182
+ parts.push(sieveFilter(PRODUCT_FIELDS.PRICE, SIEVE_OP.GTE, minPrice));
183
+ const maxPrice = num(input.maxPrice ?? "");
184
+ if (maxPrice !== undefined)
185
+ parts.push(sieveFilter(PRODUCT_FIELDS.PRICE, SIEVE_OP.LTE, maxPrice));
186
+ const minBid = num(input.minBid ?? "");
187
+ if (minBid !== undefined)
188
+ parts.push(sieveFilter(PRODUCT_FIELDS.CURRENT_BID, SIEVE_OP.GTE, minBid));
189
+ const maxBid = num(input.maxBid ?? "");
190
+ if (maxBid !== undefined)
191
+ parts.push(sieveFilter(PRODUCT_FIELDS.CURRENT_BID, SIEVE_OP.LTE, maxBid));
192
+ if (input.featured)
193
+ parts.push(sieveFilter(PRODUCT_FIELDS.FEATURED, SIEVE_OP.EQ, true));
194
+ if (input.isPromoted)
195
+ parts.push(sieveFilter(PRODUCT_FIELDS.IS_PROMOTED, SIEVE_OP.EQ, true));
196
+ if (input.freeShipping) {
197
+ parts.push(sieveFilter(PRODUCT_FIELDS.SHIPPING_PAID_BY, SIEVE_OP.EQ, PRODUCT_FIELDS.SHIPPING_PAID_BY_VALUES.SELLER));
198
+ }
199
+ if (input.isPartOfBundle)
200
+ parts.push(sieveFilter("isPartOfBundle", SIEVE_OP.EQ, true));
201
+ if (input.preOrderProductionStatus) {
202
+ parts.push(sieveFilter(PRODUCT_FIELDS.PRE_ORDER_PRODUCTION_STATUS, SIEVE_OP.EQ, input.preOrderProductionStatus));
203
+ }
204
+ if (input.prizeRevealStatus) {
205
+ parts.push(sieveFilter(PRODUCT_FIELDS.PRIZE_REVEAL_STATUS, SIEVE_OP.EQ, input.prizeRevealStatus));
206
+ }
207
+ if (input.q)
208
+ parts.push(sieveFilter(PRODUCT_FIELDS.TITLE, SIEVE_OP.CONTAINS_CI, input.q));
209
+ // `array-contains-any` isn't supported by the Sieve Firebase adapter, so only a
210
+ // single feature can be pushed down; multi-select falls through to the caller.
211
+ if (input.features?.length === 1) {
212
+ parts.push(sieveFilter(PRODUCT_FIELDS.FEATURES, SIEVE_OP.CONTAINS, input.features[0]));
213
+ }
214
+ if (input.rawFilters)
215
+ parts.push(input.rawFilters);
216
+ return sieveAnd(...parts.filter(Boolean));
217
+ }
218
+ /** Which Timestamp field a date range refers to, given the requested listing types. */
219
+ function dateFieldFor(types) {
220
+ if (!types || types.length !== 1)
221
+ return null;
222
+ if (types[0] === PRODUCT_FIELDS.LISTING_TYPE_VALUES.AUCTION) {
223
+ return PRODUCT_FIELDS.AUCTION_END_DATE;
224
+ }
225
+ if (types[0] === PRODUCT_FIELDS.LISTING_TYPE_VALUES.PRE_ORDER) {
226
+ return PRODUCT_FIELDS.PRE_ORDER_DELIVERY_DATE;
227
+ }
228
+ return null;
229
+ }
230
+ async function defaultExecutor(query) {
231
+ const result = await productRepository.list({
232
+ filters: query.filters,
233
+ sorts: query.sorts,
234
+ page: query.page,
235
+ pageSize: query.pageSize,
236
+ });
237
+ return {
238
+ items: result.items,
239
+ total: result.total,
240
+ page: result.page,
241
+ totalPages: result.totalPages,
242
+ hasMore: result.hasMore,
243
+ cursor: null,
244
+ };
245
+ }
246
+ // ---------------------------------------------------------------------------
247
+ // The one public listing query
248
+ // ---------------------------------------------------------------------------
249
+ export async function listPublicProducts(input, opts) {
250
+ const page = Math.max(1, input.page ?? DEFAULT_PAGE);
251
+ const pageSize = Math.min(PUBLIC_PRODUCT_MAX_PAGE_SIZE, Math.max(1, input.pageSize ?? DEFAULT_PAGE_SIZE));
252
+ const sorts = input.sorts || DEFAULT_SORTS;
253
+ const requestedSortField = sorts.replace(/^-/, "");
254
+ const dateField = dateFieldFor(input.listingTypes);
255
+ const hasDateRange = Boolean((input.dateFrom || input.dateTo) && dateField);
256
+ // Firestore DOES accept one inequality when the query sorts by that same field —
257
+ // the auctions "Ending Soon" default is exactly that shape, so push it down.
258
+ const canPushDate = hasDateRange && !input.inStock && requestedSortField === dateField;
259
+ const safeFilters = buildFirestoreSafeFilters(input);
260
+ const filters = sieveAnd(safeFilters, ...(canPushDate && input.dateFrom
261
+ ? [sieveFilter(dateField, SIEVE_OP.GTE, input.dateFrom)]
262
+ : []), ...(canPushDate && input.dateTo
263
+ ? [sieveFilter(dateField, SIEVE_OP.LTE, input.dateTo)]
264
+ : []));
265
+ const hasUnsafeFilter = Boolean(input.inStock) || (hasDateRange && !canPushDate);
266
+ // When a date range stays in memory, fetch in whichever direction front-loads
267
+ // the rows that will PASS it: `dateFrom` (">=", still live) wants the most
268
+ // future dates first; `dateTo` ("<=", ends by) wants the earliest first.
269
+ // Fetching in the client's own order instead is what made live auctions
270
+ // invisible once ~50 had already ended.
271
+ const fetchSorts = hasUnsafeFilter && hasDateRange && dateField
272
+ ? input.dateFrom
273
+ ? sortBy(dateField, "DESC")
274
+ : sortBy(dateField, "ASC")
275
+ : sorts;
276
+ const executor = opts?.executor ?? defaultExecutor;
277
+ let result;
278
+ try {
279
+ result = await executor({
280
+ filters,
281
+ sorts: fetchSorts,
282
+ page: hasUnsafeFilter ? 1 : page,
283
+ pageSize: hasUnsafeFilter ? PUBLIC_PRODUCT_MAX_PAGE_SIZE : pageSize,
284
+ cursor: hasUnsafeFilter ? null : (input.cursor ?? null),
285
+ });
286
+ }
287
+ catch (error) {
288
+ void normalizeError(error);
289
+ // Loud. A swallowed query failure here is indistinguishable from an empty
290
+ // catalogue at every call site above, which is exactly how the /art bug hid.
291
+ serverLogger.error("listPublicProducts query failed", {
292
+ filters,
293
+ sorts: fetchSorts,
294
+ error: error instanceof Error ? error.message : String(error),
295
+ });
296
+ return null;
297
+ }
298
+ let items = result.items;
299
+ let total = result.total;
300
+ let totalPages = result.totalPages;
301
+ let resultPage = result.page;
302
+ let hasMore = result.hasMore;
303
+ let cursor = result.cursor ?? null;
304
+ if (hasUnsafeFilter) {
305
+ const filtered = items.filter((item) => {
306
+ if (input.inStock) {
307
+ const stock = item[PRODUCT_FIELDS.STOCK_QUANTITY];
308
+ if (!(typeof stock === "number" && stock > 0))
309
+ return false;
310
+ }
311
+ if (hasDateRange && dateField) {
312
+ const raw = item[dateField];
313
+ if (!raw)
314
+ return false;
315
+ const value = raw instanceof Date ? raw.toISOString() : String(raw);
316
+ if (input.dateFrom && value < input.dateFrom)
317
+ return false;
318
+ if (input.dateTo && value > input.dateTo)
319
+ return false;
320
+ }
321
+ return true;
322
+ });
323
+ const ordered = fetchSorts === sorts ? filtered : sortRows(filtered, requestedSortField, sorts.startsWith("-"));
324
+ total = ordered.length;
325
+ totalPages = Math.max(1, Math.ceil(total / pageSize));
326
+ resultPage = page;
327
+ const start = (page - 1) * pageSize;
328
+ items = ordered.slice(start, start + pageSize);
329
+ hasMore = start + pageSize < total;
330
+ cursor = null;
331
+ }
332
+ // Feature-flagged-off types never reach a public surface.
333
+ const enabled = opts?.enabledListingTypes;
334
+ if (enabled && enabled.size > 0) {
335
+ const before = items.length;
336
+ items = items.filter((it) => {
337
+ const lt = typeof it.listingType === "string" ? it.listingType : "standard";
338
+ return enabled.has(lt);
339
+ });
340
+ const removed = before - items.length;
341
+ if (removed > 0)
342
+ total = Math.max(0, total - removed);
343
+ }
344
+ return {
345
+ items,
346
+ total,
347
+ page: resultPage,
348
+ pageSize,
349
+ totalPages,
350
+ hasMore,
351
+ cursor,
352
+ filters,
353
+ sorts,
354
+ };
355
+ }
356
+ function sortRows(rows, field, desc) {
357
+ return [...rows].sort((a, b) => {
358
+ const av = a[field];
359
+ const bv = b[field];
360
+ if (av == null && bv == null)
361
+ return 0;
362
+ if (av == null)
363
+ return 1;
364
+ if (bv == null)
365
+ return -1;
366
+ const cmp = typeof av === "number" && typeof bv === "number"
367
+ ? av - bv
368
+ : String(av) < String(bv)
369
+ ? -1
370
+ : String(av) > String(bv)
371
+ ? 1
372
+ : 0;
373
+ return desc ? -cmp : cmp;
374
+ });
375
+ }
@@ -1,4 +1,4 @@
1
- export declare const getReviewsForProduct: (productId: string) => Promise<never[] | import("../../../..").ReviewDocument[]>;
1
+ export declare const getReviewsForProduct: (productId: string, limit?: number) => Promise<never[] | import("../../../..").ReviewDocument[]>;
2
2
  export declare const getReviewsForStore: (storeId: string, page?: number, pageSize?: number) => Promise<import("../../../..").FirebaseSieveResult<import("../../../..").ReviewDocument> | {
3
3
  items: never[];
4
4
  total: number;
@@ -1,7 +1,8 @@
1
1
  import { cache } from "react";
2
2
  import { reviewRepository, orderRepository } from "../../../../repositories";
3
- export const getReviewsForProduct = cache(async (productId) => {
4
- return reviewRepository.findApprovedByProduct(productId).catch(() => []);
3
+ import { REVIEWS_DETAIL_PAGE_SIZE } from "../../../shared/features/reviews/config";
4
+ export const getReviewsForProduct = cache(async (productId, limit = REVIEWS_DETAIL_PAGE_SIZE) => {
5
+ return reviewRepository.findApprovedByProduct(productId, limit).catch(() => []);
5
6
  });
6
7
  export const getReviewsForStore = cache(async (storeId, page = 1, pageSize = 20) => {
7
8
  return reviewRepository.listForStore(storeId, { page, pageSize }).catch(() => ({
@@ -3,9 +3,9 @@
3
3
  * created as fully public/active documents so testers exercise the real browse/search/
4
4
  * checkout code paths, but must stay invisible to non-testers. Filtering happens at the
5
5
  * application layer (post-fetch, in-memory) rather than via a Firestore `where` clause,
6
- * because `isTestData` is a new optional field — a Firestore inequality filter
7
- * (`where("isTestData", "!=", true)`) would silently exclude every pre-existing document
8
- * that doesn't have the field set at all, which is worse than the problem it solves.
6
+ * because `isTestData` is a new optional field — a Firestore inequality filter on it
7
+ * (a `!=` clause) would silently exclude every pre-existing document that doesn't have
8
+ * the field set at all, which is worse than the problem it solves.
9
9
  */
10
10
  import { isAdminUser } from "../../../../features/auth/role-predicates";
11
11
  /** True when the viewer is allowed to see isTestData:true documents. */