@mohasinac/appkit 4.42.9 → 4.42.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.
@@ -38,7 +38,31 @@ export const getProductForDetail = cache(async (slugOrId) => {
38
38
  * `pageViews` is the single counter now. `viewCount` on the document stays
39
39
  * for historical rows; nothing increments it.
40
40
  */
41
- return (await productRepository.findByIdOrSlug(slugOrId)) ?? null;
41
+ const product = await productRepository.findByIdOrSlug(slugOrId);
42
+ if (!product)
43
+ return null;
44
+ /*
45
+ * 🛑 Status gate — added 2026-10-02 alongside the identical one in
46
+ * `makeGetListingForDetail`, which covers the other six listing types.
47
+ *
48
+ * Without it, an archived or rejected listing kept rendering in full at its
49
+ * own public URL, signed out, with a live CTA — measured on production. This
50
+ * is the standard, art and sticker half of that leak.
51
+ *
52
+ * `isPubliclyVisible` is the predicate every public LIST query already uses:
53
+ * it treats a MISSING status as visible, so pre-field documents survive, and
54
+ * it says nothing about availability — a sold-out product must still render
55
+ * with its "Out of Stock" chrome rather than 404.
56
+ *
57
+ * It is handed the one field it reads, not the whole document: its row type
58
+ * is an index-signature `Record` so that one predicate can serve Firestore
59
+ * docs, the Function's ISO-string rows and API JSON, and a declared
60
+ * interface does not satisfy that. Passing `{ status }` keeps it type-safe
61
+ * where a cast would only silence the check (Root Cause #98).
62
+ */
63
+ if (!isPubliclyVisible({ status: product.status }))
64
+ return null;
65
+ return product;
42
66
  });
43
67
  /** Fetch the first page of approved reviews for a product, deduped per request. */
44
68
  export const getReviewsForProduct = cache(async (productId, limit = REVIEWS_DETAIL_PAGE_SIZE) => {
@@ -2,8 +2,31 @@ import { cache } from "react";
2
2
  import { productRepository } from "../../../../repositories";
3
3
  import { safeRead } from "../../../../errors/safe-read";
4
4
  import { loadProductFeaturesForStore } from "../../../../features/products/repository/loadProductFeatures";
5
+ import { isPubliclyVisible } from "../../../shared/listing-types/availability";
5
6
  // Returns a React.cache()-wrapped fetch gated on one listing type.
6
7
  // The guard prevents cross-type URL collisions (e.g. /auctions/classified-slug returning wrong product).
8
+ //
9
+ // 🛑 THE STATUS GATE IS LOAD-BEARING, AND ITS ABSENCE WAS A LIVE LEAK.
10
+ //
11
+ // Until 2026-10-02 this checked existence and listing type only. Measured on
12
+ // production, signed out: an admin archiving OR rejecting a classified did NOT
13
+ // remove it from /classified/{slug} — the page rendered in full, h1, description,
14
+ // seller card and a live "Request to Buy" CTA. Deleting the product 404s the same
15
+ // URL immediately, so the route reads live data; it simply had no status
16
+ // predicate. A listing rejected as fraudulent stayed publicly buyable at its own
17
+ // URL, and because the admin row updated correctly every time, the moderator had
18
+ // no way to notice.
19
+ //
20
+ // One gate here covers SIX detail routes — auction, classified, digital-code,
21
+ // live, pre-order, prize-draw — which is why it belongs in the factory rather
22
+ // than in each page.
23
+ //
24
+ // `isPubliclyVisible` is the same predicate every public LIST query uses, and it
25
+ // deliberately treats a MISSING status as visible: documents written before the
26
+ // field existed must not vanish. It gates on status alone and says nothing about
27
+ // availability — a sold-out or ended listing must still render, with its own
28
+ // unavailable chrome, because a buyer following an old link deserves to be told
29
+ // it is gone rather than shown a 404.
7
30
  export function makeGetListingForDetail(type) {
8
31
  return cache(async (slugOrId) => {
9
32
  if (!slugOrId)
@@ -11,6 +34,13 @@ export function makeGetListingForDetail(type) {
11
34
  const product = await productRepository.findByIdOrSlug(slugOrId);
12
35
  if (!product || product.listingType !== type)
13
36
  return null;
37
+ // `AvailabilityRow` is `Record<string, FirestoreValue>` on purpose — the same
38
+ // predicate serves Firestore documents, the Function's ISO-string rows and
39
+ // JSON from /api/products. A declared interface has no index signature, so
40
+ // the honest adaptation is to hand it the one field it reads rather than to
41
+ // cast a whole document past the type system (Root Cause #98).
42
+ if (!isPubliclyVisible({ status: product.status }))
43
+ return null;
14
44
  return product;
15
45
  });
16
46
  }
@@ -9,6 +9,7 @@ import { FieldInput, FormErrorSummary, FormShellContext, useFormShellState } fro
9
9
  import { SectionForm, useSectionFormNav } from "../../shell";
10
10
  import { apiClient } from "../../../http";
11
11
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
12
+ import { FAQ_CATEGORY_LABELS } from "../../faq/schemas/firestore";
12
13
  /**
13
14
  * Matches the draft this form actually holds, and the payload it actually
14
15
  * sends.
@@ -57,14 +58,33 @@ const __O = {
57
58
  yAuto: "overflow-y-auto",
58
59
  };
59
60
  // --- Constants ---------------------------------------------------------------
60
- const CATEGORY_OPTIONS = [
61
- { label: "Shipping", value: "shipping" },
62
- { label: "Returns", value: "returns" },
63
- { label: "Payments", value: "payments" },
64
- { label: "Auctions", value: "auctions" },
65
- { label: "Pre-orders", value: "pre-orders" },
66
- { label: "General", value: "general" },
67
- ];
61
+ /*
62
+ * 🛑 DERIVED FROM THE REAL UNION. NEVER HAND-WRITE THIS LIST AGAIN.
63
+ *
64
+ * Until 2026-10-02 this was six hand-written options — shipping / returns /
65
+ * payments / auctions / pre-orders / general — and FIVE of them were values no
66
+ * reader in the product understands. `FAQCategory` is orders_payment |
67
+ * shipping_delivery | returns_refunds | product_information | account_security |
68
+ * technical_support | general | scam_awareness, so only `general` overlapped.
69
+ *
70
+ * Measured on production: an FAQ created with "Shipping" stored category
71
+ * "shipping" with isActive true, and then appeared on /faqs under NO sidebar
72
+ * category AND not under All FAQs — nowhere at all. The API types `category` as
73
+ * a bare z.string() and casts it, so the write was a clean 200 and the admin saw
74
+ * a saved FAQ that no visitor could ever reach. Moving it to "Payments" changed
75
+ * nothing.
76
+ *
77
+ * The old list almost certainly came from the OTHER `FAQCategory` declared in
78
+ * `admin/components/sections/adminSectionsTypes.ts` (general | shipping |
79
+ * returns | payment | account | products | sellers) — two same-named types in
80
+ * one package, Root Cause #36. Note it says "payment" where this list said
81
+ * "payments", so it did not even match its own source.
82
+ *
83
+ * `FAQ_CATEGORY_LABELS` is a `Record<FAQCategory, string>`, so adding a ninth
84
+ * category is a compile error here rather than a silently missing option, and a
85
+ * renamed value cannot drift out of this picker.
86
+ */
87
+ const CATEGORY_OPTIONS = Object.entries(FAQ_CATEGORY_LABELS).map(([value, label]) => ({ value, label }));
68
88
  function toSlug(str) {
69
89
  const base = str
70
90
  .toLowerCase()
@@ -49,11 +49,32 @@ export function useCategoriesFiltered(params = {}) {
49
49
  items = items.filter((c) => (c.productCount ?? 0) >= minProductCount);
50
50
  if (maxProductCount !== undefined)
51
51
  items = items.filter((c) => (c.productCount ?? 0) <= maxProductCount);
52
+ /*
53
+ * 🛑 This comparator is the ONLY thing that orders the category index —
54
+ * `sort` is never sent to the API (the request is `?flat=true` and nothing
55
+ * else), so an unrecognised value does not fall back to a server ordering,
56
+ * it falls through to name ASC right here. That is how the tier sort
57
+ * shipped "fixed" and stayed broken: CategoriesIndexListing declared
58
+ * DEFAULT_SORT = tier ASC and offered a "Top level first" option, both
59
+ * correct, and this switch had never heard of tier — so the roots stayed
60
+ * off page 1 while four source reads said the code was right.
61
+ *
62
+ * Add a case here for every value in SORT_OPTIONS, or the option is
63
+ * decoration.
64
+ */
52
65
  items = [...items].sort((a, b) => {
53
66
  if (sort === "-productCount")
54
67
  return (b.productCount ?? 0) - (a.productCount ?? 0);
68
+ if (sort === "productCount")
69
+ return (a.productCount ?? 0) - (b.productCount ?? 0);
55
70
  if (sort === "-name")
56
71
  return b.name.localeCompare(a.name);
72
+ /* Tier ascending puts the roots first; name breaks ties within a tier. */
73
+ if (sort === "tier" || sort === "-tier") {
74
+ const dir = sort === "tier" ? 1 : -1;
75
+ const d = (a.tier ?? 0) - (b.tier ?? 0);
76
+ return d !== 0 ? d * dir : a.name.localeCompare(b.name);
77
+ }
57
78
  return a.name.localeCompare(b.name);
58
79
  });
59
80
  const total = items.length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.42.9",
3
+ "version": "4.42.11",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"