@mohasinac/appkit 4.41.9 โ†’ 4.41.10

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 (29) hide show
  1. package/dist/_internal/server/features/orders/adapters.js +22 -1
  2. package/dist/_internal/server/jobs/core/countersReconcile.js +8 -0
  3. package/dist/_internal/server/jobs/core/onProductWrite.js +25 -2
  4. package/dist/client.d.ts +1 -0
  5. package/dist/client.js +8 -0
  6. package/dist/features/admin/components/AdminCarouselGroupEditorView.js +17 -1
  7. package/dist/features/admin/components/AdminOrdersView.js +28 -1
  8. package/dist/features/faq/components/FAQPageContent.js +16 -3
  9. package/dist/features/scams/actions/scam-actions.js +12 -1
  10. package/dist/features/scams/repository/scammer.repository.d.ts +18 -1
  11. package/dist/features/scams/repository/scammer.repository.js +25 -3
  12. package/dist/features/seller/components/SellerOrdersView.js +11 -1
  13. package/dist/features/stores/components/StoresIndexListing.js +15 -5
  14. package/dist/features/stores/components/StoresIndexPageView.d.ts +16 -1
  15. package/dist/features/stores/components/StoresIndexPageView.js +25 -3
  16. package/dist/features/stores/hooks/useStores.js +5 -0
  17. package/dist/features/stores/repository/store.repository.d.ts +1 -0
  18. package/dist/features/stores/repository/store.repository.js +30 -1
  19. package/dist/features/stores/types/index.d.ts +2 -0
  20. package/dist/features/tester/seed-data/authored/account-auth__profile-settings.js +0 -2
  21. package/dist/features/tester/seed-data/authored/account-auth__signup-login.js +0 -10
  22. package/dist/features/tester/seed-data/authored/admin__site-system.js +0 -2
  23. package/dist/features/tester/seed-data/authored/buying__my-orders.js +0 -2
  24. package/dist/features/tester/seed-data/authored/buying__offers.js +0 -2
  25. package/dist/features/tester/seed-data/authored/content-discovery__notifications.js +0 -2
  26. package/dist/features/tester/seed-data/authored/public-pages__auth-error-pages.js +0 -4
  27. package/dist/features/tester/seed-data/authored/selling__seller-orders.js +0 -2
  28. package/dist/seed/orders-seed-data.js +24 -0
  29. package/package.json +1 -1
@@ -46,7 +46,28 @@ export function orderDocumentToOrder(doc) {
46
46
  };
47
47
  const shippingCost = doc.shippingFee ?? 0;
48
48
  const discount = doc.couponDiscount ?? 0;
49
- const subtotal = doc.totalPrice - shippingCost + discount;
49
+ /*
50
+ * ๐Ÿ›‘ SUM THE ITEMS. Do not back it out of the total.
51
+ *
52
+ * This was `doc.totalPrice - shippingCost + discount`, which subtracts
53
+ * shipping and adds back the discount โ€” and ignores every other charge the
54
+ * order carries: the platform fee, GST, the COD handling fee and deposit, and
55
+ * the three per-store add-ons. With shipping and discount both zero, that
56
+ * derivation returns the GRAND TOTAL, and the order page printed it on the
57
+ * row labelled "Subtotal".
58
+ *
59
+ * Measured: an order whose items are โ‚น1,399.00 + โ‚น899.00 = โ‚น2,298.00
60
+ * displayed "Subtotal โ‚น2,549.60 / Total โ‚น2,549.60" โ€” a page that visibly does
61
+ * not add up, on the document a buyer checks when they think they were
62
+ * overcharged.
63
+ *
64
+ * The items are the authority: they are on the document, they carry their own
65
+ * `totalPrice`, and they cannot drift out of step with the fee list the way a
66
+ * subtraction must every time a fee is added. The old derivation survives only
67
+ * as a fallback for documents with no items.
68
+ */
69
+ const itemsSubtotal = items.reduce((sum, it) => sum + (Number(it.price) || 0) * (Number(it.quantity) || 0), 0);
70
+ const subtotal = items.length > 0 ? itemsSubtotal : doc.totalPrice - shippingCost + discount;
50
71
  return {
51
72
  id: doc.id,
52
73
  userId: doc.userId,
@@ -3,6 +3,7 @@ import { categoriesRepository, reviewRepository, storeRepository, } from "../../
3
3
  import { ProductStatusValues, PRODUCT_COLLECTION } from "../../../../features/products/schemas/firestore";
4
4
  import { CATEGORIES_COLLECTION } from "../../../../features/categories/schemas/firestore";
5
5
  import { ORDER_FIELDS, PRODUCT_FIELDS } from "../../../../constants/field-names";
6
+ import { isListingRowAvailable } from "../../../shared/listing-types/_registry";
6
7
  import { QUERY_LIMIT } from "../handlers/messages";
7
8
  const STORES_COLLECTION = "stores";
8
9
  const ORDERS_COLLECTION = "orders";
@@ -40,6 +41,13 @@ function countRollup(t, isAuction) {
40
41
  */
41
42
  function tallyProduct(productId, data, tallies, parentsOf) {
42
43
  const isAuction = data.listingType === PRODUCT_FIELDS.LISTING_TYPE_VALUES.AUCTION;
44
+ /*
45
+ * Count only what a visitor can actually see. A published-but-sold, ended,
46
+ * closed or code-depleted row is excluded here for the same reason the listing
47
+ * excludes it โ€” otherwise the badge promises rows the tab will not show.
48
+ */
49
+ if (!isListingRowAvailable(data, new Date()))
50
+ return false;
43
51
  // `category` is @deprecated in favour of `categorySlugs[]`; mirror the live
44
52
  // trigger's selection exactly so the two agree (onProductWrite.ts).
45
53
  const leaf = (Array.isArray(data.categorySlugs) && data.categorySlugs[0]) ||
@@ -6,6 +6,7 @@ import { normalizeError } from "../../../../errors/normalize";
6
6
  import { categoriesRepository, storeRepository, } from "../../../../repositories";
7
7
  import { ProductStatusValues } from "../../../../features/products/schemas/firestore";
8
8
  import { PRODUCT_FIELDS } from "../../../../constants/field-names";
9
+ import { isListingRowAvailable } from "../../../shared/listing-types/_registry";
9
10
  const UNAVAILABLE_PRODUCT_STATUSES = new Set([
10
11
  ProductStatusValues.ARCHIVED,
11
12
  ]);
@@ -179,8 +180,30 @@ export async function handleProductWrite(input, ctx) {
179
180
  const afterStoreId = (after?.storeId || after?.sellerId) ?? null;
180
181
  const isAuction = after?.listingType === PRODUCT_FIELDS.LISTING_TYPE_VALUES.AUCTION;
181
182
  const beforeIsAuction = before?.listingType === PRODUCT_FIELDS.LISTING_TYPE_VALUES.AUCTION;
182
- const wasPublished = beforeStatus === ProductStatusValues.PUBLISHED;
183
- const isPublished = afterStatus === ProductStatusValues.PUBLISHED;
183
+ /*
184
+ * ๐Ÿ›‘ COUNTED means published AND available, because that is what the listing
185
+ * shows โ€” and therefore what the badge must promise.
186
+ *
187
+ * Counting publish/unpublish alone left the badge over-promising for as long
188
+ * as a row stayed published-but-sold: 36 of one run's failures were a tab
189
+ * badge larger than the tab it opened. `countersReconcile` now tallies the
190
+ * same way, and this keeps the live counter agreeing with it between nightly
191
+ * passes instead of drifting high all day.
192
+ *
193
+ * It closes more of the drift than it looks like it should, because the
194
+ * transitions that matter are all WRITES: a sale decrements stock, a refund
195
+ * restores it, and `auctionSettlement` writes to the product when an auction
196
+ * ends. A purely time-based expiry with no accompanying write is the only
197
+ * residue, and the nightly recount is its healer.
198
+ */
199
+ const wasCounted = beforeStatus === ProductStatusValues.PUBLISHED &&
200
+ !!before &&
201
+ isListingRowAvailable(before, new Date());
202
+ const isCounted = afterStatus === ProductStatusValues.PUBLISHED &&
203
+ !!after &&
204
+ isListingRowAvailable(after, new Date());
205
+ const wasPublished = wasCounted;
206
+ const isPublished = isCounted;
184
207
  const isDelete = !after;
185
208
  try {
186
209
  await dispatchProductWriteEvent({
package/dist/client.d.ts CHANGED
@@ -484,6 +484,7 @@ export { SELLER_LISTING_TABS, type SellerListingTabId, } from "./features/produc
484
484
  export { ALL_TAB, EMPTY_TAB, ADMIN_PRODUCT_STATUS_TABS, ADMIN_PRODUCT_LISTING_TYPE_TABS, ADMIN_BLOG_STATUS_TABS, ADMIN_USER_STATUS_TABS, ADMIN_USER_ROLE_TABS, ADMIN_STORE_STATUS_TABS, ADMIN_PAYOUT_STATUS_TABS, ADMIN_ORDER_STATUS_TABS, ADMIN_REVIEW_STATUS_TABS, ADMIN_REVIEW_RATING_TABS, ADMIN_BID_STATUS_TABS, ADMIN_CONTACT_STATUS_TABS, ADMIN_NEWSLETTER_STATUS_TABS, ADMIN_EVENT_ENTRY_STATUS_TABS, ADMIN_EVENT_STATUS_TABS, ADMIN_CART_OWNERSHIP_TABS, ADMIN_COUPON_TYPE_TABS, SELLER_PRODUCT_STATUS_TABS, SELLER_AUCTION_STATUS_TABS, SELLER_ORDER_STATUS_TABS, SELLER_OFFER_STATUS_TABS, SELLER_BID_STATUS_TABS, } from "./features/admin/constants/filter-tabs";
485
485
  export { PRODUCT_FIELDS, PRODUCT_STATUS_TRANSITIONS, ORDER_FIELDS, REVIEW_FIELDS, BID_FIELDS, AD_FIELDS, EVENT_FIELDS, EVENT_ENTRY_FIELDS, PAYOUT_FIELDS, STORE_FIELDS, CATEGORY_FIELDS, BLOG_FIELDS, USER_FIELDS, ADDRESS_FIELDS, BRAND_FIELDS, CART_FIELDS, WISHLIST_FIELDS, HISTORY_FIELDS, NOTIFICATION_FIELDS, SESSION_FIELDS, COUPON_USAGE_FIELDS, SCAMMER_FIELDS, SUPPORT_TICKET_FIELDS, CAROUSEL_FIELDS, COUPON_FIELDS, FAQ_FIELDS, HOMEPAGE_SECTION_FIELDS, SITE_SETTINGS_FIELDS, COMMON_FIELDS, OAUTH_STATE_VALUES, SCHEMA_DEFAULTS, } from "./constants/field-names";
486
486
  export { CART_LANE, CART_LANE_PRIORITY, CART_LANE_LABELS, laneOf, activeLane, laneItems, laneCounts, isLaneCheckoutable, laneBlockReason, isLockedLane, canAddNewItems, type CartLane, type LaneAssignable, } from "./_internal/shared/checkout/lanes";
487
+ export { getCartLineMembers, isMultiMemberLine, } from "./_internal/shared/checkout/line-members";
487
488
  export { newsletterSubscribeSchema, quickCreateTaxonomySchema, payoutMarkPaidSchema, } from "./features/admin/schemas/small-forms";
488
489
  export { raffleEntrySchema } from "./features/contact/schemas/inquiry-forms";
489
490
  export { orderCancelSchema, paymentProofSchema, } from "./features/orders/schemas/buyer-forms";
package/dist/client.js CHANGED
@@ -486,6 +486,14 @@ export { PRODUCT_FIELDS, PRODUCT_STATUS_TRANSITIONS, ORDER_FIELDS, REVIEW_FIELDS
486
486
  // Checkout lanes โ€” the derived auction > offer > standard partition of the
487
487
  // cart, and the priority rule that decides which one may be checked out.
488
488
  export { CART_LANE, CART_LANE_PRIORITY, CART_LANE_LABELS, laneOf, activeLane, laneItems, laneCounts, isLaneCheckoutable, laneBlockReason, isLockedLane, canAddNewItems, } from "./_internal/shared/checkout/lanes";
489
+ /*
490
+ * The cart page needs these to tell a multi-member line from an ordinary one: a
491
+ * group/bundle line's `productId` is a group or category id, not a product id,
492
+ * so anything that resolves it as a product must skip those lines. Reading
493
+ * `groupMembers` directly instead would miss carts written before that field
494
+ * existed โ€” the fallback lives inside these accessors.
495
+ */
496
+ export { getCartLineMembers, isMultiMemberLine, } from "./_internal/shared/checkout/line-members";
489
497
  export { newsletterSubscribeSchema, quickCreateTaxonomySchema, payoutMarkPaidSchema, } from "./features/admin/schemas/small-forms";
490
498
  export { raffleEntrySchema } from "./features/contact/schemas/inquiry-forms";
491
499
  export { orderCancelSchema, paymentProofSchema, } from "./features/orders/schemas/buyer-forms";
@@ -28,8 +28,24 @@ export function AdminCarouselGroupEditorView({ onCreated, carouselId, onSaved, .
28
28
  queryFn: () => apiClient.get(ADMIN_ENDPOINTS.CAROUSELS_BY_ID(carouselId)),
29
29
  enabled: isEdit,
30
30
  });
31
+ /*
32
+ * ๐Ÿ›‘ `existing.data` IS the document. Do not reach for `.data.data`.
33
+ *
34
+ * `apiClient` already unwraps the envelope โ€” it returns `data.data` โ€” so the
35
+ * extra hop resolved `undefined`, the effect bailed, and the form kept its
36
+ * initial values: an EMPTY name and `status: "draft"`.
37
+ *
38
+ * That is not a cosmetic bug. The editor then PATCHed those defaults over a
39
+ * live carousel, so opening "Homepage Hero" and pressing save renamed it to
40
+ * "" and took it off the homepage. An editor seeded from nothing is more
41
+ * dangerous than one that fails to open.
42
+ *
43
+ * Mirror image of Root Cause #98, which was a raw envelope treated as the
44
+ * payload; this is a payload unwrapped a second time. Both typecheck, because
45
+ * the cast asserts the shape rather than checking it.
46
+ */
31
47
  React.useEffect(() => {
32
- const doc = existing.data?.data;
48
+ const doc = existing.data;
33
49
  if (!doc)
34
50
  return;
35
51
  setName(doc.name ?? "");
@@ -24,7 +24,34 @@ import { AdminOrderEditorView } from "./AdminOrderEditorView";
24
24
  * GUID) instead was Root Cause Pattern #(order-guid) โ€” see CLAUDE.md.
25
25
  */
26
26
  function toOrderItemRows(item) {
27
- const items = Array.isArray(item.items) ? item.items : [];
27
+ const raw = Array.isArray(item.items) ? item.items : [];
28
+ /*
29
+ * Fall back to the LEGACY flat shape when `items[]` is absent.
30
+ *
31
+ * `items[]` is canonical and every real checkout writes it, but older
32
+ * documents carry `productId`/`productTitle`/`quantity` at the top level
33
+ * instead. Reading only `items[]` rendered those rows as "Order <raw id>"
34
+ * beside a placeholder emoji โ€” the product was known, just not where this
35
+ * looked.
36
+ *
37
+ * Root Cause #42's rule: prefer making the READER resilient over fixing only
38
+ * today's seed data, because the next hand-written fixture or legacy
39
+ * document will land in the same shape.
40
+ */
41
+ const items = raw.length > 0
42
+ ? raw
43
+ : typeof item.productTitle === "string" && item.productTitle
44
+ ? [
45
+ {
46
+ productId: item.productId,
47
+ productTitle: item.productTitle,
48
+ quantity: item.quantity,
49
+ unitPrice: item.unitPrice,
50
+ totalPrice: item.totalPrice,
51
+ image: item.image,
52
+ },
53
+ ]
54
+ : [];
28
55
  return items
29
56
  .filter((entry) => Boolean(entry) && typeof entry === "object")
30
57
  .map((entry) => ({
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Div, Heading, Input, SlottedListingView, Text } from "../../../ui";
3
+ import { Alert, Div, Heading, Input, SlottedListingView, Text } from "../../../ui";
4
4
  import { sortBy } from "../../../constants/sort";
5
5
  import { FAQCategorySidebar } from "./FAQCategorySidebar";
6
6
  import { FAQSortDropdown } from "./FAQSortDropdown";
@@ -14,7 +14,7 @@ export function FAQPageContent({ initialCategory = "all", categories, routeHelpe
14
14
  : sortOption === "alphabetical"
15
15
  ? "question"
16
16
  : "-createdAt";
17
- const { faqs, total, isLoading } = useFaqList({
17
+ const { faqs, total, isLoading, isError } = useFaqList({
18
18
  category: selectedCategory === "all" ? undefined : selectedCategory,
19
19
  search: searchValue.trim() || undefined,
20
20
  sorts,
@@ -56,7 +56,20 @@ export function FAQPageContent({ initialCategory = "all", categories, routeHelpe
56
56
  helpful: labels.sortHelpful,
57
57
  newest: labels.sortNewest,
58
58
  alphabetical: labels.sortAlphabetical,
59
- } })), renderTable: () => isLoading ? (_jsx(Text, { color: "muted", children: labels.loading })) : renderAccordion ? (renderAccordion(faqs)) : (_jsx(FAQAccordion, { faqs: faqs })), renderPagination: () => renderPagination?.(total) ?? null, total: total, isLoading: isLoading }), faqs.length > 0 ? (_jsx(Div, { className: "mt-12", children: _jsx(ContactCTA, { email: contact.email, phone: contact.phone, contactHref: routeHelpers.contactHref, labels: {
59
+ } })), renderTable: () => isLoading ? (_jsx(Text, { color: "muted", children: labels.loading })) : isError ? (
60
+ /*
61
+ * ๐Ÿ›‘ A failed query is NOT an empty result, and rendering it as
62
+ * one is what hid this for so long: the list query threw
63
+ * FAILED_PRECONDITION (its default -createdAt sort had no
64
+ * composite index) while the sidebar's count query, sorted
65
+ * differently, succeeded. The page therefore showed "0
66
+ * questions" beside a sidebar counting 14 matches โ€” two numbers
67
+ * from two queries, one of which had died silently.
68
+ *
69
+ * Saying so costs nothing and makes the next index gap
70
+ * self-reporting rather than a phantom empty state.
71
+ */
72
+ _jsx(Alert, { variant: "error", title: "Couldn't load questions", children: "Something went wrong fetching these FAQs. Try a different sort or reload the page." })) : renderAccordion ? (renderAccordion(faqs)) : (_jsx(FAQAccordion, { faqs: faqs })), renderPagination: () => renderPagination?.(total) ?? null, total: total, isLoading: isLoading }), faqs.length > 0 ? (_jsx(Div, { className: "mt-12", children: _jsx(ContactCTA, { email: contact.email, phone: contact.phone, contactHref: routeHelpers.contactHref, labels: {
60
73
  title: labels.contactTitle,
61
74
  description: labels.contactDescription,
62
75
  emailUs: labels.contactEmailUs,
@@ -20,12 +20,23 @@ export async function listVerifiedScammers(searchParams) {
20
20
  filters.push(sieveFilter("scamType", SIEVE_OP.EQ, String(params.scamType)));
21
21
  if (params.scamPlatform)
22
22
  filters.push(sieveFilter("scamPlatform", SIEVE_OP.EQ, String(params.scamPlatform)));
23
+ /*
24
+ * ๐Ÿ›‘ `q` MUST reach the repository. It was read by ScamRegistryView and then
25
+ * dropped here, so the registry's own search box โ€” whose subtitle reads
26
+ * "Search by name, phone, or UPI" โ€” filtered nothing: `?q=zzzznope` returned
27
+ * every verified profile and the counter still showed the full total.
28
+ *
29
+ * Only a nonsense term exposes this, because a real term also returns rows.
30
+ * Same shape as the /stores SSR search that was fixed earlier: the parameter
31
+ * was parsed, and then simply not used.
32
+ */
33
+ const search = String(params.q ?? "").trim();
23
34
  const result = await scammerRepository.listVerified({
24
35
  filters: filters.join(",") || undefined,
25
36
  sorts: sort,
26
37
  page,
27
38
  pageSize,
28
- });
39
+ }, search ? { search } : undefined);
29
40
  return {
30
41
  items: result.items,
31
42
  total: result.total,
@@ -62,7 +62,24 @@ declare class ScammerRepository extends BaseRepository<ScammerDocument> {
62
62
  canSort: boolean;
63
63
  };
64
64
  };
65
- listVerified(model: SieveModel): Promise<FirebaseSieveResult<ScammerDocument>>;
65
+ /**
66
+ * The PUBLIC registry at /scams.
67
+ *
68
+ * ๐Ÿ›‘ `opts.search` is not optional decoration โ€” the page has a prominent
69
+ * search box whose own subtitle says "Search by name, phone, or UPI", and it
70
+ * filtered nothing: `listVerifiedScammers` read `q` and never passed it on,
71
+ * so `?q=zzzznope` returned every verified profile and the counter still read
72
+ * the full total. Only a nonsense term exposes that, because a real term also
73
+ * returns rows.
74
+ *
75
+ * Mirrors `listAll` below deliberately, including the empty-plan guard: a
76
+ * search that narrows to no usable token must return NOTHING rather than the
77
+ * whole registry โ€” failing open here would publish every profile to someone
78
+ * who searched for a single stop-word.
79
+ */
80
+ listVerified(model: SieveModel, opts?: {
81
+ search?: string;
82
+ }): Promise<FirebaseSieveResult<ScammerDocument>>;
66
83
  /** Derived on every write path via `applyWriteHooks`. */
67
84
  protected buildSearchTxtFor(data: Record<string, JsonValue>): string[] | null;
68
85
  listAll(model: SieveModel, opts?: {
@@ -12,13 +12,35 @@ class ScammerRepository extends BaseRepository {
12
12
  constructor() {
13
13
  super(SCAMMER_COLLECTION);
14
14
  }
15
- async listVerified(model) {
16
- const baseQuery = this.getCollection().where(SCAMMER_FIELDS.STATUS, "==", "verified");
17
- return this.sieveQuery(model, ScammerRepository.SIEVE_FIELDS, {
15
+ /**
16
+ * The PUBLIC registry at /scams.
17
+ *
18
+ * ๐Ÿ›‘ `opts.search` is not optional decoration โ€” the page has a prominent
19
+ * search box whose own subtitle says "Search by name, phone, or UPI", and it
20
+ * filtered nothing: `listVerifiedScammers` read `q` and never passed it on,
21
+ * so `?q=zzzznope` returned every verified profile and the counter still read
22
+ * the full total. Only a nonsense term exposes that, because a real term also
23
+ * returns rows.
24
+ *
25
+ * Mirrors `listAll` below deliberately, including the empty-plan guard: a
26
+ * search that narrows to no usable token must return NOTHING rather than the
27
+ * whole registry โ€” failing open here would publish every profile to someone
28
+ * who searched for a single stop-word.
29
+ */
30
+ async listVerified(model, opts) {
31
+ const plan = planSearchTxt(opts?.search);
32
+ if (plan.empty)
33
+ return emptySearchResult();
34
+ let baseQuery = this.getCollection().where(SCAMMER_FIELDS.STATUS, "==", "verified");
35
+ if (plan.head) {
36
+ baseQuery = baseQuery.where(SCAMMER_FIELDS.SEARCH_TXT, "array-contains", plan.head);
37
+ }
38
+ const result = await this.sieveQuery(model, ScammerRepository.SIEVE_FIELDS, {
18
39
  baseQuery,
19
40
  defaultPageSize: 20,
20
41
  maxPageSize: 50,
21
42
  });
43
+ return refineSearchTxt(result, plan.rest);
22
44
  }
23
45
  /** Derived on every write path via `applyWriteHooks`. */
24
46
  buildSearchTxtFor(data) {
@@ -352,7 +352,17 @@ export function SellerOrdersView({ orderDetailApiBase = SELLER_ENDPOINTS.ORDERS,
352
352
  columns,
353
353
  mapRows: (response) => toRecordArray(response.orders).map((item, index) => {
354
354
  const itemsArr = Array.isArray(item.items) ? item.items : [];
355
- const firstItem = itemsArr[0] && typeof itemsArr[0] === "object" ? itemsArr[0] : {};
355
+ /*
356
+ * Same legacy fallback as AdminOrdersView: `items[]` is canonical and
357
+ * every real checkout writes it, but older documents carry
358
+ * `productTitle` at the top level instead, and reading only `items[]`
359
+ * leaves the row with no title or thumbnail at all.
360
+ */
361
+ const firstItem = itemsArr[0] && typeof itemsArr[0] === "object"
362
+ ? itemsArr[0]
363
+ : typeof item.productTitle === "string" && item.productTitle
364
+ ? { productTitle: item.productTitle, image: item.image }
365
+ : {};
356
366
  const loc = item.physicalLocation;
357
367
  return {
358
368
  id: toStringValue(item.id, `order-${index}`),
@@ -101,11 +101,20 @@ export function StoresIndexListing({ initialData }) {
101
101
  const maxProductCount = table.get("maxProductCount");
102
102
  const featured = table.get(TABLE_KEYS.FEATURED);
103
103
  const filterParts = [];
104
- if (ratingRaw) {
105
- const ratings = ratingRaw.split("|").filter(Boolean);
106
- if (ratings.length === 1)
107
- filterParts.push(sieveFilter("averageRating", SIEVE_OP.GTE, ratings[0]));
108
- }
104
+ /*
105
+ * ๐Ÿ›‘ The rating threshold is NOT a sieve clause.
106
+ *
107
+ * It used to emit `averageRating>=N`, a field the document does not have โ€”
108
+ * it nests as `stats.averageRating` โ€” so sievejs dropped it with
109
+ * `throwExceptions: false` and the facet returned every store. Measured:
110
+ * `?rating=5` listed stores rated well below 5.
111
+ *
112
+ * Correcting the NAME would not be enough and would be worse: a GTE
113
+ * inequality forces Firestore to order by that field first, so pairing it
114
+ * with any other sort demands a composite index nobody declares (Root Cause
115
+ * #59). The `rating` URL param reaches the route directly and the repository
116
+ * refines it in memory, which composes with every sort.
117
+ */
109
118
  if (minProductCount)
110
119
  filterParts.push(sieveFilter("stats.totalProducts", SIEVE_OP.GTE, minProductCount));
111
120
  if (maxProductCount)
@@ -119,6 +128,7 @@ export function StoresIndexListing({ initialData }) {
119
128
  sort: table.get(TABLE_KEYS.SORT) || undefined,
120
129
  category: table.get(TABLE_KEYS.CATEGORY) || undefined,
121
130
  filters: filterParts.length > 0 ? filterParts.join(",") : undefined,
131
+ rating: ratingRaw || undefined,
122
132
  }, { initialData });
123
133
  const selection = useBulkSelection({ items: stores, keyExtractor: (s) => s.id ?? s.storeSlug });
124
134
  // useBottomActions must run unconditionally on every render, once per
@@ -2,6 +2,21 @@ import React from "react";
2
2
  type SearchParams = Record<string, string | string[]>;
3
3
  export interface StoresIndexPageViewProps {
4
4
  searchParams?: SearchParams;
5
+ /**
6
+ * Restrict to verified stores and retitle the page. This is what /sellers is.
7
+ *
8
+ * /sellers rendered `<SellersListView />` with ZERO render props โ€” and every
9
+ * slot on `SlottedListingView` is optional, so it produced a correct header,
10
+ * a correct breadcrumb and then a blank region: Root Cause #8 exactly. That
11
+ * shell had one consumer, this page, and it was empty in it.
12
+ *
13
+ * Reusing this view rather than filling in the shell means /sellers inherits
14
+ * what /stores already got right โ€” the SSR `q` push-down, sandbox hiding,
15
+ * the ad slots and the shared listing โ€” instead of growing a second copy that
16
+ * has to be fixed twice.
17
+ */
18
+ verifiedOnly?: boolean;
19
+ heading?: string;
5
20
  }
6
- export declare function StoresIndexPageView({ searchParams }: StoresIndexPageViewProps): Promise<React.JSX.Element>;
21
+ export declare function StoresIndexPageView({ searchParams, verifiedOnly, heading, }: StoresIndexPageViewProps): Promise<React.JSX.Element>;
7
22
  export {};
@@ -5,11 +5,12 @@ import { AdSlot } from "../../homepage/components/AdSlot";
5
5
  import { StoresIndexListing } from "./StoresIndexListing";
6
6
  import { safeRead } from "../../../errors/safe-read";
7
7
  import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility";
8
+ import { STORE_FIELDS } from "../../../constants/field-names";
8
9
  function sp(params, key) {
9
10
  const v = params[key];
10
11
  return Array.isArray(v) ? v[0] ?? "" : v ?? "";
11
12
  }
12
- export async function StoresIndexPageView({ searchParams = {} }) {
13
+ export async function StoresIndexPageView({ searchParams = {}, verifiedOnly = false, heading = "Stores", }) {
13
14
  const sort = sp(searchParams, "sort") || "-createdAt";
14
15
  const page = Number(sp(searchParams, "page")) || 1;
15
16
  const pageSize = Number(sp(searchParams, "pageSize")) || 24;
@@ -29,7 +30,28 @@ export async function StoresIndexPageView({ searchParams = {} }) {
29
30
  * the same filters the client would, because it never gets a second chance.
30
31
  */
31
32
  const q = sp(searchParams, "q").trim();
32
- const result = await safeRead(() => storeRepository.listStores({ page, pageSize, sorts: sort }, true, q ? { search: q } : undefined), { route: "/stores", key: "stores.listStores", fallback: null });
33
+ /*
34
+ * The rating facet must be applied HERE too, not only by the client. This
35
+ * result is handed to <StoresIndexListing initialData=โ€ฆ>, and public listing
36
+ * hooks set staleTime: Infinity when given SSR data โ€” so an unfiltered first
37
+ * paint is frozen for that query key and never self-corrects (Root Cause #30).
38
+ */
39
+ const ratingRaw = sp(searchParams, "rating");
40
+ const minRating = ratingRaw
41
+ ? Math.max(...ratingRaw.split("|").map(Number).filter((n) => Number.isFinite(n)))
42
+ : undefined;
43
+ const result = await safeRead(() => storeRepository.listStores({
44
+ page,
45
+ pageSize,
46
+ sorts: sort,
47
+ ...(verifiedOnly ? { filters: `${STORE_FIELDS.IS_VERIFIED}==true` } : {}),
48
+ }, true, q || minRating !== undefined
49
+ ? { ...(q ? { search: q } : {}), ...(minRating !== undefined ? { minRating } : {}) }
50
+ : undefined), {
51
+ route: verifiedOnly ? "/sellers" : "/stores",
52
+ key: "stores.listStores",
53
+ fallback: null,
54
+ });
33
55
  /*
34
56
  * The API already filters this; the SSR page called the repository directly
35
57
  * and did not, so the first paint rendered a card for `store-tester-sandbox`
@@ -38,5 +60,5 @@ export async function StoresIndexPageView({ searchParams = {} }) {
38
60
  const publicResult = result
39
61
  ? { ...result, items: hidePublicTestData(result.items) }
40
62
  : result;
41
- return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, className: "mb-8", color: "primary", size: "3xl", weight: "semibold", children: "Stores" }), _jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-6" }), _jsx(StoresIndexListing, { initialData: publicResult ?? undefined }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] }) }) }));
63
+ return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, className: "mb-8", color: "primary", size: "3xl", weight: "semibold", children: heading }), _jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-6" }), _jsx(StoresIndexListing, { initialData: publicResult ?? undefined }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] }) }) }));
42
64
  }
@@ -15,6 +15,11 @@ export function useStores(params = {}, opts) {
15
15
  sp.set("sorts", params.sort);
16
16
  if (params.filters)
17
17
  sp.set("filters", params.filters);
18
+ // Rides as its own param, never inside `filters` โ€” the route refines it in
19
+ // memory because a GTE inequality would force Firestore to order by rating
20
+ // first and break every other sort (Root Cause #59).
21
+ if (params.rating)
22
+ sp.set("rating", params.rating);
18
23
  const qs = sp.toString();
19
24
  const endpoint = opts?.endpoint ?? `${STORE_ENDPOINTS.LIST}${qs ? `?${qs}` : ""}`;
20
25
  const { data, isLoading, error, refetch } = useQuery({
@@ -94,6 +94,7 @@ export declare class StoreRepository extends BaseRepository<StoreDocument> {
94
94
  protected buildSearchTxtFor(data: Record<string, JsonValue>): string[] | null;
95
95
  listStores(model: SieveModel, activeOnly?: boolean, opts?: {
96
96
  search?: string;
97
+ minRating?: number;
97
98
  }): Promise<FirebaseSieveResult<StoreDocument>>;
98
99
  /**
99
100
  * Admin: list all stores regardless of status.
@@ -197,6 +197,11 @@ export class StoreRepository extends BaseRepository {
197
197
  // the clause and the toggle did nothing at all. Root Cause #62's
198
198
  // EMITTED_BUT_UNFILTERABLE, live in production.
199
199
  [STORE_FIELDS.IS_FEATURED]: { canFilter: true, canSort: false },
200
+ // Same omission as IS_FEATURED above, found the same way: /sellers is a
201
+ // "Verified Sellers" page, and a filter on a field missing from this map
202
+ // is dropped silently by sievejs (`throwExceptions: false`) โ€” the page
203
+ // would have listed every store while claiming to list verified ones.
204
+ [STORE_FIELDS.IS_VERIFIED]: { canFilter: true, canSort: false },
200
205
  [STORE_FIELDS.CREATED_AT]: { canFilter: false, canSort: true },
201
206
  "stats.itemsSold": { canFilter: false, canSort: true },
202
207
  "stats.averageRating": { canFilter: false, canSort: true },
@@ -225,7 +230,31 @@ export class StoreRepository extends BaseRepository {
225
230
  // WhatsApp bearer token and a list has no use for it.
226
231
  mapDoc: (snap) => this.mapDocForList(snap),
227
232
  });
228
- return refineSearchTxt(result, plan.rest);
233
+ const searched = refineSearchTxt(result, plan.rest);
234
+ /*
235
+ * ๐Ÿ›‘ The rating threshold is applied IN MEMORY, deliberately.
236
+ *
237
+ * The facet emitted `averageRating>=N` โ€” a field name that does not exist
238
+ * (the document nests it as `stats.averageRating`), so sievejs dropped the
239
+ * clause with `throwExceptions: false` and the facet returned every store.
240
+ * Measured: `?rating=5` listed both stores, neither rated 5.
241
+ *
242
+ * Renaming it and setting `canFilter: true` would fix the drop and create a
243
+ * worse bug: a GTE inequality forces Firestore to order by that field
244
+ * first, so pairing it with any other sort demands a composite index nobody
245
+ * declares โ€” the FAILED_PRECONDITION trap of Root Cause #59, and exactly
246
+ * what the FAQ list was doing. Trading a silent wrong answer for a silent
247
+ * empty one is not progress.
248
+ *
249
+ * A threshold over a small, already-paged collection is cheap to refine
250
+ * here, needs no index, and composes with every sort. Same shape as the
251
+ * availability predicate, which is a per-row test for the same reason.
252
+ */
253
+ if (opts?.minRating === undefined)
254
+ return searched;
255
+ const min = opts.minRating;
256
+ const kept = searched.items.filter((store) => Number(store.stats?.averageRating ?? 0) >= min);
257
+ return { ...searched, items: kept, total: kept.length };
229
258
  }
230
259
  /**
231
260
  * Admin: list all stores regardless of status.
@@ -77,6 +77,8 @@ export interface StoreListParams {
77
77
  pageSize?: number;
78
78
  sort?: string;
79
79
  filters?: string;
80
+ /** Minimum average rating, pipe-joined when several chips are ticked. Refined in memory server-side. */
81
+ rating?: string;
80
82
  }
81
83
  export interface StoreAuctionsResponse {
82
84
  items: StoreAuctionItem[];
@@ -148,8 +148,6 @@ export const authored = {
148
148
  endResult: "All three links reach the same page. Set profile visibility back to Public afterwards, since other cases read this profile as another user.",
149
149
  },
150
150
  "checklist-account-auth-profile-settings-password-change-reset-link": {
151
- requiresHumanChannel: true,
152
- humanChannelReason: "real email inbox",
153
151
  roles: ["buyer"],
154
152
  startPage: "/user/settings",
155
153
  steps: [
@@ -28,8 +28,6 @@
28
28
  */
29
29
  export const authored = {
30
30
  "checklist-account-auth-signup-login-auth-email-links-single-use": {
31
- requiresHumanChannel: true,
32
- humanChannelReason: "real email inbox",
33
31
  roles: ["guest"],
34
32
  startPage: "/auth/forgot-password",
35
33
  steps: [
@@ -53,8 +51,6 @@ export const authored = {
53
51
  endResult: "Account state after the second click is identical to after the first. divya.funko@gmail.com is back on TempPass123! by the final step โ€” skipping that restore leaves the account unusable by every later case, since the seeder never resets an existing password.",
54
52
  },
55
53
  "checklist-account-auth-signup-login-auth-emails-sender-identity-and-inbox": {
56
- requiresHumanChannel: true,
57
- humanChannelReason: "real email inbox",
58
54
  roles: ["guest"],
59
55
  startPage: "/auth/register",
60
56
  steps: [
@@ -96,8 +92,6 @@ export const authored = {
96
92
  endResult: "Reloading /user shows 'QA Signup Tester' and the address used. The account persists โ€” this case creates a real row that a later teardown does not remove, which is why the address is numbered rather than fixed.",
97
93
  },
98
94
  "checklist-account-auth-signup-login-email-verify": {
99
- requiresHumanChannel: true,
100
- humanChannelReason: "real email inbox",
101
95
  roles: ["guest"],
102
96
  startPage: "/auth/register",
103
97
  steps: [
@@ -271,8 +265,6 @@ export const authored = {
271
265
  endResult: "Navigating to /user lands on /auth/login and no data for Vivaan Kapoor is rendered at any point. A back-button press does not restore the dashboard.",
272
266
  },
273
267
  "checklist-account-auth-signup-login-password-reset": {
274
- requiresHumanChannel: true,
275
- humanChannelReason: "real email inbox",
276
268
  roles: ["guest"],
277
269
  startPage: "/auth/login",
278
270
  steps: [
@@ -296,8 +288,6 @@ export const authored = {
296
288
  endResult: "The password is back at TempPass123! by the final step. That restore is mandatory: the seeder never resets an existing password, so skipping it leaves this account permanently signed out of every later case.",
297
289
  },
298
290
  "checklist-account-auth-signup-login-signup-verification-email-arrives": {
299
- requiresHumanChannel: true,
300
- humanChannelReason: "real email inbox",
301
291
  roles: ["guest"],
302
292
  startPage: "/auth/register",
303
293
  steps: [
@@ -211,8 +211,6 @@ export const authored = {
211
211
  endResult: "Read-only; send nothing.",
212
212
  },
213
213
  "checklist-admin-site-system-daily-digest-email-content": {
214
- requiresHumanChannel: true,
215
- humanChannelReason: "real email inbox",
216
214
  roles: ["admin"],
217
215
  startPage: "/admin/site",
218
216
  steps: [
@@ -239,8 +239,6 @@ export const authored = {
239
239
  endResult: "Read-only. If no rejected order exists, answer null rather than creating one โ€” rejecting as fraud bans a real account for seven days.",
240
240
  },
241
241
  "checklist-buying-my-orders-order-lifecycle-emails-arrive": {
242
- requiresHumanChannel: true,
243
- humanChannelReason: "real email inbox",
244
242
  roles: ["buyer"],
245
243
  startPage: "/user/orders",
246
244
  steps: [
@@ -384,8 +384,6 @@ export const authored = {
384
384
  endResult: "All four listings keep their type-specific fields. Restore any title that was changed.",
385
385
  },
386
386
  "checklist-buying-offers-notification-email-is-a-real-email": {
387
- requiresHumanChannel: true,
388
- humanChannelReason: "real email inbox",
389
387
  roles: ["buyer"],
390
388
  startPage: "/user/settings",
391
389
  steps: [
@@ -96,8 +96,6 @@ export const authored = {
96
96
  endResult: "Read-only; nothing persists beyond the URL.",
97
97
  },
98
98
  "checklist-content-discovery-notifications-notification-email-actually-arrives": {
99
- requiresHumanChannel: true,
100
- humanChannelReason: "real email inbox",
101
99
  roles: ["buyer"],
102
100
  startPage: "/user/settings",
103
101
  steps: [
@@ -36,8 +36,6 @@ export const authored = {
36
36
  endResult: "The account exists and persists. This case creates a real row that teardown does not remove, which is why the address is numbered rather than fixed.",
37
37
  },
38
38
  "checklist-public-pages-auth-error-pages-forgot-reset-password-pages": {
39
- requiresHumanChannel: true,
40
- humanChannelReason: "real email inbox",
41
39
  roles: ["guest"],
42
40
  startPage: "/auth/forgot-password",
43
41
  steps: [
@@ -60,8 +58,6 @@ export const authored = {
60
58
  endResult: "The password is back at TempPass123! by the final step. This case shares divya.funko@gmail.com with the single-use-link case in signup-login and with nothing else, deliberately โ€” the seeder never resets an existing password, so a changed one outlives every future re-seed.",
61
59
  },
62
60
  "checklist-public-pages-auth-error-pages-verify-email-page": {
63
- requiresHumanChannel: true,
64
- humanChannelReason: "real email inbox",
65
61
  roles: ["guest"],
66
62
  startPage: "/auth/register",
67
63
  steps: [
@@ -176,8 +176,6 @@ export const authored = {
176
176
  endResult: "Read-only; nothing persists.",
177
177
  },
178
178
  "checklist-selling-seller-orders-seller-new-order-notification-reaches-seller": {
179
- requiresHumanChannel: true,
180
- humanChannelReason: "real email inbox",
181
179
  roles: ["buyer", "seller"],
182
180
  startPage: "/products/product-beyblade-burst-valkyrie",
183
181
  steps: [
@@ -324,6 +324,30 @@ for (let i = _rawOrdersSeedData.length; i < 50; i++) {
324
324
  const qty = (i % 3) + 1;
325
325
  expandedOrders.push({
326
326
  id: generateOrderId(qty, daysBack, `${combo.userId}-${product.productId}-${i}`),
327
+ /*
328
+ * ๐Ÿ›‘ `items[]` IS THE CANONICAL SHAPE. The flat `productId`/`productTitle`
329
+ * pair below is legacy and is kept only for back-compat readers.
330
+ *
331
+ * This generator emitted the flat fields alone, so 44 of the 50 seeded
332
+ * orders had NO `items[]` โ€” and every order surface reads
333
+ * `items[0].productTitle` for its row label. The admin list therefore
334
+ * rendered "๐Ÿงพ Order order-1-20260822-aucwon" on 22 of 25 rows: a raw id
335
+ * beside a placeholder emoji, for orders whose product was known all along.
336
+ *
337
+ * Same family as Root Cause #60 โ€” a writer producing a shape no reader can
338
+ * render โ€” except the writer here is the seed, which is why it survived a
339
+ * fix to the readers.
340
+ */
341
+ items: [
342
+ {
343
+ productId: product.productId,
344
+ productTitle: product.productTitle,
345
+ listingType: "standard",
346
+ quantity: qty,
347
+ unitPrice: product.unitPrice,
348
+ totalPrice: product.unitPrice * qty,
349
+ },
350
+ ],
327
351
  productId: product.productId,
328
352
  productTitle: product.productTitle,
329
353
  userId: combo.userId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.41.9",
3
+ "version": "4.41.10",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"