@mohasinac/appkit 4.45.0 → 4.46.0
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/.build-stamp.json +4 -4
- package/dist/_internal/server/features/categories/live-counts.d.ts +41 -0
- package/dist/_internal/server/features/categories/live-counts.js +72 -0
- package/dist/_internal/server/features/checkout/expand-order-items.js +17 -0
- package/dist/_internal/server/features/orders/adapters.js +29 -0
- package/dist/_internal/server/features/parts/assembly.d.ts +126 -0
- package/dist/_internal/server/features/parts/assembly.js +246 -0
- package/dist/_internal/server/features/parts/data.d.ts +65 -0
- package/dist/_internal/server/features/parts/data.js +170 -0
- package/dist/_internal/server/features/parts/og.d.ts +34 -0
- package/dist/_internal/server/features/parts/og.js +66 -0
- package/dist/_internal/server/features/search/search-everything.d.ts +75 -0
- package/dist/_internal/server/features/search/search-everything.js +120 -0
- package/dist/_internal/server/features/search/search-sources.d.ts +17 -0
- package/dist/_internal/server/features/search/search-sources.js +138 -0
- package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.d.ts +70 -0
- package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.js +302 -0
- package/dist/_internal/server/jobs/core/countersReconcile.js +128 -19
- package/dist/_internal/server/jobs/core/jobRunners.js +12 -0
- package/dist/_internal/server/jobs/core/onCategoryWrite.js +73 -3
- package/dist/_internal/server/jobs/core/onProductWrite.js +93 -43
- package/dist/_internal/server/jobs/core/productViewWindows.d.ts +2 -0
- package/dist/_internal/server/jobs/core/productViewWindows.js +173 -0
- package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
- package/dist/_internal/server/jobs/core/testDataCleanup.js +27 -1
- package/dist/_internal/server/jobs/handlers/pageViewRollup.d.ts +20 -0
- package/dist/_internal/server/jobs/handlers/pageViewRollup.js +25 -1
- package/dist/_internal/shared/actions/action-registry.js +36 -0
- package/dist/_internal/shared/features/promotions/schema.d.ts +2 -2
- package/dist/_internal/shared/features/shipping/protections.d.ts +93 -1
- package/dist/_internal/shared/features/shipping/protections.js +127 -0
- package/dist/_internal/shared/search/part-aliases.d.ts +44 -0
- package/dist/_internal/shared/search/part-aliases.js +104 -0
- package/dist/constants/api-endpoints.d.ts +18 -12
- package/dist/constants/api-endpoints.js +6 -8
- package/dist/constants/field-names.d.ts +0 -2
- package/dist/constants/field-names.js +0 -2
- package/dist/constants/index.d.ts +1 -1
- package/dist/constants/index.js +1 -1
- package/dist/core/index.d.ts +0 -3
- package/dist/core/index.js +0 -2
- package/dist/errors/messages.d.ts +0 -7
- package/dist/errors/messages.js +0 -8
- package/dist/features/admin/components/AdminSectionsView.js +83 -32
- package/dist/features/admin/components/AdminSiteSettingsView.js +6 -2
- package/dist/features/admin/components/sections/sectionTemplates.d.ts +117 -0
- package/dist/features/admin/components/sections/sectionTemplates.js +213 -0
- package/dist/features/admin/schemas/firestore.d.ts +15 -0
- package/dist/features/admin/types/index.d.ts +0 -1
- package/dist/features/auth/permissions/constants.d.ts +1 -1
- package/dist/features/auth/permissions/constants.js +0 -2
- package/dist/features/categories/api/[id]/description-template/route.js +40 -1
- package/dist/features/categories/components/BrandDetailPageView.js +14 -3
- package/dist/features/categories/components/CategoryDetailPageView.js +89 -14
- package/dist/features/categories/components/CategoryTemplatePicker.js +9 -1
- package/dist/features/categories/repository/categories.repository.d.ts +65 -0
- package/dist/features/categories/repository/categories.repository.js +168 -22
- package/dist/features/categories/repository/category-counts.d.ts +143 -0
- package/dist/features/categories/repository/category-counts.js +224 -0
- package/dist/features/categories/schemas/category-counts.d.ts +155 -0
- package/dist/features/categories/schemas/category-counts.js +116 -0
- package/dist/features/categories/schemas/firestore.d.ts +0 -2
- package/dist/features/categories/schemas/firestore.js +0 -1
- package/dist/features/categories/utils/breadcrumb-chain.d.ts +194 -0
- package/dist/features/categories/utils/breadcrumb-chain.js +202 -0
- package/dist/features/corporate/schemas/index.d.ts +4 -4
- package/dist/features/orders/schemas/firestore.d.ts +13 -0
- package/dist/features/orders/types/index.d.ts +30 -0
- package/dist/features/parts/components/AssemblyDrawer.d.ts +107 -0
- package/dist/features/parts/components/AssemblyDrawer.js +250 -0
- package/dist/features/parts/components/ModelCompositionPanel.d.ts +37 -0
- package/dist/features/parts/components/ModelCompositionPanel.js +34 -0
- package/dist/features/parts/components/PartDetailPageView.d.ts +48 -0
- package/dist/features/parts/components/PartDetailPageView.js +20 -0
- package/dist/features/parts/components/PartsIndexPageView.d.ts +33 -0
- package/dist/features/parts/components/PartsIndexPageView.js +17 -0
- package/dist/features/parts/schemas/firestore.d.ts +257 -0
- package/dist/features/parts/schemas/firestore.js +181 -0
- package/dist/features/parts/utils/colourway-match.d.ts +62 -0
- package/dist/features/parts/utils/colourway-match.js +98 -0
- package/dist/features/parts/utils/part-breakdown.d.ts +53 -0
- package/dist/features/parts/utils/part-breakdown.js +77 -0
- package/dist/features/products/components/ProductDetailPageView.d.ts +14 -1
- package/dist/features/products/components/ProductDetailPageView.js +22 -3
- package/dist/features/products/components/ProductTabsShell.d.ts +3 -1
- package/dist/features/products/components/ProductTabsShell.js +4 -1
- package/dist/features/products/repository/category-assignment.d.ts +132 -0
- package/dist/features/products/repository/category-assignment.js +304 -0
- package/dist/features/products/repository/products.repository.d.ts +58 -1
- package/dist/features/products/repository/products.repository.js +255 -28
- package/dist/features/products/schemas/firestore.d.ts +147 -6
- package/dist/features/products/schemas/firestore.js +49 -0
- package/dist/features/search/components/SearchResultsPageView.d.ts +33 -0
- package/dist/features/search/components/SearchResultsPageView.js +21 -0
- package/dist/features/seller/components/SellerProductsView.js +129 -3
- package/dist/features/tester/seed-data/authored/admin__site-system.js +0 -15
- package/dist/features/tester/seed-data/authored/content-discovery__parts-database.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/content-discovery__parts-database.js +166 -0
- package/dist/features/tester/seed-data/authored/index.js +2 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +28 -1
- package/dist/index.d.ts +11 -16
- package/dist/index.js +26 -18
- package/dist/next/routing/route-map.d.ts +6 -2
- package/dist/next/routing/route-map.js +14 -1
- package/dist/providers/db-firebase/base.repository.d.ts +31 -0
- package/dist/providers/db-firebase/base.repository.js +31 -0
- package/dist/repositories/index.d.ts +1 -2
- package/dist/repositories/index.js +0 -1
- package/dist/seed/_helpers/category-content.d.ts +29 -0
- package/dist/seed/_helpers/category-content.js +98 -1
- package/dist/seed/_helpers/category-forest.js +104 -24
- package/dist/seed/_helpers/category-parts.d.ts +9 -0
- package/dist/seed/_helpers/category-parts.js +4594 -0
- package/dist/seed/carousel-slides-seed-data.js +81 -8
- package/dist/seed/categories-seed-data.js +44 -6
- package/dist/seed/homepage-sections-seed-data.js +170 -9
- package/dist/seed/index.d.ts +2 -0
- package/dist/seed/index.js +13 -0
- package/dist/seed/model-composition-seed-data.d.ts +15 -0
- package/dist/seed/model-composition-seed-data.js +8918 -0
- package/dist/seed/part-glossary-seed-data.d.ts +15 -0
- package/dist/seed/part-glossary-seed-data.js +10729 -0
- package/dist/seed/products-standard-seed-data.js +52 -0
- package/dist/seed/seed-purpose.d.ts +103 -4
- package/dist/seed/seed-purpose.js +162 -11
- package/dist/seo/json-ld.d.ts +10 -1
- package/dist/seo/json-ld.js +9 -1
- package/dist/server.d.ts +7 -0
- package/dist/server.js +11 -0
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/utils/id-generators.d.ts +30 -0
- package/dist/utils/id-generators.js +31 -1
- package/dist/utils/search-txt-builders.js +22 -0
- package/dist/utils/search-txt.d.ts +22 -0
- package/dist/utils/search-txt.js +17 -0
- package/dist/values/success-messages.d.ts +0 -4
- package/dist/values/success-messages.js +0 -5
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +292 -16
- package/dist/core/copilot-log.repository.d.ts +0 -44
- package/dist/core/copilot-log.repository.js +0 -61
- package/dist/features/copilot/components/AdminCopilotView.d.ts +0 -21
- package/dist/features/copilot/components/AdminCopilotView.js +0 -51
- package/dist/features/copilot/components/index.d.ts +0 -2
- package/dist/features/copilot/components/index.js +0 -1
- package/dist/features/copilot/hooks/useCopilotChat.d.ts +0 -25
- package/dist/features/copilot/hooks/useCopilotChat.js +0 -65
- package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +0 -15
- package/dist/features/copilot/hooks/useCopilotFeedback.js +0 -15
- package/dist/features/copilot/index.d.ts +0 -3
- package/dist/features/copilot/index.js +0 -3
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { normalizeError } from "../../../errors/normalize.js";
|
|
2
2
|
import { increment, serverTimestamp } from "../../../contracts/field-ops.js";
|
|
3
|
+
// 🛑 `../../../errors` re-exports the REAL ValidationError (errors/validation-error),
|
|
4
|
+
// which is the one `mapToHttpError` instanceof-checks to produce a 400. The
|
|
5
|
+
// unrelated same-named class under `_internal/shared/errors` falls through every
|
|
6
|
+
// classified branch to a generic 500 — Root Cause #53.
|
|
3
7
|
import { DatabaseError } from "../../../errors/index.js";
|
|
4
8
|
import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase/index.js";
|
|
5
9
|
import { cacheManager } from "../../../core/index.js";
|
|
@@ -19,6 +23,11 @@ import { PRODUCT_COLLECTION, ProductStatusValues } from "../schemas/index.js";
|
|
|
19
23
|
// reads: no repository in this codebase imports another feature's, because of
|
|
20
24
|
// the Turbopack import-chain trap (Root Cause #6/#18).
|
|
21
25
|
import { CATEGORIES_COLLECTION } from "../../categories/schemas/firestore.js";
|
|
26
|
+
import { CATEGORY_COUNTS_COLLECTION, sumCounts } from "../../categories/schemas/category-counts.js";
|
|
27
|
+
import { ENFORCE_LEAF_FILING, assertAssignmentShape, buildTaxonomyClosure, pickDefaultAssignment, readAssignments, } from "./category-assignment.js";
|
|
28
|
+
import { describeCountable, countsChanged, buildCountPatch, applyCountPatch, } from "../../categories/repository/category-counts.js";
|
|
29
|
+
// The ONE availability predicate — never a second copy (Root Cause #73).
|
|
30
|
+
import { isListingRowAvailable } from "../../../_internal/shared/listing-types/_registry.js";
|
|
22
31
|
import { TAX_CODES_COLLECTION } from "../../tax-codes/schemas/firestore.js";
|
|
23
32
|
// Collection id only, never the stores repository — no repository in this
|
|
24
33
|
// codebase imports another feature's (the Turbopack import-chain trap).
|
|
@@ -294,9 +303,8 @@ export class ProductRepository extends BaseRepository {
|
|
|
294
303
|
* either way. The two can only disagree when one of them is stale, and the
|
|
295
304
|
* derived one is the one that goes stale.
|
|
296
305
|
*/
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
null;
|
|
306
|
+
const assignments = readAssignments(input);
|
|
307
|
+
const leaf = assignments[0] ?? null;
|
|
300
308
|
/*
|
|
301
309
|
* The brand selector's option value is the brand ROW's id (`brand-beyblade`),
|
|
302
310
|
* but `ProductDocument.brand` is matched by DISPLAY NAME —
|
|
@@ -309,32 +317,91 @@ export class ProductRepository extends BaseRepository {
|
|
|
309
317
|
* left exactly as it was.
|
|
310
318
|
*/
|
|
311
319
|
const brandRef = input.brandSlug || input.brand || null;
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
320
|
+
/*
|
|
321
|
+
* ONE `getAll` for every assignment plus the brand, rather than a read per
|
|
322
|
+
* assignment. Rule #6 budgets ~3 sequential Firestore round-trips for a
|
|
323
|
+
* write path and this method is already one of them.
|
|
324
|
+
*/
|
|
325
|
+
const refs = [...assignments.map((id) => cats.doc(id)), ...(brandRef ? [cats.doc(brandRef)] : [])];
|
|
326
|
+
const snaps = refs.length > 0 ? await this.db.getAll(...refs) : [];
|
|
327
|
+
const assignmentSnaps = snaps.slice(0, assignments.length);
|
|
328
|
+
const brandSnap = brandRef ? (snaps[assignments.length] ?? null) : null;
|
|
329
|
+
const catSnap = assignmentSnaps[0] ?? null;
|
|
330
|
+
/*
|
|
331
|
+
* I1/I2 — resolve every assignment, refuse the disallowed shapes, pick the
|
|
332
|
+
* default, then derive the ordered closure from all of them.
|
|
333
|
+
*
|
|
334
|
+
* 🛑 A row that does not resolve is DROPPED from the assignment list and
|
|
335
|
+
* warned about, never silently kept: an unresolvable id cannot be checked
|
|
336
|
+
* against the one-per-root rule and cannot contribute a chain, so keeping
|
|
337
|
+
* it would put an id in the closure that resolves to no page.
|
|
338
|
+
*/
|
|
339
|
+
const resolved = [];
|
|
340
|
+
for (let i = 0; i < assignmentSnaps.length; i += 1) {
|
|
341
|
+
const snap = assignmentSnaps[i];
|
|
342
|
+
const id = assignments[i];
|
|
343
|
+
if (!snap?.exists) {
|
|
344
|
+
// Never destroy a chain we cannot improve on — see the `else if` below.
|
|
345
|
+
serverLogger.warn("Product category id does not resolve to a category", { leaf: id });
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const d = snap.data();
|
|
349
|
+
resolved.push({
|
|
350
|
+
id,
|
|
351
|
+
name: d.name,
|
|
352
|
+
rootId: d.rootId,
|
|
353
|
+
tier: d.tier,
|
|
354
|
+
isLeaf: d.isLeaf,
|
|
355
|
+
parentIds: d.parentIds ?? [],
|
|
356
|
+
ancestorNames: (d.ancestors ?? []).map((a) => a?.name ?? ""),
|
|
357
|
+
});
|
|
330
358
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
leaf,
|
|
359
|
+
if (resolved.length > 0) {
|
|
360
|
+
assertAssignmentShape(resolved, input.isLot === true, {
|
|
361
|
+
enforceLeafFiling: ENFORCE_LEAF_FILING,
|
|
362
|
+
warn: (msg, meta) => serverLogger.warn(msg, { productTitle: input.title, ...meta }),
|
|
336
363
|
});
|
|
364
|
+
/*
|
|
365
|
+
* The default's tie-break reads listing counts — and ONLY on a genuine
|
|
366
|
+
* tie, so a single-assignment product (the overwhelming majority) pays
|
|
367
|
+
* nothing for it. See `pickDefaultAssignment` for why depth, not count,
|
|
368
|
+
* is the first criterion.
|
|
369
|
+
*/
|
|
370
|
+
let counts;
|
|
371
|
+
const deepest = Math.max(...resolved.map((c) => c.tier ?? -1));
|
|
372
|
+
if (resolved.filter((c) => (c.tier ?? -1) === deepest).length > 1) {
|
|
373
|
+
counts = await this.readAssignmentCounts(resolved.map((c) => c.id));
|
|
374
|
+
}
|
|
375
|
+
const defaultIdx = pickDefaultAssignment(resolved, counts);
|
|
376
|
+
const closure = buildTaxonomyClosure(resolved, defaultIdx);
|
|
377
|
+
/*
|
|
378
|
+
* 🛑 `categoryIds` is written in the DEFAULT-FIRST order, not the order
|
|
379
|
+
* the seller picked, so it is parallel to `categoryIdNames` and index 0
|
|
380
|
+
* agrees with `categorySlugs[0]`. Three fields that must line up, lined
|
|
381
|
+
* up at the one place that produces all three.
|
|
382
|
+
*/
|
|
383
|
+
out.categoryIds = closure.categoryIdNames.length
|
|
384
|
+
? [resolved[defaultIdx].id, ...resolved.filter((_, i) => i !== defaultIdx).map((c) => c.id)]
|
|
385
|
+
: resolved.map((c) => c.id);
|
|
386
|
+
out.categorySlugs = closure.categorySlugs;
|
|
387
|
+
out.category = closure.categorySlugs[0];
|
|
388
|
+
if (closure.categoryNames.length)
|
|
389
|
+
out.categoryNames = closure.categoryNames;
|
|
390
|
+
if (closure.categoryIdNames.length)
|
|
391
|
+
out.categoryIdNames = closure.categoryIdNames;
|
|
337
392
|
}
|
|
393
|
+
/*
|
|
394
|
+
* 🛑 The old single-leaf block that stood here is GONE, and its two jobs
|
|
395
|
+
* moved for different reasons:
|
|
396
|
+
*
|
|
397
|
+
* - the chain + names build -> `buildTaxonomyClosure`, because it now has
|
|
398
|
+
* to span every assignment while keeping index 0 the default leaf;
|
|
399
|
+
* - the lot filing rule -> `assertAssignmentShape`, because it has to run
|
|
400
|
+
* per assignment and is now one half of a two-directional rule.
|
|
401
|
+
*
|
|
402
|
+
* Its `else if (leaf)` warn also moved, into the resolve loop above, where
|
|
403
|
+
* it fires per unresolvable assignment instead of only for the first.
|
|
404
|
+
*/
|
|
338
405
|
if (brandSnap?.exists) {
|
|
339
406
|
const brand = brandSnap.data();
|
|
340
407
|
if (brand.categoryType === "brand") {
|
|
@@ -349,6 +416,49 @@ export class ProductRepository extends BaseRepository {
|
|
|
349
416
|
await this.applyInclusivePricing(input, out);
|
|
350
417
|
return out;
|
|
351
418
|
}
|
|
419
|
+
/**
|
|
420
|
+
* Listing counts for a handful of category ids, for the default tie-break.
|
|
421
|
+
*
|
|
422
|
+
* 🛑 Called ONLY when two assignments tie at the deepest tier, so the common
|
|
423
|
+
* single-assignment write pays nothing. One `getAll` of at most
|
|
424
|
+
* `MAX_CATEGORY_ASSIGNMENTS` documents by id — no index, no query.
|
|
425
|
+
*
|
|
426
|
+
* 🛑 A missing row is OMITTED from the map rather than defaulted to 0.
|
|
427
|
+
* `categoryCounts` documents the same rule: `undefined` means "not counted
|
|
428
|
+
* yet", and defaulting it to zero would let an uncounted node lose a tie it
|
|
429
|
+
* might have won.
|
|
430
|
+
*
|
|
431
|
+
* Reads the collection through `this.db` rather than importing
|
|
432
|
+
* `categoryCountsRepository` — no repository here imports another feature's
|
|
433
|
+
* (Root Cause #6/#18), and a raw read by id has neither problem.
|
|
434
|
+
*/
|
|
435
|
+
async readAssignmentCounts(ids) {
|
|
436
|
+
const out = new Map();
|
|
437
|
+
if (ids.length === 0)
|
|
438
|
+
return out;
|
|
439
|
+
try {
|
|
440
|
+
const refs = ids.map((id) => this.db.collection(CATEGORY_COUNTS_COLLECTION).doc(id));
|
|
441
|
+
const snaps = await this.db.getAll(...refs);
|
|
442
|
+
for (const snap of snaps) {
|
|
443
|
+
if (!snap.exists)
|
|
444
|
+
continue;
|
|
445
|
+
const d = snap.data();
|
|
446
|
+
if (!d.total)
|
|
447
|
+
continue;
|
|
448
|
+
out.set(snap.id, sumCounts(d.total));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
/*
|
|
453
|
+
* A tie-break is not worth failing a product write over. Falling through
|
|
454
|
+
* with an empty map drops to level 3 — the order the seller picked — which
|
|
455
|
+
* is a deterministic answer rather than a wrong one.
|
|
456
|
+
*/
|
|
457
|
+
void normalizeError(err);
|
|
458
|
+
serverLogger.warn("Could not read category counts for the default tie-break", { ids });
|
|
459
|
+
}
|
|
460
|
+
return out;
|
|
461
|
+
}
|
|
352
462
|
/**
|
|
353
463
|
* Resolve the GST rate floor, and gross up an ex-tax price if the seller
|
|
354
464
|
* authors that way.
|
|
@@ -713,8 +823,58 @@ export class ProductRepository extends BaseRepository {
|
|
|
713
823
|
searchTxt: buildProductSearchTxt(merged),
|
|
714
824
|
});
|
|
715
825
|
this.cacheSet(updated);
|
|
826
|
+
await this.syncCategoryCounts(current ?? null, updated);
|
|
716
827
|
return updated;
|
|
717
828
|
}
|
|
829
|
+
/**
|
|
830
|
+
* G3b — move the leaf→root listing counters for one product transition.
|
|
831
|
+
*
|
|
832
|
+
* 🛑 Called from HERE rather than from a Firestore trigger because
|
|
833
|
+
* `FieldValue.increment` is atomic but **not idempotent** and a trigger is
|
|
834
|
+
* at-least-once: a redelivered trigger double-counts permanently and
|
|
835
|
+
* invisibly. See `repository/category-counts.ts` for the full argument.
|
|
836
|
+
*
|
|
837
|
+
* 🛑 And the guard is on (chain, leaf, type, isLot, AVAILABILITY) — not on
|
|
838
|
+
* the taxonomy alone. `draft → published` changes no `categorySlugs`, so a
|
|
839
|
+
* taxonomy-only guard would never move the count on the one transition that
|
|
840
|
+
* matters most, with nothing to notice.
|
|
841
|
+
*
|
|
842
|
+
* Availability comes from `isListingRowAvailable`, the SAME predicate the
|
|
843
|
+
* listing query and `countersReconcile` use, so the incremental and nightly
|
|
844
|
+
* values can agree. Four partial copies of that predicate is what Root Cause
|
|
845
|
+
* #73 cost; this module takes the verdict as an argument rather than
|
|
846
|
+
* recomputing it.
|
|
847
|
+
*/
|
|
848
|
+
async syncCategoryCounts(before, after) {
|
|
849
|
+
const now = new Date();
|
|
850
|
+
const side = (row) => ({
|
|
851
|
+
delta: row ? describeCountable(row) : null,
|
|
852
|
+
// A draft or archived row is not "available" and must not be counted —
|
|
853
|
+
// the count is of what a visitor can actually see, which is why the
|
|
854
|
+
// badge and the tab agree.
|
|
855
|
+
countsNow: !!row
|
|
856
|
+
&& row.status === ProductStatusValues.PUBLISHED
|
|
857
|
+
&& isListingRowAvailable(row, now),
|
|
858
|
+
});
|
|
859
|
+
const b = side(before);
|
|
860
|
+
const a = side(after);
|
|
861
|
+
if (!countsChanged(b, a))
|
|
862
|
+
return;
|
|
863
|
+
const patch = buildCountPatch([
|
|
864
|
+
{ delta: b.countsNow ? b.delta : null, sign: -1 },
|
|
865
|
+
{ delta: a.countsNow ? a.delta : null, sign: 1 },
|
|
866
|
+
]);
|
|
867
|
+
await applyCountPatch(this.db, patch, (err) => {
|
|
868
|
+
// Never fails the product write: a count is a display number, and losing
|
|
869
|
+
// a seller's publish over an ancestor counter would trade a cosmetic
|
|
870
|
+
// staleness for real data. The nightly reconciler's `drifted` is how a
|
|
871
|
+
// missed increment becomes visible.
|
|
872
|
+
serverLogger.warn("categoryCounts increment failed", {
|
|
873
|
+
productId: after?.id ?? before?.id,
|
|
874
|
+
error: err.message,
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
}
|
|
718
878
|
async create(input) {
|
|
719
879
|
// Slug = document ID: derive slug from title first, then ensure uniqueness.
|
|
720
880
|
const baseSlug = input.slug || slugify(input.title);
|
|
@@ -750,6 +910,7 @@ export class ProductRepository extends BaseRepository {
|
|
|
750
910
|
.set(prepareForFirestore(productData));
|
|
751
911
|
const created = { id, ...productData };
|
|
752
912
|
this.cacheSet(created);
|
|
913
|
+
await this.syncCategoryCounts(null, created);
|
|
753
914
|
return created;
|
|
754
915
|
}
|
|
755
916
|
async findByStore(storeId) {
|
|
@@ -767,6 +928,32 @@ export class ProductRepository extends BaseRepository {
|
|
|
767
928
|
.get();
|
|
768
929
|
return snap.docs.map((d) => this.mapDoc(d));
|
|
769
930
|
}
|
|
931
|
+
/**
|
|
932
|
+
* I4 — products ASSIGNED to this category, not merely descended from it.
|
|
933
|
+
*
|
|
934
|
+
* 🛑 The distinction is the whole point, and the category-delete cascade is
|
|
935
|
+
* what needs it. `findByCategory` matches the derived CLOSURE, so it also
|
|
936
|
+
* returns every product in the descendant subtree — those belong to the
|
|
937
|
+
* children and travel with them when the children are reparented.
|
|
938
|
+
* `findBy("category", id)` matches only the scalar, i.e. the DEFAULT leaf, so
|
|
939
|
+
* a product that named this category as its SECOND assignment was invisible
|
|
940
|
+
* and the delete left a dangling id in its `categoryIds`.
|
|
941
|
+
*
|
|
942
|
+
* This matches the assigned list, and falls back to the scalar for every row
|
|
943
|
+
* written before `categoryIds` existed — two queries, because Firestore
|
|
944
|
+
* permits one array operator per query and there is no OR across fields.
|
|
945
|
+
*/
|
|
946
|
+
async findByAssignment(categoryId) {
|
|
947
|
+
const [assigned, legacy] = await Promise.all([
|
|
948
|
+
this.getCollection().where("categoryIds", "array-contains", categoryId).get(),
|
|
949
|
+
this.getCollection().where(PRODUCT_FIELDS.CATEGORY, "==", categoryId).get(),
|
|
950
|
+
]);
|
|
951
|
+
const byId = new Map();
|
|
952
|
+
for (const d of [...assigned.docs, ...legacy.docs]) {
|
|
953
|
+
byId.set(d.id, this.mapDoc(d));
|
|
954
|
+
}
|
|
955
|
+
return [...byId.values()];
|
|
956
|
+
}
|
|
770
957
|
/**
|
|
771
958
|
* Published products sharing at least one tag with the given list — "you
|
|
772
959
|
* might also like" style discovery. `array-contains-any` caps at 10 values,
|
|
@@ -957,7 +1144,33 @@ export class ProductRepository extends BaseRepository {
|
|
|
957
1144
|
}
|
|
958
1145
|
async delete(id) {
|
|
959
1146
|
this.cacheInvalidateForId(id);
|
|
960
|
-
|
|
1147
|
+
/*
|
|
1148
|
+
* 🛑 Read BEFORE deleting, or there is no chain left to decrement and the
|
|
1149
|
+
* counters keep the row forever — the one case where the delta cannot be
|
|
1150
|
+
* recovered from the write itself. One extra read on a delete, which is
|
|
1151
|
+
* the rarest product mutation there is.
|
|
1152
|
+
*
|
|
1153
|
+
* `safeRead`-shaped on purpose: a product that cannot be read must still
|
|
1154
|
+
* be deletable, and the nightly reconciler corrects the count.
|
|
1155
|
+
*/
|
|
1156
|
+
let prior = null;
|
|
1157
|
+
try {
|
|
1158
|
+
prior = await super.findById(id);
|
|
1159
|
+
}
|
|
1160
|
+
catch (err) {
|
|
1161
|
+
const e = normalizeError(err);
|
|
1162
|
+
// 🛑 Logged, not swallowed. A failed pre-read means this product's
|
|
1163
|
+
// contribution stays on its ancestors' counters until the nightly
|
|
1164
|
+
// reconciler corrects them — bounded and self-healing, but invisible
|
|
1165
|
+
// unless it is said. `void normalizeError(err)` alone would look handled
|
|
1166
|
+
// while reporting nothing (audit-silent-degrade's normalize theatre).
|
|
1167
|
+
serverLogger.warn("product delete: pre-read failed, counts will drift until reconcile", {
|
|
1168
|
+
productId: id,
|
|
1169
|
+
error: e.message,
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
await super.delete(id);
|
|
1173
|
+
await this.syncCategoryCounts(prior, null);
|
|
961
1174
|
}
|
|
962
1175
|
/**
|
|
963
1176
|
* Shared scope construction for `list()` and `countBy*()` — both must narrow
|
|
@@ -1229,7 +1442,21 @@ ProductRepository.SIEVE_FIELDS = {
|
|
|
1229
1442
|
price: { canFilter: true, canSort: true },
|
|
1230
1443
|
stockQuantity: { canFilter: true, canSort: true },
|
|
1231
1444
|
availableQuantity: { canFilter: true, canSort: true },
|
|
1232
|
-
|
|
1445
|
+
/*
|
|
1446
|
+
* 🛑 D8 closes F1 #1. `viewCount` was `canFilter`/`canSort` here with NO
|
|
1447
|
+
* writer on any document — measured: absent from all 72 products and all 58
|
|
1448
|
+
* categories — and a Firestore `orderBy` EXCLUDES every document lacking
|
|
1449
|
+
* the ordering field, so `?sort=viewCount` returned zero rows with a valid
|
|
1450
|
+
* index present (Root Cause #100). A5 removed the UI sort and the reachable
|
|
1451
|
+
* field survived.
|
|
1452
|
+
*
|
|
1453
|
+
* Replaced by two fields that have a real nightly writer
|
|
1454
|
+
* (`runProductViewWindows`) and whose names state the window, which
|
|
1455
|
+
* `viewCount` never did — it read as a lifetime figure while nothing
|
|
1456
|
+
* maintained it.
|
|
1457
|
+
*/
|
|
1458
|
+
views7d: { canFilter: true, canSort: true },
|
|
1459
|
+
views30d: { canFilter: true, canSort: true },
|
|
1233
1460
|
currentBid: { canFilter: true, canSort: true },
|
|
1234
1461
|
bidCount: { canFilter: true, canSort: true },
|
|
1235
1462
|
bidsHaveStarted: { canFilter: true, canSort: false },
|
|
@@ -159,11 +159,57 @@ export interface ProductDocument extends BaseDocument {
|
|
|
159
159
|
seoTitle?: string;
|
|
160
160
|
seoDescription?: string;
|
|
161
161
|
seoKeywords?: string[];
|
|
162
|
-
/**
|
|
162
|
+
/**
|
|
163
|
+
* I1 — the ASSIGNED categories. What a human picked, and the only one of the
|
|
164
|
+
* three category fields that is authored rather than derived.
|
|
165
|
+
*
|
|
166
|
+
* At most `MAX_CATEGORY_ASSIGNMENTS` (4), and 🛑 **at most ONE per root** —
|
|
167
|
+
* "a category can be multiple, they should not be under same tree". A single
|
|
168
|
+
* files at LEAVES, a lot at NON-LEAVES. All of it is enforced in
|
|
169
|
+
* `assertAssignmentShape`, which `deriveTaxonomy` calls on every write path.
|
|
170
|
+
*
|
|
171
|
+
* `categoryIds[0]` is NOT the default — the default is the DEEPEST assignment
|
|
172
|
+
* (count breaks a tie), and `categorySlugs[0]` is what carries it.
|
|
173
|
+
*/
|
|
174
|
+
categoryIds?: string[];
|
|
175
|
+
/**
|
|
176
|
+
* I5b — DERIVED. One display name per assignment, parallel to `categoryIds`.
|
|
177
|
+
*
|
|
178
|
+
* Lets the breadcrumb row offer the OTHER categories as real links without a
|
|
179
|
+
* second read on a cached page. Deliberately not their full chains: the
|
|
180
|
+
* alternate crumb shows the other categories, never N stacked trails.
|
|
181
|
+
*/
|
|
182
|
+
categoryIdNames?: string[];
|
|
183
|
+
/**
|
|
184
|
+
* DERIVED closure over every assignment — the field every category QUERY
|
|
185
|
+
* matches on, via `array-contains` / `array-contains-any`.
|
|
186
|
+
*
|
|
187
|
+
* 🛑 ORDERED, and that is load-bearing: the DEFAULT chain first (so
|
|
188
|
+
* `categorySlugs[0]` is still the default leaf, which ~30 call sites read it
|
|
189
|
+
* as), then each remaining chain leaf-first, deduped. A set-shaped union
|
|
190
|
+
* would break all of them silently — see `category-assignment.ts`'s header.
|
|
191
|
+
*
|
|
192
|
+
* Never authored. An inbound value is treated as a legacy single leaf: only
|
|
193
|
+
* its HEAD is read, as one assignment.
|
|
194
|
+
*/
|
|
163
195
|
categorySlugs: string[];
|
|
164
|
-
/**
|
|
196
|
+
/**
|
|
197
|
+
* Display-name cache for the DEFAULT chain only, leaf-first.
|
|
198
|
+
*
|
|
199
|
+
* 🛑 Deliberately NOT parallel to `categorySlugs` once a product has two
|
|
200
|
+
* assignments: a union of two chains has no readable path, and the breadcrumb
|
|
201
|
+
* builder reverses this array to render root-first. Keeping it single-chain
|
|
202
|
+
* is what leaves every existing breadcrumb byte-identical.
|
|
203
|
+
*/
|
|
165
204
|
categoryNames?: string[];
|
|
166
|
-
/**
|
|
205
|
+
/**
|
|
206
|
+
* The default leaf, mirrored. 🛑 NOT dead and NOT read-only, whatever the
|
|
207
|
+
* `@deprecated` tag here said for a long time: it is in
|
|
208
|
+
* `PRODUCT_UPDATABLE_FIELDS`, it is what `CategoryInlineSelect` writes, it is
|
|
209
|
+
* the one category key `productCreateSchema` declares, and `deriveTaxonomy`
|
|
210
|
+
* prefers it over the derived chain. Sending it alone still means exactly
|
|
211
|
+
* "one assignment".
|
|
212
|
+
*/
|
|
167
213
|
category?: string;
|
|
168
214
|
/** @deprecated Read-only alias. */
|
|
169
215
|
categoryName?: string;
|
|
@@ -224,6 +270,101 @@ export interface ProductDocument extends BaseDocument {
|
|
|
224
270
|
* always accepted. See `_internal/shared/features/orders/return-reasons`.
|
|
225
271
|
*/
|
|
226
272
|
finalSale?: boolean;
|
|
273
|
+
/**
|
|
274
|
+
* This listing is a multi-item LOT rather than a single item.
|
|
275
|
+
*
|
|
276
|
+
* 🛑 A lot files under a NON-LEAF category and a single may file anywhere —
|
|
277
|
+
* enforced one-directionally in `ProductRepository.deriveTaxonomy`, which
|
|
278
|
+
* already holds the leaf document so the check costs no extra read. The
|
|
279
|
+
* reason is a claim about the ITEMS, not about counting: filing "12 assorted
|
|
280
|
+
* Burst beys" under `category-dragoon-g` asserts all twelve are Dragoon G.
|
|
281
|
+
*
|
|
282
|
+
* Counting is disambiguated separately, by `categoryCounts` carrying its own
|
|
283
|
+
* `lots` pair — so singles and lots are never summed into one number.
|
|
284
|
+
*
|
|
285
|
+
* A lot is the INVERSE of per-unit selling: many objects, one listing, one
|
|
286
|
+
* stock count. Two physically distinct units of one model are two listings
|
|
287
|
+
* each at `stockQuantity: 1` (see `unitLabel`), never one listing of two.
|
|
288
|
+
*/
|
|
289
|
+
isLot?: boolean;
|
|
290
|
+
/**
|
|
291
|
+
* How many items the lot contains. Drives the "lot of 12" label and the
|
|
292
|
+
* per-item price hint.
|
|
293
|
+
*
|
|
294
|
+
* Deliberately NOT required when `isLot` is true: a seller who knows it is a
|
|
295
|
+
* lot but has not counted the pile yet must still be able to save a draft.
|
|
296
|
+
*/
|
|
297
|
+
lotSize?: number;
|
|
298
|
+
/**
|
|
299
|
+
* Distinguishes two physically distinct units of the SAME model and the same
|
|
300
|
+
* catalogue variant — both white Dragoon G, one with a scuffed attack ring.
|
|
301
|
+
*
|
|
302
|
+
* 🛑 Referenced by `isLot`'s comment above since G3a and declared NOWHERE
|
|
303
|
+
* until now, so the rule that comment states had no field to state it about.
|
|
304
|
+
*
|
|
305
|
+
* 🛑 NOT `itemCode`, which is the grading axis and documents `"PSA 10"` as
|
|
306
|
+
* its example — a grade is a third party's claim about condition. A unit
|
|
307
|
+
* label is a disambiguator the seller chose.
|
|
308
|
+
*
|
|
309
|
+
* It exists because the alternative is what the crawl measured at
|
|
310
|
+
* thelimitlesscorner: one listing per physical unit with the condition
|
|
311
|
+
* defects welded into the TITLE.
|
|
312
|
+
*/
|
|
313
|
+
unitLabel?: string;
|
|
314
|
+
/**
|
|
315
|
+
* This listing is a COMPONENT, and this is which one.
|
|
316
|
+
*
|
|
317
|
+
* The `partGlossary` document id (`${slot}--${tail}`), stored whole.
|
|
318
|
+
*
|
|
319
|
+
* 🛑 A single id rather than the `{classification, code}` pair the plan
|
|
320
|
+
* proposed, and the departure is deliberate: a pair is a second place the
|
|
321
|
+
* key gets composed, and `partGlossaryId()` is already the one place that
|
|
322
|
+
* composes it. Two composers of one key is Root Cause #75's shape, and the
|
|
323
|
+
* id is also directly the URL (`/parts/{slot}/{tail}`) with no re-derivation.
|
|
324
|
+
*
|
|
325
|
+
* Optional even on a part listing: the glossary holds 753 rows and does not
|
|
326
|
+
* cover every component ever moulded, so a spare we cannot classify is still
|
|
327
|
+
* sellable and still countable.
|
|
328
|
+
*/
|
|
329
|
+
partGlossaryId?: string;
|
|
330
|
+
/**
|
|
331
|
+
* What distinguishes two physical units of the same part code — "pink",
|
|
332
|
+
* "translucent blue", "gold".
|
|
333
|
+
*
|
|
334
|
+
* 🛑 NOT a `productFeature`. That vocabulary is ~169 curated terms in 13
|
|
335
|
+
* groups and `colour` is not one of them, deliberately: colourways are
|
|
336
|
+
* open-ended and per-part, and pouring them into a shared vocabulary gives a
|
|
337
|
+
* facet with hundreds of single-use values — the long-tail-tag anti-pattern
|
|
338
|
+
* this project REJECTED when redeyemerch was crawled (50–160 tags per
|
|
339
|
+
* product, 162 products on one tag).
|
|
340
|
+
*
|
|
341
|
+
* 🛑 Free text, and the VOCABULARY is deliberately not seeded. The corpus
|
|
342
|
+
* carries part names, codes and weights and no colours at all, so a seeded
|
|
343
|
+
* list would be invented. It accumulates from real spares (GAP-8: data
|
|
344
|
+
* entry, not code).
|
|
345
|
+
*/
|
|
346
|
+
colourway?: string;
|
|
347
|
+
/** Mould/version variant — the "storm pegasus version" of a shared part. */
|
|
348
|
+
mouldVariant?: string;
|
|
349
|
+
/**
|
|
350
|
+
* Built HERE from components. Each entry decremented a part listing's stock.
|
|
351
|
+
*
|
|
352
|
+
* 🛑 The decrement must happen in a real transaction that RE-READS, and it
|
|
353
|
+
* must read `availableQuantity` rather than `stockQuantity`: a component with
|
|
354
|
+
* 10 in stock and 3 reserved has SEVEN assemblable, and assembling from the
|
|
355
|
+
* wrong field builds a bey out of parts somebody has already bought. The
|
|
356
|
+
* PhonePe path's `runBatch` over a snapshot read outside any transaction is
|
|
357
|
+
* the oversell this avoids, five components at a time.
|
|
358
|
+
*/
|
|
359
|
+
assembledFrom?: Array<{
|
|
360
|
+
slotKey: string;
|
|
361
|
+
productId: string;
|
|
362
|
+
quantity: number;
|
|
363
|
+
}>;
|
|
364
|
+
/** Views in the trailing 7 days. */
|
|
365
|
+
views7d?: number;
|
|
366
|
+
/** Views in the trailing 30 days. */
|
|
367
|
+
views30d?: number;
|
|
227
368
|
condition?: "new" | "like_new" | "good" | "fair" | "poor" | "used" | "refurbished" | "broken" | "graded";
|
|
228
369
|
insurance?: boolean;
|
|
229
370
|
insuranceCost?: number;
|
|
@@ -505,10 +646,10 @@ export declare const ProductStatusValues: {
|
|
|
505
646
|
readonly ARCHIVED: "archived";
|
|
506
647
|
};
|
|
507
648
|
export declare const PRODUCT_COLLECTION: "products";
|
|
508
|
-
export declare const PRODUCT_INDEXED_FIELDS: readonly ["storeId", "status", "category", "featured", "listingType", "isPromoted", "isOnSale", "isSold", "searchTxt", "createdAt", "barcodeId", "physicalLocation.zone"];
|
|
649
|
+
export declare const PRODUCT_INDEXED_FIELDS: readonly ["storeId", "status", "category", "categoryIds", "featured", "listingType", "isPromoted", "isOnSale", "isSold", "searchTxt", "createdAt", "barcodeId", "physicalLocation.zone"];
|
|
509
650
|
export declare const DEFAULT_PRODUCT_DATA: Partial<ProductDocument>;
|
|
510
|
-
export declare const PRODUCT_PUBLIC_FIELDS: readonly ["allowOffers", "minOfferPercent", "id", "title", "description", "category", "subcategory", "brand", "price", "currency", "stockQuantity", "availableQuantity", "images", "status", "storeName", "featured", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "finalSale", "listingType", "auctionEndDate", "startingBid", "currentBid", "bidCount", "reservePrice", "buyNowPrice", "minBidIncrement", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderCurrentCount", "preOrderProductionStatus", "preOrderCancellable", "condition", "insurance", "insuranceCost", "shippingPaidBy", "isPromoted", "isOnSale", "isSold", "slug", "seoTitle", "seoDescription", "seoKeywords", "viewCount", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "createdAt"];
|
|
511
|
-
export declare const PRODUCT_UPDATABLE_FIELDS: readonly ["title", "description", "category", "subcategory", "brand", "price", "stockQuantity", "images", "status", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "finalSale", "pickupAddressId", "condition", "insurance", "shippingPaidBy", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "reservePrice", "buyNowPrice", "minBidIncrement", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "listingType", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderProductionStatus", "preOrderCancellable", "isOnSale", "isSold", "allowShipBeforeEmiComplete", "gstRate", "hsnCode", "authenticity", "printMeta", "seoTitle", "seoDescription", "seoKeywords", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "barcodeId"];
|
|
651
|
+
export declare const PRODUCT_PUBLIC_FIELDS: readonly ["allowOffers", "minOfferPercent", "id", "title", "description", "category", "categoryIds", "categoryIdNames", "subcategory", "brand", "price", "currency", "stockQuantity", "availableQuantity", "images", "status", "storeName", "featured", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "finalSale", "isLot", "lotSize", "unitLabel", "partGlossaryId", "colourway", "mouldVariant", "assembledFrom", "views7d", "views30d", "unitLabel", "partGlossaryId", "colourway", "mouldVariant", "assembledFrom", "listingType", "auctionEndDate", "startingBid", "currentBid", "bidCount", "reservePrice", "buyNowPrice", "minBidIncrement", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderCurrentCount", "preOrderProductionStatus", "preOrderCancellable", "condition", "insurance", "insuranceCost", "shippingPaidBy", "isPromoted", "isOnSale", "isSold", "slug", "seoTitle", "seoDescription", "seoKeywords", "viewCount", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "createdAt"];
|
|
652
|
+
export declare const PRODUCT_UPDATABLE_FIELDS: readonly ["title", "description", "category", "categoryIds", "subcategory", "brand", "price", "stockQuantity", "images", "status", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "finalSale", "isLot", "lotSize", "pickupAddressId", "condition", "insurance", "shippingPaidBy", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "reservePrice", "buyNowPrice", "minBidIncrement", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "listingType", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderProductionStatus", "preOrderCancellable", "isOnSale", "isSold", "allowShipBeforeEmiComplete", "gstRate", "hsnCode", "authenticity", "printMeta", "seoTitle", "seoDescription", "seoKeywords", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "barcodeId"];
|
|
512
653
|
export type ProductCreateInput = Omit<ProductDocument, "id" | "createdAt" | "updatedAt" | "availableQuantity" | "bidCount" | "currentBid" | "auctionOriginalEndDate">;
|
|
513
654
|
export type ProductUpdateInput = Partial<Pick<ProductDocument, (typeof PRODUCT_UPDATABLE_FIELDS)[number]>>;
|
|
514
655
|
export type ProductAdminUpdateInput = Partial<Omit<ProductDocument, "id" | "createdAt">>;
|
|
@@ -16,6 +16,10 @@ export const PRODUCT_INDEXED_FIELDS = [
|
|
|
16
16
|
"storeId",
|
|
17
17
|
"status",
|
|
18
18
|
"category",
|
|
19
|
+
/* Indexed: `array-contains` on the ASSIGNED list is what the category
|
|
20
|
+
* delete cascade queries to find a product filed here as its SECOND
|
|
21
|
+
* category — `findBy("category", id)` sees only the default leaf. */
|
|
22
|
+
"categoryIds",
|
|
19
23
|
"featured",
|
|
20
24
|
"listingType",
|
|
21
25
|
"isPromoted",
|
|
@@ -74,6 +78,9 @@ export const PRODUCT_PUBLIC_FIELDS = [
|
|
|
74
78
|
"title",
|
|
75
79
|
"description",
|
|
76
80
|
"category",
|
|
81
|
+
/* Public: the chips on a detail page render the assignments. */
|
|
82
|
+
"categoryIds",
|
|
83
|
+
"categoryIdNames",
|
|
77
84
|
"subcategory",
|
|
78
85
|
"brand",
|
|
79
86
|
"price",
|
|
@@ -92,6 +99,35 @@ export const PRODUCT_PUBLIC_FIELDS = [
|
|
|
92
99
|
// Buyer-facing: drives the "Final Sale" card pill and the detail-page
|
|
93
100
|
// Delivery & Returns line. Absent means final sale — read via isFinalSale().
|
|
94
101
|
"finalSale",
|
|
102
|
+
// Buyer-facing by necessity: "lot of 12" is the single most important thing
|
|
103
|
+
// about a lot listing, and a buyer who cannot see it thinks they are bidding
|
|
104
|
+
// on one item.
|
|
105
|
+
"isLot",
|
|
106
|
+
"lotSize",
|
|
107
|
+
/* D13/D14 — buyer-facing by definition: a colourway is WHY someone buys that
|
|
108
|
+
* particular spare, and a unit label is how they tell two identical listings
|
|
109
|
+
* apart. `assembledFrom` is listed too — it names product ids that are
|
|
110
|
+
* already public listings, and "built from these parts" is provenance a
|
|
111
|
+
* buyer should see. */
|
|
112
|
+
"unitLabel",
|
|
113
|
+
"partGlossaryId",
|
|
114
|
+
"colourway",
|
|
115
|
+
"mouldVariant",
|
|
116
|
+
"assembledFrom",
|
|
117
|
+
/* D8 — public because "N people viewed this this week" is the honest social
|
|
118
|
+
* proof H2 is built on, and it is a figure we measured. 🛑 Deliberately NOT
|
|
119
|
+
* in PRODUCT_UPDATABLE_FIELDS: a seller who could write their own view count
|
|
120
|
+
* turns a measurement into a claim. */
|
|
121
|
+
"views7d",
|
|
122
|
+
"views30d",
|
|
123
|
+
/* 🛑 D13/D14. Listed here or an update STRIPS them: the write schema has no
|
|
124
|
+
* `.passthrough()`, so a field absent from this list is dropped before it
|
|
125
|
+
* reaches Firestore while the write still returns 200 (Root Cause #101). */
|
|
126
|
+
"unitLabel",
|
|
127
|
+
"partGlossaryId",
|
|
128
|
+
"colourway",
|
|
129
|
+
"mouldVariant",
|
|
130
|
+
"assembledFrom",
|
|
95
131
|
"listingType",
|
|
96
132
|
"auctionEndDate",
|
|
97
133
|
"startingBid",
|
|
@@ -147,6 +183,12 @@ export const PRODUCT_UPDATABLE_FIELDS = [
|
|
|
147
183
|
"title",
|
|
148
184
|
"description",
|
|
149
185
|
"category",
|
|
186
|
+
/* I1 — the ASSIGNED list. 🛑 Named here or an update STRIPS it while still
|
|
187
|
+
* returning 200 (Root Cause #101), which is exactly how a UI-created
|
|
188
|
+
* product lost its chain. `categorySlugs`/`categoryNames` stay OUT: they
|
|
189
|
+
* are derived, and a caller that could set them could desync the closure
|
|
190
|
+
* from the assignments it is supposed to be the closure OF. */
|
|
191
|
+
"categoryIds",
|
|
150
192
|
"subcategory",
|
|
151
193
|
"brand",
|
|
152
194
|
"price",
|
|
@@ -159,6 +201,13 @@ export const PRODUCT_UPDATABLE_FIELDS = [
|
|
|
159
201
|
"shippingInfo",
|
|
160
202
|
"returnPolicy",
|
|
161
203
|
"finalSale",
|
|
204
|
+
/* 🛑 Listed here or an update STRIPS them. The write schema has no
|
|
205
|
+
* `.passthrough()`, so a field absent from this list is dropped before it
|
|
206
|
+
* reaches Firestore while the write still returns 200 — exactly how a
|
|
207
|
+
* UI-created product lost its `categorySlugs` chain (Root Cause #101) and how
|
|
208
|
+
* `compareAtPrice` is silently discarded today (F1 #25). */
|
|
209
|
+
"isLot",
|
|
210
|
+
"lotSize",
|
|
162
211
|
"pickupAddressId",
|
|
163
212
|
"condition",
|
|
164
213
|
"insurance",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D2 Fix 4 — the cross-resource search RESULTS page.
|
|
3
|
+
*
|
|
4
|
+
* Replaces a pure redirect. Before this, `/search` mapped each of 15
|
|
5
|
+
* `SearchResourceType` values to a browse route and `redirect()`ed, so a buyer
|
|
6
|
+
* searching "dranzer" could not see that we have a product, a category, a blog
|
|
7
|
+
* post and an FAQ about it — the dropdown only chose which single listing page
|
|
8
|
+
* to send them to.
|
|
9
|
+
*
|
|
10
|
+
* Measured on production: "burst" returns 21 hits across SIX groups
|
|
11
|
+
* (listings, categories, brands, stores, articles, FAQs); "zzzznope" and "!!!"
|
|
12
|
+
* return none.
|
|
13
|
+
*
|
|
14
|
+
* 🛑 STILL `noindex`, and that is deliberate rather than inherited. A search
|
|
15
|
+
* results page is the classic crawl trap: one URL per query string, thin
|
|
16
|
+
* content, and no canonical answer to index. The redirect it replaces was
|
|
17
|
+
* `noindex` too, so this changes the page's usefulness to a HUMAN without
|
|
18
|
+
* changing its relationship to a crawler. The `moreHref` links go to the real
|
|
19
|
+
* indexable listing pages.
|
|
20
|
+
*
|
|
21
|
+
* 🛑 NO PRICES ANYWHERE. Prices are hidden from signed-out visitors and this
|
|
22
|
+
* is the one surface where gating every row would be most of the markup.
|
|
23
|
+
* A title plus a link is honest, needs no `<GatedPrice>`, and cannot leak —
|
|
24
|
+
* there is nothing on the row to leak.
|
|
25
|
+
*/
|
|
26
|
+
import React from "react";
|
|
27
|
+
import type { SearchEverythingResult } from "../../../_internal/server/features/search/search-everything";
|
|
28
|
+
export interface SearchResultsPageViewProps {
|
|
29
|
+
result: SearchEverythingResult;
|
|
30
|
+
/** Rendered above the groups — the consumer owns the input control. */
|
|
31
|
+
renderSearchInput?: () => React.ReactNode;
|
|
32
|
+
}
|
|
33
|
+
export declare function SearchResultsPageView({ result, renderSearchInput, }: SearchResultsPageViewProps): React.JSX.Element;
|