@mohasinac/appkit 4.41.9 โ 4.41.11
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/orders/adapters.js +22 -1
- package/dist/_internal/server/jobs/core/countersReconcile.js +8 -0
- package/dist/_internal/server/jobs/core/onProductWrite.js +25 -2
- package/dist/client.d.ts +1 -0
- package/dist/client.js +8 -0
- package/dist/features/admin/components/AdminCarouselGroupEditorView.js +17 -1
- package/dist/features/admin/components/AdminOrdersView.js +28 -1
- package/dist/features/faq/components/FAQPageContent.js +16 -3
- package/dist/features/scams/actions/scam-actions.js +12 -1
- package/dist/features/scams/repository/scammer.repository.d.ts +18 -1
- package/dist/features/scams/repository/scammer.repository.js +25 -3
- package/dist/features/seller/components/SellerOrdersView.js +11 -1
- package/dist/features/stores/components/StoresIndexListing.js +15 -5
- package/dist/features/stores/components/StoresIndexPageView.d.ts +16 -1
- package/dist/features/stores/components/StoresIndexPageView.js +25 -3
- package/dist/features/stores/hooks/useStores.js +5 -0
- package/dist/features/stores/repository/store.repository.d.ts +1 -0
- package/dist/features/stores/repository/store.repository.js +30 -1
- package/dist/features/stores/types/index.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/account-auth__profile-settings.js +0 -2
- package/dist/features/tester/seed-data/authored/account-auth__signup-login.js +0 -10
- package/dist/features/tester/seed-data/authored/admin__site-system.js +0 -2
- package/dist/features/tester/seed-data/authored/buying__my-orders.js +0 -2
- package/dist/features/tester/seed-data/authored/buying__offers.js +0 -2
- package/dist/features/tester/seed-data/authored/content-discovery__notifications.js +0 -2
- package/dist/features/tester/seed-data/authored/public-pages__auth-error-pages.js +0 -4
- package/dist/features/tester/seed-data/authored/selling__seller-orders.js +0 -2
- package/dist/seed/orders-seed-data.js +54 -1
- 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
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
|
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
|
|
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 })) :
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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: [
|
|
@@ -40,6 +40,33 @@ import { withOrderSearchTxt } from "./_helpers/search-txt-wrappers";
|
|
|
40
40
|
import { seedPhoto } from "./_helpers/media";
|
|
41
41
|
const NOW = new Date();
|
|
42
42
|
const daysAgo = (n) => new Date(NOW.getTime() - n * 86400000);
|
|
43
|
+
/**
|
|
44
|
+
* The anchor the order IDs are built from โ fixed, never `NOW`.
|
|
45
|
+
*
|
|
46
|
+
* ๐ An ID may not contain a value that moves. `generateOrderId` embeds a
|
|
47
|
+
* `yyyymmdd` derived from its anchor, so with `NOW` every order's id changed on
|
|
48
|
+
* every calendar day: `appkit-seed load` CREATED 50 fresh documents instead of
|
|
49
|
+
* upserting the same 50, and `status`/`delete` computed ids that had never been
|
|
50
|
+
* written.
|
|
51
|
+
*
|
|
52
|
+
* Measured immediately after a successful `load --collections orders` that
|
|
53
|
+
* reported "created 50, errors 0": `appkit-seed status` answered
|
|
54
|
+
* **`orders 50 seed / 0 in db`**. Both numbers were right; they were about
|
|
55
|
+
* different sets of ids.
|
|
56
|
+
*
|
|
57
|
+
* This is exactly the case Recurrent Root Cause #25 names โ "`Date.now()` used
|
|
58
|
+
* as an ID component (as opposed to just a display timestamp)". The
|
|
59
|
+
* distinction is the whole fix:
|
|
60
|
+
*
|
|
61
|
+
* - **IDs** anchor to `SEED_EPOCH`, so they are reproducible forever.
|
|
62
|
+
* - **Display timestamps** (`orderDate`, `createdAt`, shipping dates) keep
|
|
63
|
+
* using `daysAgo()` off `NOW`, so a reseeded catalogue still looks recent
|
|
64
|
+
* and time-bound fixtures re-arm, which is what the tester window needs.
|
|
65
|
+
*
|
|
66
|
+
* It also broke every checklist case that names an order by id: they were
|
|
67
|
+
* authored against one day's ids and could never resolve afterwards.
|
|
68
|
+
*/
|
|
69
|
+
const SEED_EPOCH = new Date("2026-01-01T00:00:00.000Z");
|
|
43
70
|
// Deterministic per-product thumbnail โ picsum.photos returns a stable image
|
|
44
71
|
// for any seed string, so this doesn't need to match the real product seed's
|
|
45
72
|
// own image, just be present and consistent across reseeds. Backfills the
|
|
@@ -64,7 +91,9 @@ function seededSuffix(seed) {
|
|
|
64
91
|
return (hash >>> 0).toString(36).padStart(6, "0").slice(0, 6);
|
|
65
92
|
}
|
|
66
93
|
const generateOrderId = (itemCount, daysBack, seed) => {
|
|
67
|
-
|
|
94
|
+
// SEED_EPOCH, not NOW โ see the block comment on SEED_EPOCH. An id that
|
|
95
|
+
// moves with the calendar is an id that never upserts.
|
|
96
|
+
const date = new Date(SEED_EPOCH.getTime() - daysBack * 86400000);
|
|
68
97
|
const yyyymmdd = date.toISOString().split("T")[0].replace(/-/g, "");
|
|
69
98
|
const rand = seededSuffix(`${itemCount}-${daysBack}-${seed}`);
|
|
70
99
|
return `order-${itemCount}-${yyyymmdd}-${rand}`;
|
|
@@ -324,6 +353,30 @@ for (let i = _rawOrdersSeedData.length; i < 50; i++) {
|
|
|
324
353
|
const qty = (i % 3) + 1;
|
|
325
354
|
expandedOrders.push({
|
|
326
355
|
id: generateOrderId(qty, daysBack, `${combo.userId}-${product.productId}-${i}`),
|
|
356
|
+
/*
|
|
357
|
+
* ๐ `items[]` IS THE CANONICAL SHAPE. The flat `productId`/`productTitle`
|
|
358
|
+
* pair below is legacy and is kept only for back-compat readers.
|
|
359
|
+
*
|
|
360
|
+
* This generator emitted the flat fields alone, so 44 of the 50 seeded
|
|
361
|
+
* orders had NO `items[]` โ and every order surface reads
|
|
362
|
+
* `items[0].productTitle` for its row label. The admin list therefore
|
|
363
|
+
* rendered "๐งพ Order order-1-20260822-aucwon" on 22 of 25 rows: a raw id
|
|
364
|
+
* beside a placeholder emoji, for orders whose product was known all along.
|
|
365
|
+
*
|
|
366
|
+
* Same family as Root Cause #60 โ a writer producing a shape no reader can
|
|
367
|
+
* render โ except the writer here is the seed, which is why it survived a
|
|
368
|
+
* fix to the readers.
|
|
369
|
+
*/
|
|
370
|
+
items: [
|
|
371
|
+
{
|
|
372
|
+
productId: product.productId,
|
|
373
|
+
productTitle: product.productTitle,
|
|
374
|
+
listingType: "standard",
|
|
375
|
+
quantity: qty,
|
|
376
|
+
unitPrice: product.unitPrice,
|
|
377
|
+
totalPrice: product.unitPrice * qty,
|
|
378
|
+
},
|
|
379
|
+
],
|
|
327
380
|
productId: product.productId,
|
|
328
381
|
productTitle: product.productTitle,
|
|
329
382
|
userId: combo.userId,
|