@mohasinac/appkit 4.42.8 → 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
- 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()
@@ -8,5 +8,15 @@ export interface AdminStoreEditorViewProps {
8
8
  currentIsVerified?: boolean;
9
9
  currentIsFeatured?: boolean;
10
10
  currentCapabilities?: string[];
11
+ /**
12
+ * 🛑 Required for the note to DISPLAY. Without it `adminNotes` was hardcoded
13
+ * `""` at both the initial-state and the reopen-reset sites, so a stored note
14
+ * could never appear — and because the payload sent `parsed.adminNotes ||
15
+ * undefined`, an always-empty box meant `"" || undefined === undefined`, the
16
+ * route skipped the key, and an existing note could never be CLEARED either.
17
+ * Verified on production 2026-10-02: the document held `adminNotes:
18
+ * "RT3-probe"` while `textarea[name="adminNotes"]` read `""`.
19
+ */
20
+ currentAdminNotes?: string;
11
21
  }
12
- export declare function AdminStoreEditorView({ open, onClose, storeId, storeName, currentStatus, currentIsVerified, currentIsFeatured, currentCapabilities, }: AdminStoreEditorViewProps): React.JSX.Element;
22
+ export declare function AdminStoreEditorView({ open, onClose, storeId, storeName, currentStatus, currentIsVerified, currentIsFeatured, currentCapabilities, currentAdminNotes, }: AdminStoreEditorViewProps): React.JSX.Element;
@@ -69,10 +69,10 @@ const STATUS_OPTIONS = [
69
69
  * right that it is unreadable there.
70
70
  */
71
71
  function CapabilityPicker({ selected, onToggle, }) {
72
- return (_jsxs(Stack, { gap: "sm", children: [_jsxs(Span, { size: "sm", weight: "medium", color: "muted", children: ["Capabilities", _jsxs(Span, { size: "xs", weight: "normal", className: "ml-2", color: "muted", children: ["(", selected.size, " active)"] })] }), _jsx(Div, { className: "divide-y divide-[var(--appkit-color-border)]", rounded: "xl", border: "default", children: CAPABILITY_GROUPS.map((group) => (_jsxs(Details, { className: "group", children: [_jsxs(Summary, { paddingX: "x-sm", paddingY: "y-xs", weight: "semibold", color: "muted", layout: "flex", align: "center", justify: "between", className: "uppercase tracking-wide hover:bg-[var(--appkit-color-bg)] transition-colors", children: [_jsx(Span, { size: "xs", children: group.label }), _jsxs(Span, { size: "xs", weight: "normal", className: "normal-case", color: "faint", children: [group.caps.filter((c) => selected.has(c.key)).length, "/", group.caps.length] })] }), _jsx(Div, { layout: "grid", paddingY: "y-xs-tall", className: "grid-cols-2 gap-x-2 gap-y-1.5", surface: "muted", padding: "x-sm", children: group.caps.map((cap) => (_jsxs("label", { className: "flex items-center gap-[var(--appkit-space-2)] cursor-pointer text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-text-muted)]", children: [_jsx(Checkbox, { bare: true, checked: selected.has(cap.key), onChange: () => onToggle(cap.key), className: "h-3.5 w-3.5 rounded border-[var(--appkit-color-border)] accent-primary" }), cap.label] }, cap.key))) })] }, group.label))) })] }));
72
+ return (_jsxs(Stack, { gap: "sm", children: [_jsxs(Span, { size: "sm", weight: "medium", color: "muted", children: ["Capabilities", _jsxs(Span, { size: "xs", weight: "normal", className: "ml-2", color: "muted", children: ["(", selected.size, " active)"] })] }), _jsx(Div, { className: "divide-y divide-[var(--appkit-color-border)]", rounded: "xl", border: "default", children: CAPABILITY_GROUPS.map((group) => (_jsxs(Details, { className: "group", children: [_jsxs(Summary, { paddingX: "x-sm", paddingY: "y-xs", weight: "semibold", color: "muted", layout: "flex", align: "center", justify: "between", className: "uppercase tracking-wide hover:bg-[var(--appkit-color-bg)] transition-colors", children: [_jsx(Span, { size: "xs", children: group.label }), _jsxs(Span, { size: "xs", weight: "normal", className: "normal-case", color: "faint", children: [group.caps.filter((c) => selected.has(c.key)).length, "/", group.caps.length] })] }), _jsx(Div, { layout: "grid", paddingY: "y-xs-tall", className: "grid-cols-2 gap-x-2 gap-y-1.5", surface: "muted", padding: "x-sm", children: group.caps.map((cap) => (_jsx(Checkbox, { label: cap.label, checked: selected.has(cap.key), onChange: () => onToggle(cap.key), wrapperClassName: "flex items-center gap-[var(--appkit-space-2)] cursor-pointer text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-text-muted)]" }, cap.key))) })] }, group.label))) })] }));
73
73
  }
74
74
  // --- Component ---------------------------------------------------------------
75
- export function AdminStoreEditorView({ open, onClose, storeId, storeName, currentStatus, currentIsVerified, currentIsFeatured, currentCapabilities, }) {
75
+ export function AdminStoreEditorView({ open, onClose, storeId, storeName, currentStatus, currentIsVerified, currentIsFeatured, currentCapabilities, currentAdminNotes, }) {
76
76
  const queryClient = useQueryClient();
77
77
  const { showToast } = useToast();
78
78
  const [values, setValues] = React.useState(() => ({
@@ -81,7 +81,7 @@ export function AdminStoreEditorView({ open, onClose, storeId, storeName, curren
81
81
  isFeatured: currentIsFeatured ?? false,
82
82
  suspensionReason: "",
83
83
  capabilities: currentCapabilities ?? DEFAULT_CAPABILITIES,
84
- adminNotes: "",
84
+ adminNotes: currentAdminNotes ?? "",
85
85
  }));
86
86
  const handleChange = React.useCallback((partial) => {
87
87
  setValues((prev) => Object.assign({}, prev, partial));
@@ -95,9 +95,9 @@ export function AdminStoreEditorView({ open, onClose, storeId, storeName, curren
95
95
  isFeatured: currentIsFeatured ?? false,
96
96
  suspensionReason: "",
97
97
  capabilities: currentCapabilities ?? DEFAULT_CAPABILITIES,
98
- adminNotes: "",
98
+ adminNotes: currentAdminNotes ?? "",
99
99
  });
100
- }, [open, currentStatus, currentIsVerified, currentIsFeatured, currentCapabilities]);
100
+ }, [open, currentStatus, currentIsVerified, currentIsFeatured, currentCapabilities, currentAdminNotes]);
101
101
  const saveMutation = useApiMutation({
102
102
  errorMessage: "Failed to update store.",
103
103
  mutationFn: async () => {
@@ -114,7 +114,9 @@ export function AdminStoreEditorView({ open, onClose, storeId, storeName, curren
114
114
  const parsed = adminStoreUpdateSchema.parse(values);
115
115
  await apiClient.patch(ADMIN_ENDPOINTS.STORE_BY_ID(storeId), {
116
116
  storeStatus: parsed.storeStatus,
117
- adminNotes: parsed.adminNotes || undefined,
117
+ // `?? ""`, NOT `|| undefined` — sending undefined makes the route skip
118
+ // the key, so clearing a note was impossible.
119
+ adminNotes: parsed.adminNotes ?? "",
118
120
  isFeatured: parsed.isFeatured,
119
121
  isVerified: parsed.isVerified,
120
122
  suspensionReason: parsed.suspensionReason || undefined,
@@ -127,5 +127,15 @@ export function AdminStoresView({ children, ...props }) {
127
127
  };
128
128
  return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(AdminStoreEditorView, { open: Boolean(editorRow), onClose: () => setEditorRow(null), storeId: editorRow?.id, storeName: editorRow?.primary, currentStatus: editorRow?.status?.toLowerCase(), currentIsVerified: Boolean(editorRow?._raw?.isVerified), currentIsFeatured: Boolean(editorRow?._raw?.isFeatured), currentCapabilities: Array.isArray(editorRow?._raw?.capabilities)
129
129
  ? editorRow._raw.capabilities
130
+ : undefined,
131
+ /*
132
+ * The list serializer DOES emit `adminNotes`
133
+ * (src/app/api/admin/stores/route.ts), so the row already carries it —
134
+ * it was simply never passed down, which is why the textbox could not
135
+ * display a stored note. Root Cause #38's shape, one hop later: the
136
+ * field survives the API and dies at the prop.
137
+ */
138
+ currentAdminNotes: typeof editorRow?._raw?.adminNotes === "string"
139
+ ? editorRow._raw.adminNotes
130
140
  : undefined })] }));
131
141
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.42.8",
3
+ "version": "4.42.10",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"