@mohasinac/appkit 4.46.0 → 4.48.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.
Files changed (175) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/checkout/expand-order-items.js +3 -0
  3. package/dist/_internal/server/features/messages/actions.d.ts +68 -0
  4. package/dist/_internal/server/features/messages/actions.js +249 -0
  5. package/dist/_internal/server/features/parts/buildable.d.ts +47 -0
  6. package/dist/_internal/server/features/parts/buildable.js +159 -0
  7. package/dist/_internal/server/features/products/list-public.d.ts +17 -0
  8. package/dist/_internal/server/features/products/list-public.js +92 -5
  9. package/dist/_internal/server/features/products/og.d.ts +7 -0
  10. package/dist/_internal/server/features/products/og.js +38 -0
  11. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  12. package/dist/_internal/server/features/site-settings/adapters.js +6 -0
  13. package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +28 -0
  14. package/dist/_internal/server/jobs/core/notificationPrune.js +87 -3
  15. package/dist/_internal/server/jobs/core/priceIndexRollup.js +36 -1
  16. package/dist/_internal/shared/actions/action-registry.js +8 -0
  17. package/dist/_internal/shared/features/notifications/action-url.js +8 -0
  18. package/dist/_internal/shared/features/notifications/email-eligibility.js +3 -0
  19. package/dist/_internal/shared/features/site-settings/types.d.ts +4 -0
  20. package/dist/client.d.ts +10 -0
  21. package/dist/client.js +8 -0
  22. package/dist/constants/api-endpoints.d.ts +48 -0
  23. package/dist/constants/api-endpoints.js +30 -0
  24. package/dist/constants/notification-types.d.ts +1 -1
  25. package/dist/features/about/components/GstExplainerView.d.ts +28 -0
  26. package/dist/features/about/components/GstExplainerView.js +59 -0
  27. package/dist/features/about/components/GuidesHubView.d.ts +28 -0
  28. package/dist/features/about/components/GuidesHubView.js +58 -0
  29. package/dist/features/about/components/PublicProfileView.js +5 -0
  30. package/dist/features/about/components/SpotAFakeView.d.ts +29 -0
  31. package/dist/features/about/components/SpotAFakeView.js +49 -0
  32. package/dist/features/admin/actions/notification-actions.js +4 -0
  33. package/dist/features/admin/schemas/firestore.d.ts +13 -3
  34. package/dist/features/admin/schemas/firestore.js +8 -0
  35. package/dist/features/blog/repository/blog.repository.js +21 -7
  36. package/dist/features/categories/api/route.js +41 -1
  37. package/dist/features/categories/components/BreadcrumbTrail.d.ts +58 -13
  38. package/dist/features/categories/components/BreadcrumbTrail.js +63 -26
  39. package/dist/features/categories/components/CategoryDetailPageView.js +92 -38
  40. package/dist/features/categories/components/ContextualTrail.d.ts +62 -0
  41. package/dist/features/categories/components/ContextualTrail.js +81 -0
  42. package/dist/features/categories/components/LastCategoryBeacon.d.ts +18 -0
  43. package/dist/features/categories/components/LastCategoryBeacon.js +33 -0
  44. package/dist/features/categories/repository/categories.repository.d.ts +21 -0
  45. package/dist/features/categories/repository/categories.repository.js +36 -3
  46. package/dist/features/categories/repository/price-index.repository.d.ts +25 -0
  47. package/dist/features/email/notification-templates.js +4 -0
  48. package/dist/features/events/repository/events.repository.js +10 -3
  49. package/dist/features/faq/constants/read-caps.d.ts +29 -0
  50. package/dist/features/faq/constants/read-caps.js +29 -0
  51. package/dist/features/faq/repository/faqs.repository.d.ts +0 -2
  52. package/dist/features/faq/repository/faqs.repository.js +19 -16
  53. package/dist/features/homepage/components/AdSlot.js +10 -4
  54. package/dist/features/homepage/hooks/useActiveAd.d.ts +18 -1
  55. package/dist/features/homepage/hooks/useActiveAd.js +42 -2
  56. package/dist/features/homepage/lib/section-renderer.js +12 -2
  57. package/dist/features/layout/AppLayoutShell.d.ts +4 -1
  58. package/dist/features/layout/AppLayoutShell.js +2 -2
  59. package/dist/features/layout/TitleBarLayout.d.ts +4 -1
  60. package/dist/features/layout/TitleBarLayout.js +8 -4
  61. package/dist/features/media/AvatarUpload.js +8 -1
  62. package/dist/features/media/MediaAvatar.js +1 -1
  63. package/dist/features/media/MediaImage.js +3 -1
  64. package/dist/features/media/MediaLightbox.js +4 -1
  65. package/dist/features/media/MediaPickerModal.js +6 -1
  66. package/dist/features/media/MediaVideo.js +3 -1
  67. package/dist/features/messages/components/ConversationThreadView.d.ts +3 -0
  68. package/dist/features/messages/components/ConversationThreadView.js +75 -0
  69. package/dist/features/messages/components/ConversationsInboxView.d.ts +1 -0
  70. package/dist/features/messages/components/ConversationsInboxView.js +39 -0
  71. package/dist/features/messages/components/MessageSellerButton.d.ts +17 -0
  72. package/dist/features/messages/components/MessageSellerButton.js +33 -0
  73. package/dist/features/messages/hooks/useConversationChannel.d.ts +24 -0
  74. package/dist/features/messages/hooks/useConversationChannel.js +89 -0
  75. package/dist/features/messages/hooks/useMessages.d.ts +347 -0
  76. package/dist/features/messages/hooks/useMessages.js +113 -0
  77. package/dist/features/messages/repository/conversations.repository.d.ts +44 -0
  78. package/dist/features/messages/repository/conversations.repository.js +173 -0
  79. package/dist/features/messages/repository/messages.repository.d.ts +42 -0
  80. package/dist/features/messages/repository/messages.repository.js +142 -0
  81. package/dist/features/messages/schemas/firestore.d.ts +99 -0
  82. package/dist/features/messages/schemas/firestore.js +46 -0
  83. package/dist/features/orders/repository/orders.repository.js +21 -7
  84. package/dist/features/orders/schemas/firestore.d.ts +11 -0
  85. package/dist/features/products/components/CityCollectiblesView.d.ts +31 -0
  86. package/dist/features/products/components/CityCollectiblesView.js +82 -0
  87. package/dist/features/products/components/MarketPriceAnchor.d.ts +45 -0
  88. package/dist/features/products/components/MarketPriceAnchor.js +62 -0
  89. package/dist/features/products/components/ProductDetailPageView.js +71 -5
  90. package/dist/features/products/constants/meetup-cities.d.ts +47 -0
  91. package/dist/features/products/constants/meetup-cities.js +49 -0
  92. package/dist/features/products/repository/category-assignment.d.ts +82 -11
  93. package/dist/features/products/repository/category-assignment.js +93 -12
  94. package/dist/features/products/repository/products.repository.d.ts +12 -1
  95. package/dist/features/products/repository/products.repository.js +66 -34
  96. package/dist/features/products/schemas/firestore.d.ts +24 -2
  97. package/dist/features/products/schemas/firestore.js +13 -1
  98. package/dist/features/products/schemas/index.d.ts +2 -2
  99. package/dist/features/promotions/repository/coupons.repository.js +11 -4
  100. package/dist/features/reviews/repository/reviews.repository.js +11 -4
  101. package/dist/features/scams/repository/scammer.repository.js +21 -7
  102. package/dist/features/seller/components/CategoryInlineSelect.d.ts +17 -1
  103. package/dist/features/seller/components/CategoryInlineSelect.js +14 -3
  104. package/dist/features/seller/components/SellerProductShell.d.ts +27 -2
  105. package/dist/features/seller/components/SellerProductShell.js +32 -3
  106. package/dist/features/seller/repository/offer.repository.js +21 -6
  107. package/dist/features/seller/schemas/product-form.d.ts +3 -3
  108. package/dist/features/seller/schemas/product-form.js +1 -1
  109. package/dist/features/server-errors/repository/server-errors.repository.js +11 -0
  110. package/dist/features/server-errors/schemas/firestore.d.ts +17 -0
  111. package/dist/features/server-errors/schemas/firestore.js +2 -0
  112. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +17 -1
  113. package/dist/features/store-extensions/repository/store-extensions.repositories.js +31 -0
  114. package/dist/features/store-extensions/schemas/firestore.d.ts +1 -1
  115. package/dist/features/store-extensions/schemas/firestore.js +4 -0
  116. package/dist/features/stores/repository/store.repository.js +11 -3
  117. package/dist/features/tester/seed-data/authored/community-support__messages.d.ts +2 -0
  118. package/dist/features/tester/seed-data/authored/community-support__messages.js +200 -0
  119. package/dist/features/tester/seed-data/authored/index.js +2 -0
  120. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +64 -1
  121. package/dist/index.d.ts +8 -2
  122. package/dist/index.js +1 -1
  123. package/dist/next/routing/route-map.d.ts +14 -0
  124. package/dist/next/routing/route-map.js +26 -0
  125. package/dist/providers/db-firebase/rtdb-paths.d.ts +8 -0
  126. package/dist/providers/db-firebase/rtdb-paths.js +8 -0
  127. package/dist/repositories/index.d.ts +1 -0
  128. package/dist/repositories/index.js +4 -0
  129. package/dist/schemas/registry.d.ts +3 -3
  130. package/dist/security/index.d.ts +1 -1
  131. package/dist/security/index.js +1 -1
  132. package/dist/security/pii-schemas.d.ts +17 -0
  133. package/dist/security/pii-schemas.js +15 -7
  134. package/dist/seed/_helpers/category-content-generated.d.ts +24 -0
  135. package/dist/seed/_helpers/category-content-generated.js +1853 -0
  136. package/dist/seed/_helpers/category-models.d.ts +1 -1
  137. package/dist/seed/_helpers/category-models.js +25 -1
  138. package/dist/seed/_helpers/category-names.d.ts +54 -0
  139. package/dist/seed/_helpers/category-names.js +118 -0
  140. package/dist/seed/_helpers/character-features.d.ts +1 -1
  141. package/dist/seed/_helpers/character-features.js +5 -4
  142. package/dist/seed/_helpers/final-sale-fixtures.js +1 -1
  143. package/dist/seed/categories-seed-data.js +55 -1
  144. package/dist/seed/grouped-listings-seed-data.js +2 -2
  145. package/dist/seed/products-art-seed-data.js +2 -1
  146. package/dist/seed/products-auctions-seed-data.js +42 -22
  147. package/dist/seed/products-classifieds-seed-data.js +21 -3
  148. package/dist/seed/products-digital-codes-seed-data.js +2 -1
  149. package/dist/seed/products-live-items-seed-data.js +2 -1
  150. package/dist/seed/products-preorders-seed-data.js +26 -11
  151. package/dist/seed/products-prize-draws-seed-data.js +8 -1
  152. package/dist/seed/products-standard-seed-data.js +26 -25
  153. package/dist/seed/products-stickers-seed-data.js +12 -11
  154. package/dist/server.d.ts +8 -0
  155. package/dist/server.js +15 -0
  156. package/dist/styles.css +113 -1
  157. package/dist/tailwind-utilities.css +1 -1
  158. package/dist/tokens/themes/sunset.js +17 -0
  159. package/dist/tokens/tokens.css +5 -0
  160. package/dist/ui/components/Breadcrumb.d.ts +8 -1
  161. package/dist/ui/components/Breadcrumb.js +2 -2
  162. package/dist/ui/components/Breadcrumb.style.css +37 -0
  163. package/dist/ui/components/Details.d.ts +11 -1
  164. package/dist/ui/components/Details.js +2 -2
  165. package/dist/ui/components/ImageMosaic.d.ts +43 -0
  166. package/dist/ui/components/ImageMosaic.js +46 -0
  167. package/dist/ui/components/ImageMosaic.style.css +69 -0
  168. package/dist/ui/components/SplitTile.d.ts +34 -0
  169. package/dist/ui/components/SplitTile.js +28 -0
  170. package/dist/ui/components/index.style.css +1 -0
  171. package/dist/ui/index.d.ts +4 -0
  172. package/dist/ui/index.js +4 -0
  173. package/dist/utils/search-txt-query.d.ts +77 -0
  174. package/dist/utils/search-txt-query.js +50 -28
  175. package/package.json +1 -1
@@ -1,11 +1,24 @@
1
1
  import React from "react";
2
2
  import type { CategoryItem } from "../types";
3
+ import { type BreadcrumbCrumb } from "../utils/breadcrumb-chain";
3
4
  export interface BreadcrumbTrailProps {
4
5
  /**
5
- * The current (leaf) category whose ancestry should be shown as breadcrumbs.
6
- * Uses `category.ancestors` if present, otherwise only the category itself.
6
+ * The chain to render, root-first, with the final crumb carrying no `url`.
7
+ *
8
+ * 🛑 PREFER THIS over `category`. It is the same array the page hands to
9
+ * `breadcrumbJsonLd`, which is what makes the visible trail, the markup and
10
+ * `categoryNames` agree by construction rather than by discipline — D4b's
11
+ * rule. Before this prop existed the category page and the product page each
12
+ * hand-rolled their own `<Nav>`, and the product one rendered three crumbs
13
+ * (`Home / Products / Category`) while its JSON-LD carried all eight.
7
14
  */
8
- category: CategoryItem;
15
+ crumbs?: readonly BreadcrumbCrumb[];
16
+ /**
17
+ * Legacy input: a category whose `ancestors` array becomes the chain.
18
+ * Kept because it is the published prop shape; `crumbs` wins when both are
19
+ * passed.
20
+ */
21
+ category?: CategoryItem;
9
22
  /**
10
23
  * Label for the implicit root item prepended to the trail.
11
24
  * Default: "All Categories"
@@ -28,21 +41,53 @@ export interface BreadcrumbTrailProps {
28
41
  */
29
42
  showCurrent?: boolean;
30
43
  className?: string;
44
+ /**
45
+ * I5b — collapse the middle of a long trail behind a dropdown.
46
+ *
47
+ * 🛑 Needed independently of multi-assignment: a tier-4 part leaf is EIGHT
48
+ * crumbs (`Home > Categories > Spinning Tops > Beyblade Metal > Metal Parts >
49
+ * Performance Tips > Rubber Flat > <title>`) and 390px cannot hold it. D4b
50
+ * measured that depth and never addressed how it wraps.
51
+ *
52
+ * Defaults ON, because the only trails long enough to collapse are the ones
53
+ * that already overflow.
54
+ */
55
+ collapse?: boolean;
56
+ /**
57
+ * I5b — the OTHER categories this listing is filed under, as one extra crumb.
58
+ *
59
+ * Pass `categoryIds`/`categoryIdNames` straight through from the product;
60
+ * `alternateAssignments` decides inline-vs-dropdown and returns nothing at
61
+ * all for a single assignment, which is the overwhelming majority.
62
+ */
63
+ alternates?: {
64
+ ids?: readonly string[] | null;
65
+ names?: readonly string[] | null;
66
+ };
67
+ /**
68
+ * `"on-media"` when the trail sits over a cover photo — the category hero
69
+ * does. Forwarded to the `Breadcrumb` primitive, which owns the ink.
70
+ */
71
+ tone?: "default" | "on-media";
31
72
  }
32
73
  /**
33
- * BreadcrumbTrail — a category-aware breadcrumb component.
74
+ * BreadcrumbTrail — one row of crumbs, with the middle and the alternates
75
+ * collapsed behind real `<details>` dropdowns.
76
+ *
77
+ * 🛑 BOTH DROPDOWNS CONTAIN REAL `<a>` ELEMENTS IN THE SERVER-RENDERED DOM.
78
+ * `<details>`/`<summary>` is native, needs no JavaScript, works on a keyboard,
79
+ * and — the reason it was chosen over a hover menu — a crawler parsing the HTML
80
+ * sees every link whether or not anything ever opens it. Links injected on
81
+ * open would trade the SEO this exists for against tidiness, which is the
82
+ * opposite of *"better for both crawler and users"*.
34
83
  *
35
- * Renders the full ancestor chain for a category using the `ancestors` array
36
- * stored on `CategoryItem`. Falls back gracefully when `ancestors` is absent
37
- * (shows only the root + current category).
84
+ * 🛑 Collapsing is PRESENTATION ONLY. The JSON-LD is emitted by the page from
85
+ * the full chain and must stay that way: a crawler never receives a truncated
86
+ * hierarchy because a phone could not show it.
38
87
  *
39
88
  * @example
40
89
  * ```tsx
41
- * <BreadcrumbTrail
42
- * category={category}
43
- * rootHref="/categories"
44
- * basePath="/categories"
45
- * />
90
+ * <BreadcrumbTrail crumbs={withTrailHead(categoryCrumbs(category), HEAD)} />
46
91
  * ```
47
92
  */
48
- export declare function BreadcrumbTrail({ category, rootLabel, rootHref, basePath, showCurrent, className, }: BreadcrumbTrailProps): React.JSX.Element;
93
+ export declare function BreadcrumbTrail({ crumbs, category, rootLabel, rootHref, basePath, showCurrent, className, collapse, alternates, tone, }: BreadcrumbTrailProps): React.JSX.Element | null;
@@ -1,36 +1,73 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from "react";
2
3
  import { Breadcrumb } from "../../../ui/index.js";
4
+ import { Details, Summary, Div, Row, Stack } from "../../../ui/index.js";
5
+ import { Span } from "../../../ui/components/Typography.js";
6
+ import { TextLink } from "../../../ui/components/TextLink.js";
3
7
  import { ROUTES } from "../../../next/routing/route-map.js";
8
+ import { alternateAssignments, collapseTrail, } from "../utils/breadcrumb-chain.js";
4
9
  /**
5
- * BreadcrumbTrail — a category-aware breadcrumb component.
10
+ * BreadcrumbTrail — one row of crumbs, with the middle and the alternates
11
+ * collapsed behind real `<details>` dropdowns.
6
12
  *
7
- * Renders the full ancestor chain for a category using the `ancestors` array
8
- * stored on `CategoryItem`. Falls back gracefully when `ancestors` is absent
9
- * (shows only the root + current category).
13
+ * 🛑 BOTH DROPDOWNS CONTAIN REAL `<a>` ELEMENTS IN THE SERVER-RENDERED DOM.
14
+ * `<details>`/`<summary>` is native, needs no JavaScript, works on a keyboard,
15
+ * and — the reason it was chosen over a hover menu — a crawler parsing the HTML
16
+ * sees every link whether or not anything ever opens it. Links injected on
17
+ * open would trade the SEO this exists for against tidiness, which is the
18
+ * opposite of *"better for both crawler and users"*.
19
+ *
20
+ * 🛑 Collapsing is PRESENTATION ONLY. The JSON-LD is emitted by the page from
21
+ * the full chain and must stay that way: a crawler never receives a truncated
22
+ * hierarchy because a phone could not show it.
10
23
  *
11
24
  * @example
12
25
  * ```tsx
13
- * <BreadcrumbTrail
14
- * category={category}
15
- * rootHref="/categories"
16
- * basePath="/categories"
17
- * />
26
+ * <BreadcrumbTrail crumbs={withTrailHead(categoryCrumbs(category), HEAD)} />
18
27
  * ```
19
28
  */
20
- export function BreadcrumbTrail({ category, rootLabel = "All Categories", rootHref = String(ROUTES.PUBLIC.CATEGORIES), basePath = String(ROUTES.PUBLIC.CATEGORIES), showCurrent = true, className, }) {
21
- const items = [
22
- // Root
23
- { label: rootLabel, href: rootHref },
24
- // Ancestors (sorted by tier ascending so root → leaf order)
25
- ...(category.ancestors ?? [])
26
- .slice()
27
- .sort((a, b) => a.tier - b.tier)
28
- .map((ancestor) => ({
29
- label: ancestor.name,
30
- href: `${basePath}/${ancestor.id}`,
31
- })),
32
- // Current category (no href so it renders as the active item)
33
- ...(showCurrent ? [{ label: category.name }] : []),
34
- ];
35
- return _jsx(Breadcrumb, { items: items, className: className });
29
+ export function BreadcrumbTrail({ crumbs, category, rootLabel = "All Categories", rootHref = String(ROUTES.PUBLIC.CATEGORIES), basePath = String(ROUTES.PUBLIC.CATEGORIES), showCurrent = true, className, collapse = true, alternates, tone = "default", }) {
30
+ const chain = crumbs?.length
31
+ ? [...crumbs]
32
+ : category
33
+ ? [
34
+ { name: rootLabel, url: rootHref },
35
+ ...(category.ancestors ?? [])
36
+ .slice()
37
+ .sort((a, b) => a.tier - b.tier)
38
+ .map((ancestor) => ({
39
+ name: ancestor.name,
40
+ url: `${basePath}/${ancestor.id}`,
41
+ })),
42
+ ...(showCurrent ? [{ name: category.name }] : []),
43
+ ]
44
+ : [];
45
+ if (chain.length === 0)
46
+ return null;
47
+ /*
48
+ * I5b — the alternates become ONE extra crumb, never a second row. Two rows
49
+ * of crumbs above a title is a pattern no competitor in the crawl uses and it
50
+ * costs vertical space above the fold on a phone.
51
+ */
52
+ const alt = alternateAssignments({
53
+ categoryIds: alternates?.ids ?? null,
54
+ categoryIdNames: alternates?.names ?? null,
55
+ hrefFor: (slug) => `${basePath}/${slug}`,
56
+ });
57
+ const split = collapse ? collapseTrail(chain) : null;
58
+ const hasCollapsed = Boolean(split && split.collapsed.length > 0);
59
+ const visible = hasCollapsed
60
+ ? [...split.lead.map(fromCrumb), ...split.tail.map(fromCrumb)]
61
+ : chain.map(fromCrumb);
62
+ /*
63
+ * The collapsed middle is injected AFTER the lead rather than rendered as a
64
+ * label-only crumb, so the hidden ancestors stay clickable. The previous
65
+ * version rendered `… 3 more` as plain text, which told the visitor what was
66
+ * missing and gave them no way to reach it.
67
+ */
68
+ const leadCount = hasCollapsed ? split.lead.length : 0;
69
+ return (_jsxs(Div, { className: className, children: [_jsxs(Row, { align: "center", gap: "xs", wrap: true, children: [_jsx(Breadcrumb, { items: visible.slice(0, leadCount), tone: tone }), hasCollapsed && (_jsxs(Details, { tone: "card", padding: "sm", rounded: "lg", children: [_jsx(Summary, { size: "xs", color: "muted", ariaLabel: `Show ${split.collapsed.length} hidden category levels`, children: `… ${split.collapsed.length} more` }), _jsx(Div, { padding: "t-xs", children: _jsx(Stack, { align: "start", gap: "xs", children: split.collapsed.map((c) => (_jsx(TextLink, { href: c.url ?? rootHref, size: "xs", children: c.name }, c.url ?? c.name))) }) })] })), _jsx(Breadcrumb, { items: hasCollapsed ? visible.slice(leadCount) : visible, tone: tone })] }), alt.mode === "inline" && (_jsxs(Span, { size: "xs", color: "muted", children: ["Also in", " ", alt.items.map((a, i) => (_jsxs(React.Fragment, { children: [i > 0 && " · ", _jsx(TextLink, { href: a.url ?? rootHref, size: "xs", children: a.name })] }, a.url ?? a.name)))] })), alt.mode === "dropdown" && (_jsxs(Details, { tone: "card", padding: "sm", rounded: "lg", children: [_jsx(Summary, { size: "xs", color: "muted", ariaLabel: "Other categories this listing is filed under", children: `Also in ${alt.items.length} more` }), _jsx(Div, { padding: "t-xs", children: _jsx(Stack, { align: "start", gap: "xs", children: alt.items.map((a) => (_jsx(TextLink, { href: a.url ?? rootHref, size: "xs", children: a.name }, a.url ?? a.name))) }) })] }))] }));
70
+ }
71
+ function fromCrumb(c) {
72
+ return c.url ? { label: c.name, href: c.url } : { label: c.name };
36
73
  }
@@ -5,11 +5,14 @@ import { categoryCrumbs, withTrailHead } from "../utils/breadcrumb-chain.js";
5
5
  import { ownTotalFor, descendantTotalFor } from "../schemas/category-counts.js";
6
6
  import Link from "next/link.js";
7
7
  import { categoriesRepository, productRepository, storeRepository, } from "../../../repositories/index.js";
8
- import { Container, Div, Heading, Main, Nav, Section, Span, Stack, Text } from "../../../ui/index.js";
8
+ import { Container, Div, Heading, ImageMosaic, Main, MOSAIC_MIN, Section, Span, SplitTile, Stack, Text } from "../../../ui/index.js";
9
9
  import { MediaImage } from "../../media/MediaImage.js";
10
+ import { RichTextRenderer } from "../../../ui/rich-text/RichTextRenderer.js";
10
11
  import { CategoryDetailTabs } from "./CategoryDetailTabs.js";
11
12
  import { CategoryGrid } from "./CategoryGrid.js";
12
13
  import { CategoryHighlightsAndFaqSection } from "./CategoryHighlightsAndFaqSection.js";
14
+ import { BreadcrumbTrail } from "./BreadcrumbTrail.js";
15
+ import { LastCategoryBeacon } from "./LastCategoryBeacon.js";
13
16
  import { GroupedListingsCarousel } from "../../grouped/components/GroupedListingsCarousel.js";
14
17
  import { ModelCompositionPanel } from "../../parts/components/ModelCompositionPanel.js";
15
18
  import { getCompositionsForCategory } from "../../../_internal/server/features/parts/data.js";
@@ -21,11 +24,14 @@ import { enabledCategoryTypes, enabledListingTypes } from "../../../_internal/sh
21
24
  import { siteSettingsRepository } from "../../../repositories/index.js";
22
25
  import { safeRead } from "../../../errors/safe-read.js";
23
26
  import { breadcrumbJsonLd, collectionPageJsonLd, faqJsonLd, } from "../../../seo/json-ld.js";
27
+ import { toStoreListItem } from "../../../_internal/server/features/stores/adapters.js";
24
28
  import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility.js";
25
29
  const __O = {
26
30
  hidden: "overflow-hidden",
27
31
  xAuto: "overflow-x-auto",
28
32
  };
33
+ /** The page's content width, repeated across every section on this view. */
34
+ const __W = "max-w-7xl mx-auto";
29
35
  /**
30
36
  * Read and display bounds for this page. Every one of these replaced an
31
37
  * unbounded read — see the comments at each use site for what each one cost.
@@ -40,8 +46,20 @@ export async function CategoryDetailPageView({ slug }) {
40
46
  // The category IS this page's subject — a read failure must surface rather
41
47
  // than render an anonymous, empty category page. `undefined` still means
42
48
  // "no such category slug".
43
- const category = ((await categoriesRepository
44
- .getCategoryBySlug(slug)) ?? undefined);
49
+ const categoryDoc = await categoriesRepository.getCategoryBySlug(slug);
50
+ const category = (categoryDoc ?? undefined);
51
+ /*
52
+ * 🛑 Read from the DOCUMENT, not from the `CategoryItem` cast.
53
+ *
54
+ * `contentBody` is deliberately PRIVATE on the list projection — it is
55
+ * 400–900 chars of HTML per row and `CategoryInlineSelect` fetches 200 rows
56
+ * on every seller-form open, so publishing it there would be the
57
+ * Fast-Origin-Transfer shape of Root Cause #104. Adding it to `CategoryItem`
58
+ * to satisfy this one render site would invite exactly that. The detail page
59
+ * holds the whole document server-side, which is what the adapter's own
60
+ * comment says this field's reader does.
61
+ */
62
+ const contentBody = typeof categoryDoc?.contentBody === "string" ? categoryDoc.contentBody : null;
45
63
  // Products carry their FULL ancestor chain in `categorySlugs`, so matching on
46
64
  // this category's own id already returns the whole subtree — no descendant
47
65
  // expansion needed. Expanding it was also a latent outage: `categoriesIn` is
@@ -212,41 +230,27 @@ export async function CategoryDetailPageView({ slug }) {
212
230
  seen.add(s.id);
213
231
  return true;
214
232
  })
215
- .map((s) => ({
216
- id: s.id,
217
- storeSlug: s.storeSlug ?? s.id,
218
- ownerId: s.ownerId,
219
- storeName: s.storeName,
220
- storeDescription: s.storeDescription,
221
- storeCategory: s.storeCategory,
222
- storeLogoURL: s.storeLogoURL,
223
- storeBannerURL: s.storeBannerURL,
224
- status: s.status,
225
- isPublic: s.isPublic ?? true,
226
- totalProducts: s.stats?.totalProducts,
227
- itemsSold: s.stats?.itemsSold,
228
- totalReviews: s.stats?.totalReviews,
229
- averageRating: s.stats?.averageRating,
230
233
  /*
231
- * 🛑 This whole `.map()` is a SIXTH hand-rolled copy of
232
- * `toStoreListItem` — the adapter Root Cause #70 created precisely to
233
- * collapse the three that existed then, and which three public store
234
- * APIs now share. It is recorded as an F1 defect rather than fixed here
235
- * (the defect policy: a found defect goes in the backlog, not in the
236
- * step's commit), and the reason it cannot be a drive-by is a boundary:
237
- * the adapter lives in `_internal/server/` and this file is in
238
- * `features/`, so adopting it is an import-graph decision, not a rename.
234
+ * 🛑 F1 #28 CLOSED — this was a SIXTH hand-rolled copy of
235
+ * `toStoreListItem`, the adapter Root Cause #70 created to collapse the
236
+ * three that existed then and which three public store APIs now share.
239
237
  *
240
- * These two fields are added rather than deferred because `tsc` demands
241
- * them — which is the whole value of having made them REQUIRED on
242
- * `StoreListItem`: the compiler enumerated every copy, and this is the
243
- * one the audit's "union of what the registered builders emit" check
244
- * cannot see.
238
+ * It was recorded as "not a drive-by fix" because the adapter lives in
239
+ * `_internal/server/` and this file in `features/`, making adoption an
240
+ * import-graph decision. MEASURED, that decision is free: the adapter
241
+ * module's only imports are two `import type` lines, and this file already
242
+ * imports four other `_internal/server/*` modules (parts, grouped,
243
+ * listing-tab-counts, tester visibility). So the boundary argument did not
244
+ * apply to this particular module, and the copy stayed alive on a
245
+ * hypothetical.
246
+ *
247
+ * The behavioural differences the copy carried, both now gone: it defaulted
248
+ * `storeSlug` to the id and `isPublic` to `true` (the adapter forwards what
249
+ * the document says, and a store with no `isPublic` is not assumed public),
250
+ * and it cast `createdAt` through `as unknown as string` where the adapter
251
+ * converts a Timestamp properly.
245
252
  */
246
- supportsShippingProtection: s.supportsShippingProtection ?? false,
247
- supportsSaleProtection: s.supportsSaleProtection ?? false,
248
- createdAt: s.createdAt,
249
- }));
253
+ .map((s) => toStoreListItem(s));
250
254
  const storeCount = categoryStores.length;
251
255
  // `tabCounts` is keyed by tabSlug and already covers every listing type plus
252
256
  // bundles; `stores` is added here because this page resolves its store list
@@ -283,6 +287,24 @@ export async function CategoryDetailPageView({ slug }) {
283
287
  const showCountSplit = ownHere !== undefined && inSubcategories !== undefined && inSubcategories > 0;
284
288
  const coverImage = category?.display?.coverImage;
285
289
  const hasCover = Boolean(coverImage);
290
+ /*
291
+ * D3 — when the category has no cover photo of its own, the hero draws a
292
+ * mosaic of the products ALREADY fetched for the grid below. Zero extra
293
+ * reads: these are the same rows, and the band was otherwise a flat colour
294
+ * strip on roughly 1,100 categories that have no photographer.
295
+ *
296
+ * 🛑 Only when there are at least `MOSAIC_MIN` images. Two thumbnails beside
297
+ * a title is not a pattern, it is two thumbnails — the component returns null
298
+ * below the floor and the hero keeps its plain background, which is a correct
299
+ * answer rather than a half-built one.
300
+ */
301
+ const mosaicImages = hasCover
302
+ ? []
303
+ : (productsResult?.items ?? [])
304
+ .map((row) => row.mainImage ?? row.images?.[0] ?? null)
305
+ .filter((src) => !!src)
306
+ .slice(0, 5);
307
+ const hasMosaic = mosaicImages.length >= MOSAIC_MIN;
286
308
  /*
287
309
  * ── D4: structured data. This page emitted NONE ─────────────────────────
288
310
  *
@@ -324,7 +346,7 @@ export async function CategoryDetailPageView({ slug }) {
324
346
  * leaving a gap, and `withTrailHead` drops the last crumb's URL because the
325
347
  * final breadcrumb is the page you are on.
326
348
  */
327
- const ldBreadcrumb = breadcrumbJsonLd(withTrailHead(categoryCrumbs({
349
+ const trail = withTrailHead(categoryCrumbs({
328
350
  name: category?.name ?? "Category",
329
351
  url: categoryPath,
330
352
  ancestors: category?.ancestors,
@@ -332,7 +354,14 @@ export async function CategoryDetailPageView({ slug }) {
332
354
  }), [
333
355
  { name: "Home", url: "/" },
334
356
  { name: "Categories", url: String(ROUTES.PUBLIC.CATEGORIES) },
335
- ]));
357
+ ]);
358
+ /*
359
+ * 🛑 ONE ARRAY, two surfaces. The visible trail below renders `trail` and the
360
+ * markup here serialises the same `trail` — so they cannot disagree, which is
361
+ * exactly what happened while the page hand-rolled a three-crumb `<Nav>`
362
+ * beside an eight-crumb `BreadcrumbList`.
363
+ */
364
+ const ldBreadcrumb = breadcrumbJsonLd(trail);
336
365
  /*
337
366
  * 🛑 Items are the CHILD CATEGORIES, not the products.
338
367
  *
@@ -369,7 +398,32 @@ export async function CategoryDetailPageView({ slug }) {
369
398
  const ldFaq = category?.faqs && category.faqs.length > 0
370
399
  ? faqJsonLd(category.faqs.map((f) => ({ question: f.question, answer: f.answer })))
371
400
  : null;
372
- return (_jsxs(Main, { children: [_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldBreadcrumb) } }), _jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldCollection) } }), ldFaq && (_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldFaq) } })), _jsxs(Section, { className: `relative ${__O.hidden} ${hasCover ? "min-h-[220px] md:min-h-[280px]" : "bg-[var(--appkit-color-bg)]"}`, children: [hasCover && (_jsxs(_Fragment, { children: [_jsx(Div, { className: "absolute inset-0", children: _jsx(MediaImage, { src: coverImage, alt: "", size: "banner" }) }), _jsx(Div, { surface: "overlay-md", className: "absolute inset-0" })] })), _jsxs(Div, { className: "relative z-10 max-w-7xl mx-auto", paddingY: hasCover ? "y-3xl" : "y-xl", padding: "x-md", children: [_jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "sm", className: "mb-4", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Home" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORIES), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Categories" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Span, { weight: "medium", className: hasCover ? "text-white" : "text-[var(--appkit-color-text)]", children: category?.name ?? slug })] }), _jsx(Heading, { color: "inverse", level: 1, className: `mb-2 ${hasCover ? "" : "text-[var(--appkit-color-text)]"}`, size: "3xl", mdSize: "4xl", weight: "bold", children: category?.name ?? slug }), category?.description && typeof category.description === "string" && !category.description.startsWith("{") && (_jsx(Text, { color: hasCover ? "inverse" : "muted", className: `max-w-2xl mb-4 ${hasCover ? "/80" : ""}`, size: "base", children: category.description })), _jsxs(Row, { wrap: true, gap: "sm", children: [productCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: "inverse", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "bg-primary/10 text-primary-700 dark:text-primary-400", rounded: "full", padding: "pill-sm-tall", children: [productCount.toLocaleString(), " ", productCount === 1 ? "product" : "products"] })), showCountSplit && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "muted", size: "xs", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [ownHere.toLocaleString(), " here \u00B7 ", inSubcategories.toLocaleString(), " in subcategories"] })), auctionCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "warning", surface: hasCover ? undefined : "warning-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [auctionCount.toLocaleString(), " ", auctionCount === 1 ? "auction" : "auctions"] })), preOrderCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "info", surface: hasCover ? undefined : "info-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [preOrderCount.toLocaleString(), " ", preOrderCount === 1 ? "pre-order" : "pre-orders"] })), storeCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "success", surface: hasCover ? undefined : "success-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [storeCount.toLocaleString(), " ", storeCount === 1 ? "store" : "stores"] }))] })] })] }), _jsx(CategoryHighlightsAndFaqSection, { highlights: category?.highlights, faqs: category?.faqs }), compositions.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", children: _jsx(Div, { className: "max-w-7xl mx-auto", padding: "section", children: _jsxs(Stack, { gap: "md", className: "min-w-0", children: [_jsx(Heading, { level: 2, size: "lg", children: "What it is made of" }), _jsx(ModelCompositionPanel, { compositions: compositions, hrefForPart: (slot, tail) => String(ROUTES.PUBLIC.PART_DETAIL(slot, tail)) })] }) }) })), childCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", children: _jsx(Div, { className: "max-w-7xl mx-auto", padding: "inline", children: Array.from(new Set(childCategories.map((c) => c.tier)))
401
+ return (_jsxs(Main, { children: [_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldBreadcrumb) } }), _jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldCollection) } }), ldFaq && (_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldFaq) } })), _jsxs(Section, { className: `relative ${__O.hidden} ${hasCover ? "min-h-[220px] md:min-h-[280px]" : "bg-[var(--appkit-color-bg)]"}`, children: [hasCover && (_jsxs(_Fragment, { children: [_jsx(Div, { className: "absolute inset-0", children: _jsx(MediaImage, { src: coverImage, alt: "", size: "banner" }) }), _jsx(Div, { surface: "overlay-md", className: "absolute inset-0" })] })), !hasCover && hasMosaic && (_jsx(Div, { className: "relative z-10 max-w-7xl mx-auto", padding: "x-md", children: _jsx(Div, { padding: "t-lg", children: _jsx(ImageMosaic, { images: mosaicImages, alt: `${category?.name ?? slug} on LetItRip` }) }) })), _jsxs(Div, { className: "relative z-10 max-w-7xl mx-auto", paddingY: hasCover ? "y-3xl" : "y-xl", padding: "x-md", children: [_jsx(BreadcrumbTrail, { crumbs: trail, className: "mb-4", tone: hasCover ? "on-media" : "default" }), category?.id && _jsx(LastCategoryBeacon, { categoryId: category.id }), _jsx(Heading, { color: "inverse", level: 1, className: `mb-2 ${hasCover ? "" : "text-[var(--appkit-color-text)]"}`, size: "3xl", mdSize: "4xl", weight: "bold", children: category?.name ?? slug }), category?.description && typeof category.description === "string" && !category.description.startsWith("{") && (_jsx(Text, { color: hasCover ? "inverse" : "muted", className: `max-w-2xl mb-4 ${hasCover ? "/80" : ""}`, size: "base", children: category.description })), _jsxs(Row, { wrap: true, gap: "sm", children: [productCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: "inverse", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "bg-primary/10 text-primary-700 dark:text-primary-400", rounded: "full", padding: "pill-sm-tall", children: [productCount.toLocaleString(), " ", productCount === 1 ? "product" : "products"] })), showCountSplit && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "muted", size: "xs", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [ownHere.toLocaleString(), " here \u00B7 ", inSubcategories.toLocaleString(), " in subcategories"] })), auctionCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "warning", surface: hasCover ? undefined : "warning-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [auctionCount.toLocaleString(), " ", auctionCount === 1 ? "auction" : "auctions"] })), preOrderCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "info", surface: hasCover ? undefined : "info-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [preOrderCount.toLocaleString(), " ", preOrderCount === 1 ? "pre-order" : "pre-orders"] })), storeCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "success", surface: hasCover ? undefined : "success-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [storeCount.toLocaleString(), " ", storeCount === 1 ? "store" : "stores"] }))] })] })] }), contentBody && contentBody.trim().length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(RichTextRenderer, { html: contentBody }) }) })), _jsx(CategoryHighlightsAndFaqSection, { highlights: category?.highlights, faqs: category?.faqs }), compositions.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", children: _jsx(Div, { className: __W, padding: "section", children: _jsxs(Stack, { gap: "md", className: "min-w-0", children: [_jsx(Heading, { level: 2, size: "lg", children: "What it is made of" }), _jsx(ModelCompositionPanel, { compositions: compositions, hrefForPart: (slot, tail) => String(ROUTES.PUBLIC.PART_DETAIL(slot, tail)) })] }) }) })), (() => {
402
+ if (category?.tier !== 0)
403
+ return null;
404
+ const branches = childCategories.filter((c) => c.tier === 1);
405
+ if (branches.length !== 2)
406
+ return null;
407
+ const [a, b] = branches;
408
+ const coverOf = (c) => c?.display?.coverImage;
409
+ if (!a || !b || !coverOf(a) || !coverOf(b))
410
+ return null;
411
+ const countOf = (c) => {
412
+ const n = c?.metrics?.totalProductCount ?? c?.metrics?.productCount ?? 0;
413
+ return n > 0 ? `${n.toLocaleString()} listing${n === 1 ? "" : "s"}` : undefined;
414
+ };
415
+ return (_jsx(Section, { padding: "y-lg", children: _jsx(Div, { className: __W, padding: "x-md", children: _jsx(SplitTile, { left: {
416
+ href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(a.slug)),
417
+ label: a.name,
418
+ imageUrl: coverOf(a),
419
+ countLabel: countOf(a),
420
+ }, right: {
421
+ href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(b.slug)),
422
+ label: b.name,
423
+ imageUrl: coverOf(b),
424
+ countLabel: countOf(b),
425
+ } }) }) }));
426
+ })(), childCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", children: _jsx(Div, { className: __W, padding: "inline", children: Array.from(new Set(childCategories.map((c) => c.tier)))
373
427
  .sort((a, b) => a - b)
374
428
  .map((tier) => (_jsxs(Div, { padding: "b-2xs", children: [_jsx(Text, { size: "xs", color: "muted", weight: "medium", className: "mb-[var(--appkit-space-1-5)]", children: tier - (category?.tier ?? 0) === 1
375
429
  ? "Subcategories"
@@ -0,0 +1,62 @@
1
+ import React from "react";
2
+ import type { BreadcrumbCrumb } from "../utils/breadcrumb-chain";
3
+ /**
4
+ * I5b — the visible trail follows the path the visitor actually took, when
5
+ * that path is one this listing is genuinely filed under.
6
+ *
7
+ * ## 🛑 Why this is CLIENT-side, and why the markup is not
8
+ *
9
+ * A product can be filed under several categories, so there are several valid
10
+ * trails to one page. The resolution keeps D4b's rule (*the visible trail, the
11
+ * JSON-LD and `categoryNames` must agree*) by making exactly ONE of the three
12
+ * contextual, and never the markup:
13
+ *
14
+ * - `categoryNames`, the JSON-LD and the FIRST PAINT all render the DEFAULT
15
+ * chain. Always, for everyone.
16
+ * - only the visible trail, AFTER hydration, may swap to the trail matching
17
+ * the category page the visitor came from.
18
+ *
19
+ * Doing it on the server is not available: the product routes are CACHED, so a
20
+ * referrer-dependent trail computed server-side would be baked into the cache
21
+ * entry and then served to everyone who shared that URL. And a crawler has no
22
+ * history, so it would get the default anyway.
23
+ *
24
+ * There is no flash, because the default is a REAL answer rather than a
25
+ * placeholder — the same three-state shape the guest-price gate uses.
26
+ *
27
+ * ## 🛑 `sessionStorage`, not a Firestore write and not the `history` collection
28
+ *
29
+ * One key holding the last category page visited. A navigation crumb is not
30
+ * worth a write per page view (Root Cause #94 is the record of what per-page-view
31
+ * client work costs here), and the `history` collection stores *viewed
32
+ * products* — not the path taken to them.
33
+ *
34
+ * ## 🛑 Suppressed entirely for a single assignment
35
+ *
36
+ * Which is the overwhelming majority of rows. Otherwise every product page
37
+ * gains a possible post-hydration layout shift in its first crumb row for a
38
+ * feature that applies to a handful.
39
+ *
40
+ * @tag domain:categories
41
+ * @tag layer:client-component
42
+ * @tag access:client-only
43
+ */
44
+ /** The `sessionStorage` key. Read here, written by `<LastCategoryBeacon>`. */
45
+ export declare const LAST_CATEGORY_KEY = "letitrip:last-category";
46
+ export interface ContextualTrailProps {
47
+ /**
48
+ * One entry per assignment, DEFAULT FIRST. Each carries the assignment's
49
+ * category id and the full crumb chain for it.
50
+ */
51
+ trails: ReadonlyArray<{
52
+ categoryId: string;
53
+ crumbs: readonly BreadcrumbCrumb[];
54
+ }>;
55
+ /** Forwarded to `BreadcrumbTrail` for the alternates crumb. */
56
+ alternates?: {
57
+ ids?: readonly string[] | null;
58
+ names?: readonly string[] | null;
59
+ };
60
+ className?: string;
61
+ }
62
+ export declare function ContextualTrail({ trails, alternates, className }: ContextualTrailProps): React.JSX.Element | null;
@@ -0,0 +1,81 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import React from "react";
4
+ import { storageManager } from "../../../core/StorageManager.js";
5
+ import { BreadcrumbTrail } from "./BreadcrumbTrail.js";
6
+ /**
7
+ * I5b — the visible trail follows the path the visitor actually took, when
8
+ * that path is one this listing is genuinely filed under.
9
+ *
10
+ * ## 🛑 Why this is CLIENT-side, and why the markup is not
11
+ *
12
+ * A product can be filed under several categories, so there are several valid
13
+ * trails to one page. The resolution keeps D4b's rule (*the visible trail, the
14
+ * JSON-LD and `categoryNames` must agree*) by making exactly ONE of the three
15
+ * contextual, and never the markup:
16
+ *
17
+ * - `categoryNames`, the JSON-LD and the FIRST PAINT all render the DEFAULT
18
+ * chain. Always, for everyone.
19
+ * - only the visible trail, AFTER hydration, may swap to the trail matching
20
+ * the category page the visitor came from.
21
+ *
22
+ * Doing it on the server is not available: the product routes are CACHED, so a
23
+ * referrer-dependent trail computed server-side would be baked into the cache
24
+ * entry and then served to everyone who shared that URL. And a crawler has no
25
+ * history, so it would get the default anyway.
26
+ *
27
+ * There is no flash, because the default is a REAL answer rather than a
28
+ * placeholder — the same three-state shape the guest-price gate uses.
29
+ *
30
+ * ## 🛑 `sessionStorage`, not a Firestore write and not the `history` collection
31
+ *
32
+ * One key holding the last category page visited. A navigation crumb is not
33
+ * worth a write per page view (Root Cause #94 is the record of what per-page-view
34
+ * client work costs here), and the `history` collection stores *viewed
35
+ * products* — not the path taken to them.
36
+ *
37
+ * ## 🛑 Suppressed entirely for a single assignment
38
+ *
39
+ * Which is the overwhelming majority of rows. Otherwise every product page
40
+ * gains a possible post-hydration layout shift in its first crumb row for a
41
+ * feature that applies to a handful.
42
+ *
43
+ * @tag domain:categories
44
+ * @tag layer:client-component
45
+ * @tag access:client-only
46
+ */
47
+ /** The `sessionStorage` key. Read here, written by `<LastCategoryBeacon>`. */
48
+ export const LAST_CATEGORY_KEY = "letitrip:last-category";
49
+ export function ContextualTrail({ trails, alternates, className }) {
50
+ // Index 0 is the default chain and is what the server rendered.
51
+ const [index, setIndex] = React.useState(0);
52
+ const multi = trails.length > 1;
53
+ React.useEffect(() => {
54
+ // One assignment: nothing can be swapped to, so never touch state — that is
55
+ // what guarantees no post-hydration shift on the common row.
56
+ if (!multi)
57
+ return;
58
+ /*
59
+ * 🛑 Through `storageManager`, not a private `try/catch` around
60
+ * `window.sessionStorage`. It already owns the three failure modes (SSR, a
61
+ * cross-origin iframe, a security policy that throws on mere ACCESS),
62
+ * returns `null` for all of them, and normalises what it catches — so this
63
+ * component needs no catch at all and there is one implementation of
64
+ * "storage might not exist" rather than a second, quieter copy.
65
+ */
66
+ const last = storageManager.get(LAST_CATEGORY_KEY, { type: "session" });
67
+ if (!last)
68
+ return;
69
+ const match = trails.findIndex((t) => t.categoryId === last);
70
+ // 🛑 Only swap to a trail this listing is REALLY filed under. A visitor who
71
+ // arrived from an unrelated category keeps the default, because inventing a
72
+ // trail through a category the product is not in would be a lie about the
73
+ // taxonomy, not a convenience.
74
+ if (match > 0)
75
+ setIndex(match);
76
+ }, [multi, trails]);
77
+ const active = trails[index] ?? trails[0];
78
+ if (!active)
79
+ return null;
80
+ return (_jsx(BreadcrumbTrail, { crumbs: active.crumbs, alternates: alternates, className: className }));
81
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Record which category page the visitor is on, for `<ContextualTrail>`.
3
+ *
4
+ * 🛑 `sessionStorage`, one key, one write per category page view — not a
5
+ * Firestore write and not the `history` collection (which stores viewed
6
+ * PRODUCTS, not the path taken). Root Cause #94 is the record of what
7
+ * per-page-view client work costs on this project.
8
+ *
9
+ * Renders nothing. It is a component rather than an effect inside the category
10
+ * page because that page is a Server Component and must stay one.
11
+ *
12
+ * @tag domain:categories
13
+ * @tag layer:client-component
14
+ * @tag access:client-only
15
+ */
16
+ export declare function LastCategoryBeacon({ categoryId }: {
17
+ categoryId: string;
18
+ }): null;
@@ -0,0 +1,33 @@
1
+ "use client";
2
+ import React from "react";
3
+ import { storageManager } from "../../../core/StorageManager.js";
4
+ import { LAST_CATEGORY_KEY } from "./ContextualTrail.js";
5
+ /**
6
+ * Record which category page the visitor is on, for `<ContextualTrail>`.
7
+ *
8
+ * 🛑 `sessionStorage`, one key, one write per category page view — not a
9
+ * Firestore write and not the `history` collection (which stores viewed
10
+ * PRODUCTS, not the path taken). Root Cause #94 is the record of what
11
+ * per-page-view client work costs on this project.
12
+ *
13
+ * Renders nothing. It is a component rather than an effect inside the category
14
+ * page because that page is a Server Component and must stay one.
15
+ *
16
+ * @tag domain:categories
17
+ * @tag layer:client-component
18
+ * @tag access:client-only
19
+ */
20
+ export function LastCategoryBeacon({ categoryId }) {
21
+ React.useEffect(() => {
22
+ if (!categoryId)
23
+ return;
24
+ /*
25
+ * Through `storageManager`, which already handles SSR, a cross-origin
26
+ * iframe and a policy that throws on access — so there is no catch here
27
+ * and no second copy of that logic. A `false` return means the visitor
28
+ * keeps the default trail, which is a correct answer.
29
+ */
30
+ storageManager.set(LAST_CATEGORY_KEY, categoryId, { type: "session" });
31
+ }, [categoryId]);
32
+ return null;
33
+ }
@@ -11,6 +11,27 @@ export declare class CategoriesRepository extends BaseRepository<CategoryDocumen
11
11
  search?: string;
12
12
  }): Promise<FirebaseSieveResult<CategoryDocument>>;
13
13
  createWithHierarchy(input: CategoryCreateInput): Promise<CategoryDocument>;
14
+ /**
15
+ * The LIVE rolled-up listing count for a bounded set of category ids.
16
+ *
17
+ * 🛑 This is the seam `getLiveCategoryCounts` was written for and never got.
18
+ * It had zero callers, so the five surfaces that render MANY categories at
19
+ * once — the `/categories` grid, the homepage tiles, the admin table, the
20
+ * tree and the child chips — all still read `metrics.totalProductCount`,
21
+ * which only the nightly reconciler writes. A listing published at 09:00 did
22
+ * not change any of those numbers until ~04:30 the next morning, while the
23
+ * category's own page (which counts live) disagreed with every one of them.
24
+ *
25
+ * It lives on the REPOSITORY because that is the one place already holding a
26
+ * Firestore handle: the helper needs `db.getAll`, and reaching for
27
+ * `getAdminDb()` from a route module would put a static firebase-admin import
28
+ * into a file the main barrel re-exports (Root Cause #24).
29
+ *
30
+ * 🛑 A missing id is ABSENT from the map, never 0 — the caller must fall back
31
+ * to `metrics`. See `liveTotalFor`: a count that is unknown must not be
32
+ * indistinguishable from a count of nothing (Root Cause #72).
33
+ */
34
+ liveTotalsFor(ids: readonly string[]): Promise<Map<string, number>>;
14
35
  getCategoryBySlug(slug: string): Promise<CategoryDocument | null>;
15
36
  getRootCategories(): Promise<CategoryDocument[]>;
16
37
  getLeafCategories(): Promise<CategoryDocument[]>;