@mohasinac/appkit 4.47.0 → 4.48.1
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/checkout/expand-order-items.js +3 -0
- package/dist/_internal/server/features/messages/actions.d.ts +68 -0
- package/dist/_internal/server/features/messages/actions.js +249 -0
- package/dist/_internal/server/features/parts/buildable.d.ts +47 -0
- package/dist/_internal/server/features/parts/buildable.js +159 -0
- package/dist/_internal/server/features/products/list-public.d.ts +17 -0
- package/dist/_internal/server/features/products/list-public.js +92 -5
- package/dist/_internal/server/features/products/og.d.ts +7 -0
- package/dist/_internal/server/features/products/og.js +38 -0
- package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
- package/dist/_internal/server/features/site-settings/adapters.js +6 -0
- package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +28 -0
- package/dist/_internal/server/jobs/core/notificationPrune.js +87 -3
- package/dist/_internal/server/jobs/core/priceIndexRollup.js +36 -1
- package/dist/_internal/shared/actions/action-registry.js +8 -0
- package/dist/_internal/shared/features/notifications/action-url.js +8 -0
- package/dist/_internal/shared/features/notifications/email-eligibility.js +3 -0
- package/dist/_internal/shared/features/site-settings/types.d.ts +4 -0
- package/dist/client.d.ts +10 -0
- package/dist/client.js +8 -0
- package/dist/constants/api-endpoints.d.ts +48 -0
- package/dist/constants/api-endpoints.js +30 -0
- package/dist/constants/notification-types.d.ts +1 -1
- package/dist/features/about/components/GstExplainerView.d.ts +28 -0
- package/dist/features/about/components/GstExplainerView.js +59 -0
- package/dist/features/about/components/GuidesHubView.d.ts +28 -0
- package/dist/features/about/components/GuidesHubView.js +58 -0
- package/dist/features/about/components/PublicProfileView.js +5 -0
- package/dist/features/about/components/SpotAFakeView.d.ts +29 -0
- package/dist/features/about/components/SpotAFakeView.js +49 -0
- package/dist/features/admin/actions/notification-actions.js +4 -0
- package/dist/features/admin/schemas/firestore.d.ts +13 -3
- package/dist/features/admin/schemas/firestore.js +8 -0
- package/dist/features/blog/repository/blog.repository.js +21 -7
- package/dist/features/categories/api/route.js +41 -1
- package/dist/features/categories/components/BreadcrumbTrail.d.ts +39 -17
- package/dist/features/categories/components/BreadcrumbTrail.js +46 -50
- package/dist/features/categories/components/CategoryDetailPageView.js +92 -38
- package/dist/features/categories/components/ContextualTrail.d.ts +62 -0
- package/dist/features/categories/components/ContextualTrail.js +81 -0
- package/dist/features/categories/components/LastCategoryBeacon.d.ts +18 -0
- package/dist/features/categories/components/LastCategoryBeacon.js +33 -0
- package/dist/features/categories/repository/categories.repository.d.ts +21 -0
- package/dist/features/categories/repository/categories.repository.js +36 -3
- package/dist/features/categories/repository/price-index.repository.d.ts +25 -0
- package/dist/features/email/notification-templates.js +4 -0
- package/dist/features/events/repository/events.repository.js +10 -3
- package/dist/features/faq/constants/read-caps.d.ts +29 -0
- package/dist/features/faq/constants/read-caps.js +29 -0
- package/dist/features/faq/repository/faqs.repository.d.ts +0 -2
- package/dist/features/faq/repository/faqs.repository.js +19 -16
- package/dist/features/homepage/components/AdSlot.js +10 -4
- package/dist/features/homepage/hooks/useActiveAd.d.ts +18 -1
- package/dist/features/homepage/hooks/useActiveAd.js +42 -2
- package/dist/features/homepage/lib/section-renderer.js +12 -2
- package/dist/features/layout/AppLayoutShell.d.ts +4 -1
- package/dist/features/layout/AppLayoutShell.js +2 -2
- package/dist/features/layout/TitleBarLayout.d.ts +4 -1
- package/dist/features/layout/TitleBarLayout.js +8 -4
- package/dist/features/media/AvatarUpload.js +8 -1
- package/dist/features/media/MediaAvatar.js +1 -1
- package/dist/features/media/MediaImage.js +3 -1
- package/dist/features/media/MediaLightbox.js +4 -1
- package/dist/features/media/MediaPickerModal.js +6 -1
- package/dist/features/media/MediaVideo.js +3 -1
- package/dist/features/messages/components/ConversationThreadView.d.ts +3 -0
- package/dist/features/messages/components/ConversationThreadView.js +75 -0
- package/dist/features/messages/components/ConversationsInboxView.d.ts +1 -0
- package/dist/features/messages/components/ConversationsInboxView.js +39 -0
- package/dist/features/messages/components/MessageSellerButton.d.ts +17 -0
- package/dist/features/messages/components/MessageSellerButton.js +33 -0
- package/dist/features/messages/hooks/useConversationChannel.d.ts +24 -0
- package/dist/features/messages/hooks/useConversationChannel.js +89 -0
- package/dist/features/messages/hooks/useMessages.d.ts +347 -0
- package/dist/features/messages/hooks/useMessages.js +113 -0
- package/dist/features/messages/repository/conversations.repository.d.ts +44 -0
- package/dist/features/messages/repository/conversations.repository.js +173 -0
- package/dist/features/messages/repository/messages.repository.d.ts +42 -0
- package/dist/features/messages/repository/messages.repository.js +142 -0
- package/dist/features/messages/schemas/firestore.d.ts +99 -0
- package/dist/features/messages/schemas/firestore.js +46 -0
- package/dist/features/orders/repository/orders.repository.js +21 -7
- package/dist/features/orders/schemas/firestore.d.ts +11 -0
- package/dist/features/products/components/CityCollectiblesView.d.ts +31 -0
- package/dist/features/products/components/CityCollectiblesView.js +82 -0
- package/dist/features/products/components/MarketPriceAnchor.d.ts +45 -0
- package/dist/features/products/components/MarketPriceAnchor.js +62 -0
- package/dist/features/products/components/ProductDetailPageView.js +71 -5
- package/dist/features/products/constants/meetup-cities.d.ts +47 -0
- package/dist/features/products/constants/meetup-cities.js +49 -0
- package/dist/features/products/repository/category-assignment.d.ts +82 -11
- package/dist/features/products/repository/category-assignment.js +93 -12
- package/dist/features/products/repository/products.repository.d.ts +12 -1
- package/dist/features/products/repository/products.repository.js +66 -34
- package/dist/features/products/schemas/firestore.d.ts +24 -2
- package/dist/features/products/schemas/firestore.js +13 -1
- package/dist/features/products/schemas/index.d.ts +2 -2
- package/dist/features/promotions/repository/coupons.repository.js +11 -4
- package/dist/features/reviews/repository/reviews.repository.js +11 -4
- package/dist/features/scams/repository/scammer.repository.js +21 -7
- package/dist/features/seller/components/SellerProductShell.d.ts +1 -1
- package/dist/features/seller/components/SellerProductShell.js +2 -2
- package/dist/features/seller/repository/offer.repository.js +21 -6
- package/dist/features/seller/schemas/product-form.d.ts +3 -3
- package/dist/features/seller/schemas/product-form.js +1 -1
- package/dist/features/server-errors/repository/server-errors.repository.js +11 -0
- package/dist/features/server-errors/schemas/firestore.d.ts +17 -0
- package/dist/features/server-errors/schemas/firestore.js +2 -0
- package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +17 -1
- package/dist/features/store-extensions/repository/store-extensions.repositories.js +31 -0
- package/dist/features/store-extensions/schemas/firestore.d.ts +1 -1
- package/dist/features/store-extensions/schemas/firestore.js +4 -0
- package/dist/features/stores/repository/store.repository.js +11 -3
- package/dist/features/tester/seed-data/authored/community-support__messages.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/community-support__messages.js +200 -0
- package/dist/features/tester/seed-data/authored/index.js +2 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +64 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.js +1 -1
- package/dist/next/routing/route-map.d.ts +14 -0
- package/dist/next/routing/route-map.js +26 -0
- package/dist/providers/db-firebase/rtdb-paths.d.ts +8 -0
- package/dist/providers/db-firebase/rtdb-paths.js +8 -0
- package/dist/repositories/index.d.ts +1 -0
- package/dist/repositories/index.js +4 -0
- package/dist/schemas/registry.d.ts +3 -3
- package/dist/security/index.d.ts +1 -1
- package/dist/security/index.js +1 -1
- package/dist/security/pii-schemas.d.ts +17 -0
- package/dist/security/pii-schemas.js +15 -7
- package/dist/seed/_helpers/category-content-generated.d.ts +3 -3
- package/dist/seed/_helpers/category-content-generated.js +23 -7
- package/dist/seed/_helpers/category-models.d.ts +1 -1
- package/dist/seed/_helpers/category-models.js +25 -1
- package/dist/seed/_helpers/category-names.d.ts +54 -0
- package/dist/seed/_helpers/category-names.js +118 -0
- package/dist/seed/_helpers/character-features.d.ts +1 -1
- package/dist/seed/_helpers/character-features.js +5 -4
- package/dist/seed/_helpers/final-sale-fixtures.js +1 -1
- package/dist/seed/categories-seed-data.js +55 -1
- package/dist/seed/grouped-listings-seed-data.js +2 -2
- package/dist/seed/products-art-seed-data.js +2 -1
- package/dist/seed/products-auctions-seed-data.js +42 -22
- package/dist/seed/products-classifieds-seed-data.js +21 -3
- package/dist/seed/products-digital-codes-seed-data.js +2 -1
- package/dist/seed/products-live-items-seed-data.js +2 -1
- package/dist/seed/products-preorders-seed-data.js +26 -11
- package/dist/seed/products-prize-draws-seed-data.js +8 -1
- package/dist/seed/products-standard-seed-data.js +26 -25
- package/dist/seed/products-stickers-seed-data.js +12 -11
- package/dist/server.d.ts +8 -0
- package/dist/server.js +15 -0
- package/dist/styles.css +113 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/themes/sunset.js +17 -0
- package/dist/tokens/tokens.css +5 -0
- package/dist/ui/components/Breadcrumb.d.ts +8 -1
- package/dist/ui/components/Breadcrumb.js +2 -2
- package/dist/ui/components/Breadcrumb.style.css +37 -0
- package/dist/ui/components/Details.d.ts +11 -1
- package/dist/ui/components/Details.js +2 -2
- package/dist/ui/components/ImageMosaic.d.ts +43 -0
- package/dist/ui/components/ImageMosaic.js +46 -0
- package/dist/ui/components/ImageMosaic.style.css +69 -0
- package/dist/ui/components/SplitTile.d.ts +34 -0
- package/dist/ui/components/SplitTile.js +28 -0
- package/dist/ui/components/index.style.css +1 -0
- package/dist/ui/index.d.ts +4 -0
- package/dist/ui/index.js +4 -0
- package/dist/utils/search-txt-query.d.ts +77 -0
- package/dist/utils/search-txt-query.js +50 -28
- package/package.json +1 -1
|
@@ -1,41 +1,49 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { Breadcrumb } from "../../../ui/index.js";
|
|
4
|
-
import {
|
|
4
|
+
import { Details, Summary, Div, Row, Stack } from "../../../ui/index.js";
|
|
5
|
+
import { Span } from "../../../ui/components/Typography.js";
|
|
5
6
|
import { TextLink } from "../../../ui/components/TextLink.js";
|
|
6
7
|
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
7
|
-
import { alternateAssignments, collapseTrail } from "../utils/breadcrumb-chain.js";
|
|
8
|
+
import { alternateAssignments, collapseTrail, } from "../utils/breadcrumb-chain.js";
|
|
8
9
|
/**
|
|
9
|
-
* BreadcrumbTrail —
|
|
10
|
+
* BreadcrumbTrail — one row of crumbs, with the middle and the alternates
|
|
11
|
+
* collapsed behind real `<details>` dropdowns.
|
|
10
12
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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.
|
|
14
23
|
*
|
|
15
24
|
* @example
|
|
16
25
|
* ```tsx
|
|
17
|
-
* <BreadcrumbTrail
|
|
18
|
-
* category={category}
|
|
19
|
-
* rootHref="/categories"
|
|
20
|
-
* basePath="/categories"
|
|
21
|
-
* />
|
|
26
|
+
* <BreadcrumbTrail crumbs={withTrailHead(categoryCrumbs(category), HEAD)} />
|
|
22
27
|
* ```
|
|
23
28
|
*/
|
|
24
|
-
export function BreadcrumbTrail({ category, rootLabel = "All Categories", rootHref = String(ROUTES.PUBLIC.CATEGORIES), basePath = String(ROUTES.PUBLIC.CATEGORIES), showCurrent = true, className, collapse = true, alternates, }) {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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;
|
|
39
47
|
/*
|
|
40
48
|
* I5b — the alternates become ONE extra crumb, never a second row. Two rows
|
|
41
49
|
* of crumbs above a title is a pattern no competitor in the crawl uses and it
|
|
@@ -46,31 +54,19 @@ export function BreadcrumbTrail({ category, rootLabel = "All Categories", rootHr
|
|
|
46
54
|
categoryIdNames: alternates?.names ?? null,
|
|
47
55
|
hrefFor: (slug) => `${basePath}/${slug}`,
|
|
48
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);
|
|
49
62
|
/*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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.
|
|
53
67
|
*/
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
? [
|
|
57
|
-
...split.lead.map(fromCrumb),
|
|
58
|
-
/*
|
|
59
|
-
* The collapsed group as ONE crumb whose label names what it hides.
|
|
60
|
-
* 🛑 Rendered as a real crumb with no href rather than a menu,
|
|
61
|
-
* because `Breadcrumb` is a plain list primitive — and the hidden
|
|
62
|
-
* ancestors are still reachable: every one of them is an ancestor of
|
|
63
|
-
* the crumbs either side, so no path is lost, only shortened.
|
|
64
|
-
*/
|
|
65
|
-
{ label: `… ${split.collapsed.length} more` },
|
|
66
|
-
...split.tail.map(fromCrumb),
|
|
67
|
-
]
|
|
68
|
-
: full;
|
|
69
|
-
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: items, className: className }), alt.mode !== "none" && (_jsxs(Text, { size: "xs", color: "muted", children: ["Also in", " ", alt.items.map((a, i) => (_jsxs(React.Fragment, { children: [i > 0 && " · ", _jsx(TextLink, { href: a.url ?? "#", children: a.name })] }, a.url ?? a.name)))] }))] }));
|
|
70
|
-
}
|
|
71
|
-
/** `BreadcrumbItem` ⇄ `BreadcrumbCrumb` — two names for one shape. */
|
|
72
|
-
function toCrumbs(items) {
|
|
73
|
-
return items.map((i) => ({ name: i.label, url: i.href }));
|
|
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))) }) })] }))] }));
|
|
74
70
|
}
|
|
75
71
|
function fromCrumb(c) {
|
|
76
72
|
return c.url ? { label: c.name, href: c.url } : { label: c.name };
|
|
@@ -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,
|
|
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
|
|
44
|
-
|
|
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
|
-
* 🛑
|
|
232
|
-
* `toStoreListItem
|
|
233
|
-
*
|
|
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
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
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
|
-
|
|
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
|
|
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" })] })),
|
|
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[]>;
|
|
@@ -3,8 +3,9 @@ import { CATEGORY_COUNTS_COLLECTION, } from "../schemas/category-counts.js";
|
|
|
3
3
|
import { increment, arrayUnion, arrayRemove, } from "../../../contracts/field-ops.js";
|
|
4
4
|
import { DatabaseError } from "../../../errors/index.js";
|
|
5
5
|
import { BaseRepository, prepareForFirestore, parseSieveDateValue, applySieveToFirestore, } from "../../../providers/db-firebase/index.js";
|
|
6
|
+
import { getLiveCategoryCounts } from "../../../_internal/server/features/categories/live-counts.js";
|
|
6
7
|
import { buildCategorySearchTxt } from "../../../utils/search-txt-builders.js";
|
|
7
|
-
import { planSearchTxt,
|
|
8
|
+
import { planSearchTxt, beginSearchWindow, emptySearchResult, } from "../../../utils/search-txt-query.js";
|
|
8
9
|
import { PRODUCT_FIELDS } from "../../../constants/field-names.js";
|
|
9
10
|
import { CATEGORY_FIELDS, CATEGORIES_COLLECTION, MIN_ITEMS_FOR_FEATURED, buildCategoryTree, calculateCategoryFields, canBeFeatured, createCategoryId, isValidCategoryMove, } from "../schemas/index.js";
|
|
10
11
|
export class CategoriesRepository extends BaseRepository {
|
|
@@ -34,13 +35,21 @@ export class CategoriesRepository extends BaseRepository {
|
|
|
34
35
|
return this.sieveQuery(model, CategoriesRepository.SIEVE_FIELDS);
|
|
35
36
|
}
|
|
36
37
|
const baseQuery = this.getCollection().where(CATEGORY_FIELDS.SEARCH_TXT, "array-contains", plan.head);
|
|
38
|
+
/*
|
|
39
|
+
* One bounded window, one raised `maxPageSize`, one page slice — see
|
|
40
|
+
* `beginSearchWindow`. Hand-writing the three is how the other ten
|
|
41
|
+
* repositories each ended up with two of them, which reads as a working
|
|
42
|
+
* multi-word search and silently cannot reach page 2.
|
|
43
|
+
*/
|
|
44
|
+
const win = beginSearchWindow(model, plan);
|
|
37
45
|
const result = await applySieveToFirestore({
|
|
38
46
|
baseQuery,
|
|
39
|
-
model,
|
|
47
|
+
model: win.model,
|
|
48
|
+
options: { maxPageSize: win.maxPageSize(100) },
|
|
40
49
|
fields: CategoriesRepository.SIEVE_FIELDS,
|
|
41
50
|
mapDoc: (snap) => this.mapDoc(snap),
|
|
42
51
|
});
|
|
43
|
-
return
|
|
52
|
+
return win.finish(result);
|
|
44
53
|
}
|
|
45
54
|
async createWithHierarchy(input) {
|
|
46
55
|
try {
|
|
@@ -95,6 +104,30 @@ export class CategoriesRepository extends BaseRepository {
|
|
|
95
104
|
throw new DatabaseError(`Failed to create category: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
96
105
|
}
|
|
97
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* The LIVE rolled-up listing count for a bounded set of category ids.
|
|
109
|
+
*
|
|
110
|
+
* 🛑 This is the seam `getLiveCategoryCounts` was written for and never got.
|
|
111
|
+
* It had zero callers, so the five surfaces that render MANY categories at
|
|
112
|
+
* once — the `/categories` grid, the homepage tiles, the admin table, the
|
|
113
|
+
* tree and the child chips — all still read `metrics.totalProductCount`,
|
|
114
|
+
* which only the nightly reconciler writes. A listing published at 09:00 did
|
|
115
|
+
* not change any of those numbers until ~04:30 the next morning, while the
|
|
116
|
+
* category's own page (which counts live) disagreed with every one of them.
|
|
117
|
+
*
|
|
118
|
+
* It lives on the REPOSITORY because that is the one place already holding a
|
|
119
|
+
* Firestore handle: the helper needs `db.getAll`, and reaching for
|
|
120
|
+
* `getAdminDb()` from a route module would put a static firebase-admin import
|
|
121
|
+
* into a file the main barrel re-exports (Root Cause #24).
|
|
122
|
+
*
|
|
123
|
+
* 🛑 A missing id is ABSENT from the map, never 0 — the caller must fall back
|
|
124
|
+
* to `metrics`. See `liveTotalFor`: a count that is unknown must not be
|
|
125
|
+
* indistinguishable from a count of nothing (Root Cause #72).
|
|
126
|
+
*/
|
|
127
|
+
async liveTotalsFor(ids) {
|
|
128
|
+
const { total } = await getLiveCategoryCounts(this.db, ids);
|
|
129
|
+
return total;
|
|
130
|
+
}
|
|
98
131
|
async getCategoryBySlug(slug) {
|
|
99
132
|
try {
|
|
100
133
|
const snapshot = await this.db
|