@mohasinac/appkit 4.42.9 → 4.42.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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()
|