@mohasinac/appkit 4.52.1 → 4.52.3
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/client/features/digital-code/CodeRevealPanel.js +4 -4
- package/dist/_internal/server/features/products/list-public.js +37 -1
- package/dist/features/about/components/PolicyPageView.js +1 -1
- package/dist/features/auctions/components/MarketplaceAuctionCard.js +2 -2
- package/dist/features/auth/components/SocialAuthButtons.js +1 -1
- package/dist/features/categories/components/CategoryBundlesListing.js +1 -1
- package/dist/features/categories/components/CategoryDetailPageView.js +8 -7
- package/dist/features/checkout/components/CheckoutStepper.js +1 -1
- package/dist/features/faq/components/FAQAccordion.js +1 -1
- package/dist/features/filters/FilterFacetSection.js +1 -1
- package/dist/features/filters/RangeFilter.js +1 -1
- package/dist/features/filters/SwitchFilter.js +1 -1
- package/dist/features/homepage/components/BeforeAfterCard.js +2 -2
- package/dist/features/homepage/components/CharacterHotspot.js +1 -1
- package/dist/features/homepage/components/FAQSection.js +1 -1
- package/dist/features/homepage/components/HeroBanner.js +1 -1
- package/dist/features/homepage/components/HeroCarousel.js +4 -4
- package/dist/features/homepage/components/HowItWorksSection.js +1 -1
- package/dist/features/homepage/components/SectionCarousel.js +1 -1
- package/dist/features/homepage/components/SecurityHighlightsSection.js +1 -1
- package/dist/features/homepage/components/TrustBadges.js +1 -1
- package/dist/features/homepage/components/WelcomeSection.js +1 -1
- package/dist/features/layout/AppLayoutShell.js +8 -1
- package/dist/features/layout/BackToTop.js +2 -2
- package/dist/features/layout/BottomActions.js +20 -5
- package/dist/features/layout/BottomChrome.d.ts +0 -29
- package/dist/features/layout/BottomChrome.js +72 -7
- package/dist/features/live/components/LiveItemDetailPageView.js +1 -1
- package/dist/features/products/components/ProductFeaturesSelector.d.ts +38 -1
- package/dist/features/products/components/ProductFeaturesSelector.js +133 -34
- package/dist/features/products/hooks/useProducts.js +18 -1
- package/dist/features/reviews/components/ReviewDetailShell.js +2 -1
- package/dist/features/reviews/components/ReviewSummary.js +1 -1
- package/dist/features/tester/seed-data/_happy-path.js +1 -1
- package/dist/features/tester/seed-data/authored/admin__catalog-listings.js +1 -1
- package/dist/features/tester/seed-data/authored/content-discovery__category-brand-relations.js +3 -3
- package/dist/features/tester/seed-data/authored/content-discovery__category-tabs-franchise-bundles.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/content-discovery__category-tabs-franchise-bundles.js +152 -0
- package/dist/features/tester/seed-data/authored/design-ux__action-bar-placement.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/design-ux__action-bar-placement.js +89 -0
- package/dist/features/tester/seed-data/authored/design-ux__feature-badge-picker.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/design-ux__feature-badge-picker.js +82 -0
- package/dist/features/tester/seed-data/authored/design-ux__rich-text-readability.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/design-ux__rich-text-readability.js +84 -0
- package/dist/features/tester/seed-data/authored/index.js +8 -0
- package/dist/features/tester/seed-data/authored/public-pages__core-listing-pages.js +2 -2
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +157 -3
- package/dist/styles.css +74 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/RichTextEditor.js +1 -1
- package/dist/ui/rich-text/RichText.style.css +73 -0
- package/package.json +1 -1
|
@@ -44,10 +44,13 @@ const DESKTOP_REVEAL_SCROLL_PX = 400;
|
|
|
44
44
|
// ─── Sub-components ───────────────────────────────────────────────────────────
|
|
45
45
|
function BulkPickerPanel({ bulkActions, selectedActionId, pickerOpen, onSelect, }) {
|
|
46
46
|
return (_jsx(Div, { role: "listbox", "aria-label": "Bulk actions", className: [
|
|
47
|
-
|
|
47
|
+
// Flips with the tier — see InfoPanel for why.
|
|
48
|
+
"absolute left-0 right-0 overflow-hidden",
|
|
49
|
+
"bottom-full lg:bottom-auto lg:top-full",
|
|
48
50
|
BOTTOM_NAV_BG,
|
|
49
|
-
"border-t border-
|
|
51
|
+
"border-t lg:border-t-0 lg:border-b border-[var(--appkit-color-border)]/80",
|
|
50
52
|
"shadow-[0_-8px_24px_rgba(0,0,0,0.10)] dark:shadow-[0_-8px_24px_rgba(0,0,0,0.35)]",
|
|
53
|
+
"lg:shadow-[0_8px_24px_rgba(0,0,0,0.10)] dark:lg:shadow-[0_8px_24px_rgba(0,0,0,0.35)]",
|
|
51
54
|
"transition-[max-height,opacity] duration-200 ease-out",
|
|
52
55
|
pickerOpen ? "max-h-64 opacity-100" : "max-h-0 opacity-0 pointer-events-none",
|
|
53
56
|
]
|
|
@@ -78,10 +81,16 @@ function BulkPickerPanel({ bulkActions, selectedActionId, pickerOpen, onSelect,
|
|
|
78
81
|
*/
|
|
79
82
|
function InfoPanel({ id, open, children, }) {
|
|
80
83
|
return (_jsx(Div, { id: id, className: [
|
|
81
|
-
|
|
84
|
+
// Opens UPWARD from a bottom-edge bar, DOWNWARD from a desktop bar
|
|
85
|
+
// hanging under the header — a panel must always expand into the
|
|
86
|
+
// screen, never off it. Border and shadow flip with it, or the sheet
|
|
87
|
+
// is lit from the wrong side and reads as detached from its bar.
|
|
88
|
+
"absolute left-0 right-0 overflow-hidden",
|
|
89
|
+
"bottom-full lg:bottom-auto lg:top-full",
|
|
82
90
|
BOTTOM_NAV_BG,
|
|
83
|
-
"border-t border-
|
|
91
|
+
"border-t lg:border-t-0 lg:border-b border-[var(--appkit-color-border)]/80",
|
|
84
92
|
"shadow-[0_-8px_24px_rgba(0,0,0,0.10)] dark:shadow-[0_-8px_24px_rgba(0,0,0,0.35)]",
|
|
93
|
+
"lg:shadow-[0_8px_24px_rgba(0,0,0,0.10)] dark:lg:shadow-[0_8px_24px_rgba(0,0,0,0.35)]",
|
|
85
94
|
"transition-[max-height,opacity] duration-200 ease-out",
|
|
86
95
|
open ? "max-h-[60vh] opacity-100" : "max-h-0 opacity-0 pointer-events-none",
|
|
87
96
|
]
|
|
@@ -273,11 +282,17 @@ export default function BottomActions() {
|
|
|
273
282
|
.filter(Boolean)
|
|
274
283
|
.join(" "), "data-section": "bottomactions-div-401", children: [isBulkMode && (_jsx(BulkPickerPanel, { bulkActions: bulkActions, selectedActionId: selectedActionId, pickerOpen: pickerOpen, onSelect: (id) => { setSelectedActionId(id); setPickerOpen(false); } })), hasInfoPanel && !isBulkMode && (_jsx(InfoPanel, { id: infoPanelId, open: infoOpen, children: infoPanel })), _jsx(Div, { className: `grid transition-[grid-template-rows] duration-300 ease-out ${isVisible ? "grid-rows-[1fr]" : "grid-rows-[0fr]"}`, children: _jsx(Div, { overflow: "hidden", className: "min-h-0", children: _jsxs(Div, { className: [
|
|
275
284
|
BOTTOM_NAV_BG,
|
|
285
|
+
// Cast away from the edge the bar is welded to: upward from the
|
|
286
|
+
// bottom edge, downward from under the header.
|
|
276
287
|
"shadow-[0_-4px_20px_rgba(0,0,0,0.08)] dark:shadow-[0_-4px_20px_rgba(0,0,0,0.30)]",
|
|
288
|
+
"lg:shadow-[0_4px_20px_rgba(0,0,0,0.08)] dark:lg:shadow-[0_4px_20px_rgba(0,0,0,0.30)]",
|
|
289
|
+
// A hairline on the side facing the page, so the bar reads as
|
|
290
|
+
// chrome rather than as a floating slab.
|
|
291
|
+
"lg:border-b lg:border-[var(--appkit-color-border)]/80",
|
|
277
292
|
isVisible ? "" : "pointer-events-none",
|
|
278
293
|
]
|
|
279
294
|
.filter(Boolean)
|
|
280
|
-
.join(" "), children: [isBulkMode && (_jsx(Div, { className: "h-[3px] w-full bg-[image:var(--appkit-gradient-brand-tri)]" })), secondaryLabel && !isBulkMode && (_jsx(Div, { className: "pt-[var(--appkit-space-2)] pb-[var(--appkit-space-0)] border-b border-[var(--appkit-color-border-subtle)]", padding: "x-md", children: _jsx(Text, { className: "leading-5 truncate", color: "muted", size: "xs", weight: "semibold", children: secondaryLabel }) })), infoLabel && !isBulkMode && !hasInfoPanel && (_jsx(Div, { className: "pt-[var(--appkit-space-2)] pb-[var(--appkit-space-0)] border-b border-[var(--appkit-color-border-subtle)]", padding: "x-md", children: _jsx(Text, { className: "leading-5 truncate", color: "muted", size: "xs", weight: "semibold", children: infoLabel }) })), infoLabel && !isBulkMode && hasInfoPanel && (_jsxs(Button, { type: "button", variant: "ghost", onClick: () => setInfoOpen((o) => !o), "aria-expanded": infoOpen, "aria-controls": infoPanelId, className: "w-full flex items-center justify-between gap-[var(--appkit-space-2)] rounded-none border-b border-[var(--appkit-color-border)]/80 px-[var(--appkit-space-4)] pt-[var(--appkit-space-2)] pb-[var(--appkit-space-1)] min-h-0", children: [_jsx(Text, { className: "leading-5 truncate", color: "muted", size: "xs", weight: "semibold", children: infoLabel }), _jsxs(Span, { className: "flex-shrink-0 flex items-center gap-[var(--appkit-space-1)]", children: [_jsx(Text, { as: "span", size: "xs", color: "muted", children: infoOpen ? "Hide" : "Details" }), infoOpen ? (_jsx(ChevronDown, { className: "w-4 h-4 text-zinc-400", "aria-hidden": "true" })) : (_jsx(ChevronUp, { className: "w-4 h-4 text-zinc-400", "aria-hidden": "true" }))] })] })), _jsx(Row, { className: `min-${BOTTOM_NAV_HEIGHT} py-[var(--appkit-space-2)]`, gap: "sm", padding: "x-sm", children: isBulkMode && bulk ? (_jsxs(_Fragment, { children: [_jsxs(Button, { rounded: "full", gap: "xs", type: "button", variant: "ghost", onClick: dispatchBulkClear, className: "
|
|
295
|
+
.join(" "), children: [isBulkMode && (_jsx(Div, { className: "h-[3px] w-full bg-[image:var(--appkit-gradient-brand-tri)]" })), secondaryLabel && !isBulkMode && (_jsx(Div, { className: "pt-[var(--appkit-space-2)] pb-[var(--appkit-space-0)] border-b border-[var(--appkit-color-border-subtle)]", padding: "x-md", children: _jsx(Text, { className: "leading-5 truncate", color: "muted", size: "xs", weight: "semibold", children: secondaryLabel }) })), infoLabel && !isBulkMode && !hasInfoPanel && (_jsx(Div, { className: "pt-[var(--appkit-space-2)] pb-[var(--appkit-space-0)] border-b border-[var(--appkit-color-border-subtle)]", padding: "x-md", children: _jsx(Text, { className: "leading-5 truncate", color: "muted", size: "xs", weight: "semibold", children: infoLabel }) })), infoLabel && !isBulkMode && hasInfoPanel && (_jsxs(Button, { type: "button", variant: "ghost", onClick: () => setInfoOpen((o) => !o), "aria-expanded": infoOpen, "aria-controls": infoPanelId, className: "w-full flex items-center justify-between gap-[var(--appkit-space-2)] rounded-none border-b border-[var(--appkit-color-border)]/80 px-[var(--appkit-space-4)] pt-[var(--appkit-space-2)] pb-[var(--appkit-space-1)] min-h-0", children: [_jsx(Text, { className: "leading-5 truncate", color: "muted", size: "xs", weight: "semibold", children: infoLabel }), _jsxs(Span, { className: "flex-shrink-0 flex items-center gap-[var(--appkit-space-1)]", children: [_jsx(Text, { as: "span", size: "xs", color: "muted", children: infoOpen ? "Hide" : "Details" }), infoOpen ? (_jsx(ChevronDown, { className: "w-4 h-4 text-zinc-400", "aria-hidden": "true" })) : (_jsx(ChevronUp, { className: "w-4 h-4 text-zinc-400", "aria-hidden": "true" }))] })] })), _jsx(Row, { className: `min-${BOTTOM_NAV_HEIGHT} py-[var(--appkit-space-2)]`, gap: "sm", padding: "x-sm", children: isBulkMode && bulk ? (_jsxs(_Fragment, { children: [_jsxs(Button, { rounded: "full", gap: "xs", type: "button", variant: "ghost", onClick: dispatchBulkClear, className: "flex-shrink-0 bg-primary-50 hover:bg-primary-surface active:bg-primary-200 dark:bg-primary-950/30 text-primary-700 dark:text-primary-300 pl-2 pr-3 h-8 border border-primary-200/70 dark:border-primary-800/50 transition-colors min-h-0", "aria-label": "Clear selection", children: [_jsx(X, { className: "w-3.5 h-3.5 flex-shrink-0", "aria-hidden": "true" }), _jsx(Span, { size: "xs", weight: "semibold", className: "tabular-nums whitespace-nowrap leading-none", children: bulk.noun
|
|
281
296
|
? `${bulk.selectedCount} ${bulk.noun}`
|
|
282
297
|
: `${bulk.selectedCount} selected` })] }), bulkActions.length > 0 && (_jsxs(Button, { type: "button", variant: "ghost", onClick: () => setPickerOpen((o) => !o), "aria-haspopup": "listbox", "aria-expanded": pickerOpen, className: [
|
|
283
298
|
"flex-1 min-w-0 h-10 flex items-center gap-[var(--appkit-space-2)] px-[var(--appkit-space-3)] rounded-lg border text-[length:var(--appkit-text-sm)] font-medium transition-colors",
|
|
@@ -2,33 +2,4 @@ import React from "react";
|
|
|
2
2
|
export interface BottomChromeProps {
|
|
3
3
|
children: React.ReactNode;
|
|
4
4
|
}
|
|
5
|
-
/**
|
|
6
|
-
* BottomChrome — the middle tier of the bottom edge.
|
|
7
|
-
*
|
|
8
|
-
* The bottom edge is three tiers: the nav bar is the floor, `BackToTop` is the
|
|
9
|
-
* ceiling, and *everything else* lives in here. This container is what makes
|
|
10
|
-
* that middle tier a single thing with a single height:
|
|
11
|
-
*
|
|
12
|
-
* --keyboard-inset-height useVisualViewportInset (the on-screen keyboard)
|
|
13
|
-
* --bottom-nav-height BottomNavLayout (the tab bar)
|
|
14
|
-
* --bottom-chrome-height THIS COMPONENT (CTA row, pagination, …)
|
|
15
|
-
* ↑ BackToTop floats above the sum of all three
|
|
16
|
-
*
|
|
17
|
-
* Because it is a flex column, its own `offsetHeight` IS the sum of whatever is
|
|
18
|
-
* inside it — a bar changing height, revealing on scroll, entering bulk mode,
|
|
19
|
-
* or being added years from now all just resize this box, and the one
|
|
20
|
-
* ResizeObserver below picks every case up. Consumers read one variable and
|
|
21
|
-
* never have to learn about individual bars. Publishing a separate var per bar
|
|
22
|
-
* was the alternative, and it fails the moment someone adds bar number three
|
|
23
|
-
* without also editing every consumer's `calc()`.
|
|
24
|
-
*
|
|
25
|
-
* Two constraints this container places on its children:
|
|
26
|
-
* - A child must hide by COLLAPSING, not by transforming. `translate-y-full`
|
|
27
|
-
* leaves layout height behind, so a hidden bar would still be reserved here
|
|
28
|
-
* on every page that has no CTA. See BottomActions' grid-rows collapse.
|
|
29
|
-
* - No `overflow: hidden` here, ever. BottomActions' bulk-picker and info
|
|
30
|
-
* panels are `absolute bottom-full` — deliberately outside the flow (they
|
|
31
|
-
* are transient tap-to-open overlays, not persistent bands, so they are
|
|
32
|
-
* excluded from the published height) — and clipping would erase them.
|
|
33
|
-
*/
|
|
34
5
|
export declare function BottomChrome({ children }: BottomChromeProps): React.JSX.Element;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useEffect, useRef } from "react";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
4
|
import { Div } from "../../ui/index.js";
|
|
5
5
|
import { BOTTOM_CHROME_SLOT_ID } from "../../ui/components/bottom-chrome-slot.js";
|
|
6
6
|
/**
|
|
@@ -28,28 +28,93 @@ import { BOTTOM_CHROME_SLOT_ID } from "../../ui/components/bottom-chrome-slot.js
|
|
|
28
28
|
* leaves layout height behind, so a hidden bar would still be reserved here
|
|
29
29
|
* on every page that has no CTA. See BottomActions' grid-rows collapse.
|
|
30
30
|
* - No `overflow: hidden` here, ever. BottomActions' bulk-picker and info
|
|
31
|
-
* panels are `absolute bottom-full`
|
|
32
|
-
* are transient tap-to-open overlays,
|
|
33
|
-
* excluded from the published height) —
|
|
31
|
+
* panels are `absolute bottom-full` on mobile / `top-full` on desktop —
|
|
32
|
+
* deliberately outside the flow (they are transient tap-to-open overlays,
|
|
33
|
+
* not persistent bands, so they are excluded from the published height) —
|
|
34
|
+
* and clipping would erase them.
|
|
35
|
+
*
|
|
36
|
+
* ── The tier CHANGES ENDS at `lg` ──────────────────────────────────────────
|
|
37
|
+
*
|
|
38
|
+
* Below `lg` it is the bottom edge, as described above. At `lg` and up it hangs
|
|
39
|
+
* from under the sticky header instead, and publishes its height as
|
|
40
|
+
* `--top-chrome-height` rather than `--bottom-chrome-height`.
|
|
41
|
+
*
|
|
42
|
+
* A full-width action bar welded to the bottom of a desktop window is a mobile
|
|
43
|
+
* pattern on a screen that has neither a thumb zone nor a tab bar to stack
|
|
44
|
+
* above — it reads as a browser download bar, and it is the furthest point on
|
|
45
|
+
* screen from the content it acts on. Under the header it sits where the eye
|
|
46
|
+
* already is and where the page's own chrome lives.
|
|
47
|
+
*
|
|
48
|
+
* Consumers therefore read whichever end they care about, and the two are
|
|
49
|
+
* mutually exclusive by construction (see `write()`).
|
|
50
|
+
*/
|
|
51
|
+
/**
|
|
52
|
+
* The breakpoint at which the tier moves from the bottom edge to under the
|
|
53
|
+
* header. 64rem is Tailwind's `lg` — the same line `BottomNavbar` disappears
|
|
54
|
+
* at, so the tier changes ends exactly when the thing it was stacking above
|
|
55
|
+
* stops existing.
|
|
34
56
|
*/
|
|
57
|
+
const DESKTOP_QUERY = "(min-width: 64rem)";
|
|
35
58
|
export function BottomChrome({ children }) {
|
|
36
59
|
const ref = useRef(null);
|
|
60
|
+
const [isDesktop, setIsDesktop] = useState(false);
|
|
61
|
+
// Matched in JS as well as in CSS because the MEASUREMENT has to know which
|
|
62
|
+
// variable to publish — a media query alone can move the box but cannot tell
|
|
63
|
+
// the ResizeObserver which end of the screen it ended up at.
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const mq = window.matchMedia(DESKTOP_QUERY);
|
|
66
|
+
const sync = () => setIsDesktop(mq.matches);
|
|
67
|
+
sync();
|
|
68
|
+
mq.addEventListener("change", sync);
|
|
69
|
+
return () => mq.removeEventListener("change", sync);
|
|
70
|
+
}, []);
|
|
37
71
|
useEffect(() => {
|
|
38
72
|
const root = document.documentElement;
|
|
39
73
|
const el = ref.current;
|
|
40
74
|
if (!el)
|
|
41
75
|
return;
|
|
42
|
-
|
|
76
|
+
/*
|
|
77
|
+
* 🛑 EXACTLY ONE of the two variables carries the height; the other is
|
|
78
|
+
* pinned to 0. They are not interchangeable and must never both be live:
|
|
79
|
+
* `--bottom-chrome-height` is clearance at the BOTTOM of the viewport
|
|
80
|
+
* (BackToTop floats above it, the footer reserves it) and
|
|
81
|
+
* `--top-chrome-height` is clearance at the TOP (content is pushed down by
|
|
82
|
+
* it). Publishing a real height to both would make every consumer reserve
|
|
83
|
+
* the bar twice — a gap the size of the bar at the end of every page on
|
|
84
|
+
* desktop, which is exactly the "looks ugly" symptom one tier up.
|
|
85
|
+
*/
|
|
86
|
+
const write = () => {
|
|
87
|
+
const h = `${el.offsetHeight}px`;
|
|
88
|
+
root.style.setProperty("--top-chrome-height", isDesktop ? h : "0px");
|
|
89
|
+
root.style.setProperty("--bottom-chrome-height", isDesktop ? "0px" : h);
|
|
90
|
+
};
|
|
43
91
|
const observer = new ResizeObserver(write);
|
|
44
92
|
observer.observe(el);
|
|
45
93
|
write();
|
|
46
94
|
return () => {
|
|
47
95
|
observer.disconnect();
|
|
48
96
|
root.style.setProperty("--bottom-chrome-height", "0px");
|
|
97
|
+
root.style.setProperty("--top-chrome-height", "0px");
|
|
49
98
|
};
|
|
50
|
-
}, []);
|
|
99
|
+
}, [isDesktop]);
|
|
51
100
|
return (_jsxs(Div, { ref: ref, layout: "flex-col",
|
|
52
101
|
// `pointer-events-none` so an empty tier never eats clicks meant for the
|
|
53
102
|
// page underneath; every child re-enables with `pointer-events-auto`.
|
|
54
|
-
|
|
103
|
+
//
|
|
104
|
+
// Desktop pins the tier directly beneath the sticky header rather than to
|
|
105
|
+
// the bottom edge. A full-width bar welded to the bottom of a 1440px
|
|
106
|
+
// window is a mobile pattern borrowed onto a screen that has no thumb
|
|
107
|
+
// zone and no tab bar to sit above — on desktop the action row belongs
|
|
108
|
+
// with the page chrome, where the eye already is. `--header-height` is
|
|
109
|
+
// published by AppLayoutShell and already accounts for the announcement
|
|
110
|
+
// banner, so this needs no second measurement.
|
|
111
|
+
//
|
|
112
|
+
// 🛑 The child's `flex-col` ORDER is reversed on desktop: at the bottom
|
|
113
|
+
// edge the portalled slot must stack above the CTA row, and once the tier
|
|
114
|
+
// hangs from the top the same visual ordering is the other way round.
|
|
115
|
+
className: [
|
|
116
|
+
"fixed left-0 right-0 z-[var(--appkit-z-bottom-nav)] pointer-events-none",
|
|
117
|
+
"bottom-[calc(var(--keyboard-inset-height,0px)+var(--bottom-nav-height,4rem))]",
|
|
118
|
+
"lg:bottom-auto lg:top-[var(--header-height,0px)] lg:flex-col-reverse",
|
|
119
|
+
].join(" "), children: [_jsx(Div, { id: BOTTOM_CHROME_SLOT_ID, className: "contents" }), children] }));
|
|
55
120
|
}
|
|
@@ -70,7 +70,7 @@ export async function LiveItemDetailPageView({ slug, initialProduct, renderActio
|
|
|
70
70
|
price: price ?? undefined,
|
|
71
71
|
storeId: typeof p.storeId === "string" ? p.storeId : undefined,
|
|
72
72
|
storeName: storeName ?? undefined,
|
|
73
|
-
} }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.LIVE), className: CLS_BREADCRUMB_LINK, children: "Live" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(PreOrderDetailView, { renderGallery: () => _jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title }), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_LIVE_ITEM_BADGE, children: "Live Item" }), meta?.vendorVerified && (_jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Verified Seller" })), meta?.cites && (_jsxs(Span, { color: "warning", surface: "warning-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: ["CITES: ", meta.cites] }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title }), meta?.species && (_jsxs(Text, { className: "italic mt-1", color: "muted", size: "sm", children: [meta.species, meta.sex && meta.sex !== "n/a" && ` · ${meta.sex}`, meta.ageMonths !== undefined && ` · ${meta.ageMonths}mo`] }))] }), price !== null && (_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: _jsx(GatedPrice, { children: formatCurrency(price, currency) }) })), jurisdictions.length > 0 && (_jsxs(Div, { className: CLS_WARN_BOX, children: [_jsx(Text, { className: CLS_WARN_TITLE, children: "Delivery restrictions" }), _jsxs(Text, { className: CLS_WARN_BODY, children: ["This item can only be shipped to: ", jurisdictions.join(", ")] })] })), transport && (_jsxs(Div, { textSize: "sm", border: "subtle", surface: "muted", padding: "md", rounded: "lg", children: [_jsx(Text, { weight: "medium", children: "Transport" }), _jsxs(Text, { className: "text-muted
|
|
73
|
+
} }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.LIVE), className: CLS_BREADCRUMB_LINK, children: "Live" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(PreOrderDetailView, { renderGallery: () => _jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title }), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_LIVE_ITEM_BADGE, children: "Live Item" }), meta?.vendorVerified && (_jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Verified Seller" })), meta?.cites && (_jsxs(Span, { color: "warning", surface: "warning-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: ["CITES: ", meta.cites] }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title }), meta?.species && (_jsxs(Text, { className: "italic mt-1", color: "muted", size: "sm", children: [meta.species, meta.sex && meta.sex !== "n/a" && ` · ${meta.sex}`, meta.ageMonths !== undefined && ` · ${meta.ageMonths}mo`] }))] }), price !== null && (_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: _jsx(GatedPrice, { children: formatCurrency(price, currency) }) })), jurisdictions.length > 0 && (_jsxs(Div, { className: CLS_WARN_BOX, children: [_jsx(Text, { className: CLS_WARN_TITLE, children: "Delivery restrictions" }), _jsxs(Text, { className: CLS_WARN_BODY, children: ["This item can only be shipped to: ", jurisdictions.join(", ")] })] })), transport && (_jsxs(Div, { textSize: "sm", border: "subtle", surface: "muted", padding: "md", rounded: "lg", children: [_jsx(Text, { weight: "medium", children: "Transport" }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", children: ["Method: ", transport.method, transport.handlingFee !== undefined && (_jsx(PricesOnly, { children: ` · Handling: ${formatCurrency(transport.handlingFee, "INR")}` })), transport.insuranceIncluded && " · Insurance included"] })] })), meta?.careInfo && (_jsxs(Div, { textSize: "sm", border: "subtle", surface: "muted", padding: "md", rounded: "lg", children: [_jsx(Text, { weight: "medium", children: "Care information" }), _jsx(Text, { className: "mt-1 text-[var(--appkit-color-text-muted)]", children: meta.careInfo })] })), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { border: "subtle", rounded: "xl", surface: "muted", padding: "md", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && (_jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] }) }))] })), renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: specs.length > 0 ? (_jsx(Dl, { divide: "subtle", rounded: "xl", border: "subtle", className: "overflow-hidden", children: specs.map((s, i) => (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-36 flex-shrink-0", color: "primary", weight: "medium", children: s.name }), _jsxs(Dd, { className: "flex-1", color: "muted", children: [s.value, s.unit ? ` ${s.unit}` : ""] })] }, i))) })) : undefined, reviewsContent: _jsx(ReviewsListingPanel, { source: { kind: "product", productId: product.id }, stateMode: "local", context: "listing", initialData: initialReviews, emptyLabel: "No reviews yet \u2014 be the first to review this product." }), customTabs: customSections.map((s) => ({
|
|
74
74
|
id: s.id,
|
|
75
75
|
label: s.title,
|
|
76
76
|
content: _jsx(CustomSectionTabContent, { section: s }),
|
|
@@ -6,8 +6,45 @@ export interface ProductFeaturesSelectorProps {
|
|
|
6
6
|
onChange: (next: string[]) => void;
|
|
7
7
|
/** Determines which platform features are eligible. */
|
|
8
8
|
productType: ProductFeatureProductType;
|
|
9
|
-
/** Store ID — when present, store-scope features are also loaded
|
|
9
|
+
/** Store ID — when present, store-scope features are also loaded, and the
|
|
10
|
+
* picker can create new store-scope badges inline. */
|
|
10
11
|
storeId?: string;
|
|
11
12
|
disabled?: boolean;
|
|
12
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Feature-badge picker.
|
|
16
|
+
*
|
|
17
|
+
* 🛑 A SEARCHABLE MULTI-SELECT, not a checkbox grid. This rendered every
|
|
18
|
+
* eligible badge as a two-column grid of bordered checkbox rows — on the real
|
|
19
|
+
* catalogue that is a wall of ~20+ tiles a seller has to read end-to-end to
|
|
20
|
+
* find "Sealed", and it grew every time an admin added a badge. CLAUDE.md's
|
|
21
|
+
* rule is explicit and this was the clearest violation of it left: anything
|
|
22
|
+
* over five options is a `<PaginatedSelect>`, because a long list nobody can
|
|
23
|
+
* search is a list nobody reads.
|
|
24
|
+
*
|
|
25
|
+
* Three things the conversion had to preserve, all of which the grid did by
|
|
26
|
+
* accident and a naive swap would have lost:
|
|
27
|
+
*
|
|
28
|
+
* - THE SCOPE DISTINCTION. Platform badges are curated and shared; store
|
|
29
|
+
* badges are the seller's own. The grid showed them under two headings.
|
|
30
|
+
* `PaginatedSelect` has no option grouping, so the scope rides in the
|
|
31
|
+
* option label as a suffix — visible in the list AND in the selected chip,
|
|
32
|
+
* which is strictly more information than the heading gave (a chip used to
|
|
33
|
+
* say nothing about where its badge came from).
|
|
34
|
+
* - THE SELECTION CEILING. `MAX_FEATURES_PER_PRODUCT` was enforced by
|
|
35
|
+
* disabling every unchecked row at the limit. A multi-select has no such
|
|
36
|
+
* affordance, so the cap is enforced in `handleChange` — which also makes
|
|
37
|
+
* it correct for a value arriving over the limit from an older document,
|
|
38
|
+
* where the old code could only show a banner and hope.
|
|
39
|
+
* - THE DESCRIPTION. Each row carried one. It is folded into the label after
|
|
40
|
+
* an em-dash so it stays searchable: typing "factory" now finds "Sealed",
|
|
41
|
+
* whose description is the only place that word appears.
|
|
42
|
+
*
|
|
43
|
+
* Create is wired only when `storeId` is present, and only to the STORE-scope
|
|
44
|
+
* endpoint. `POST /api/admin/features` is `ROLES_ADMIN_ONLY` +
|
|
45
|
+
* `admin:categories:write`, so offering it to a seller would be a control that
|
|
46
|
+
* 403s — worse than an absent one. A seller creating a badge is creating their
|
|
47
|
+
* own, which is exactly what `POST /api/store/features` does (it sets
|
|
48
|
+
* `storeId` server-side rather than trusting the body).
|
|
49
|
+
*/
|
|
13
50
|
export declare function ProductFeaturesSelector({ value, onChange, productType, storeId, disabled, }: ProductFeaturesSelectorProps): React.JSX.Element;
|
|
@@ -1,37 +1,88 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import React from "react";
|
|
4
|
-
import { useQuery } from "@tanstack/react-query";
|
|
5
|
-
import {
|
|
4
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { Div, Heading, Stack, Text } from "../../../ui/index.js";
|
|
6
|
+
import { PaginatedSelect } from "../../../ui/components/PaginatedSelect.js";
|
|
6
7
|
import { apiClient } from "../../../http/index.js";
|
|
7
|
-
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
8
|
+
import { ADMIN_ENDPOINTS, SELLER_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
8
9
|
import { PRODUCT_FEATURE_QUERY_STALE_MS } from "../constants/product-features.constants.js";
|
|
9
10
|
import { MAX_FEATURES_PER_PRODUCT, } from "../schemas/product-features.js";
|
|
10
|
-
const SECTION_LABEL_CLASS = "text-[var(--appkit-font-size-2xs,11px)] font-semibold uppercase tracking-wide text-[var(--appkit-color-text-muted)] mb-2";
|
|
11
|
-
const ROW_BASE_CLASS = "flex items-start gap-[var(--appkit-space-2)] rounded-lg border px-[var(--appkit-space-3)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-xs)] cursor-pointer transition-colors";
|
|
12
|
-
const ROW_CHECKED_CLASS = "border-primary bg-primary/5 dark:bg-primary/10";
|
|
13
|
-
const ROW_DISABLED_CLASS = "border-[var(--appkit-color-border)] opacity-50 cursor-not-allowed";
|
|
14
|
-
const ROW_DEFAULT_CLASS = "border-[var(--appkit-color-border)] hover:bg-surface-hover";
|
|
15
11
|
const OVER_LIMIT_BANNER_CLASS = "rounded-lg border border-error/20 bg-error-surface px-[var(--appkit-space-3)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-xs)] text-error";
|
|
16
12
|
const EMPTY_STATE_CLASS = "rounded-lg border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-3)] py-[var(--appkit-space-4)] text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-text-muted)] text-left";
|
|
13
|
+
/** Inline-create form for a store-scope badge. Mirrors productFeatureStoreCreateSchema's required fields. */
|
|
14
|
+
const CREATE_FIELDS = [
|
|
15
|
+
{
|
|
16
|
+
name: "label",
|
|
17
|
+
label: "Badge label",
|
|
18
|
+
type: "text",
|
|
19
|
+
required: true,
|
|
20
|
+
placeholder: "e.g. Hand-inspected",
|
|
21
|
+
helperText: "Shown on product cards and the detail page.",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "description",
|
|
25
|
+
label: "Description",
|
|
26
|
+
type: "textarea",
|
|
27
|
+
placeholder: "What this badge promises the buyer.",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "icon",
|
|
31
|
+
label: "Icon",
|
|
32
|
+
type: "text",
|
|
33
|
+
required: true,
|
|
34
|
+
defaultValue: "star",
|
|
35
|
+
helperText: "A Lucide icon name, e.g. star, shield, package.",
|
|
36
|
+
},
|
|
37
|
+
];
|
|
17
38
|
function applyProductType(list, productType) {
|
|
18
39
|
if (productType === "all")
|
|
19
40
|
return list;
|
|
20
41
|
return list.filter((f) => f.productTypes.includes("all") || f.productTypes.includes(productType));
|
|
21
42
|
}
|
|
22
|
-
function rowClass(checked, cantSelect) {
|
|
23
|
-
if (checked)
|
|
24
|
-
return `${ROW_BASE_CLASS} ${ROW_CHECKED_CLASS}`;
|
|
25
|
-
if (cantSelect)
|
|
26
|
-
return `${ROW_BASE_CLASS} ${ROW_DISABLED_CLASS}`;
|
|
27
|
-
return `${ROW_BASE_CLASS} ${ROW_DEFAULT_CLASS}`;
|
|
28
|
-
}
|
|
29
43
|
function unwrapItems(res) {
|
|
30
44
|
const body = res?.data ??
|
|
31
45
|
res;
|
|
32
46
|
return body?.items ?? [];
|
|
33
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Feature-badge picker.
|
|
50
|
+
*
|
|
51
|
+
* 🛑 A SEARCHABLE MULTI-SELECT, not a checkbox grid. This rendered every
|
|
52
|
+
* eligible badge as a two-column grid of bordered checkbox rows — on the real
|
|
53
|
+
* catalogue that is a wall of ~20+ tiles a seller has to read end-to-end to
|
|
54
|
+
* find "Sealed", and it grew every time an admin added a badge. CLAUDE.md's
|
|
55
|
+
* rule is explicit and this was the clearest violation of it left: anything
|
|
56
|
+
* over five options is a `<PaginatedSelect>`, because a long list nobody can
|
|
57
|
+
* search is a list nobody reads.
|
|
58
|
+
*
|
|
59
|
+
* Three things the conversion had to preserve, all of which the grid did by
|
|
60
|
+
* accident and a naive swap would have lost:
|
|
61
|
+
*
|
|
62
|
+
* - THE SCOPE DISTINCTION. Platform badges are curated and shared; store
|
|
63
|
+
* badges are the seller's own. The grid showed them under two headings.
|
|
64
|
+
* `PaginatedSelect` has no option grouping, so the scope rides in the
|
|
65
|
+
* option label as a suffix — visible in the list AND in the selected chip,
|
|
66
|
+
* which is strictly more information than the heading gave (a chip used to
|
|
67
|
+
* say nothing about where its badge came from).
|
|
68
|
+
* - THE SELECTION CEILING. `MAX_FEATURES_PER_PRODUCT` was enforced by
|
|
69
|
+
* disabling every unchecked row at the limit. A multi-select has no such
|
|
70
|
+
* affordance, so the cap is enforced in `handleChange` — which also makes
|
|
71
|
+
* it correct for a value arriving over the limit from an older document,
|
|
72
|
+
* where the old code could only show a banner and hope.
|
|
73
|
+
* - THE DESCRIPTION. Each row carried one. It is folded into the label after
|
|
74
|
+
* an em-dash so it stays searchable: typing "factory" now finds "Sealed",
|
|
75
|
+
* whose description is the only place that word appears.
|
|
76
|
+
*
|
|
77
|
+
* Create is wired only when `storeId` is present, and only to the STORE-scope
|
|
78
|
+
* endpoint. `POST /api/admin/features` is `ROLES_ADMIN_ONLY` +
|
|
79
|
+
* `admin:categories:write`, so offering it to a seller would be a control that
|
|
80
|
+
* 403s — worse than an absent one. A seller creating a badge is creating their
|
|
81
|
+
* own, which is exactly what `POST /api/store/features` does (it sets
|
|
82
|
+
* `storeId` server-side rather than trusting the body).
|
|
83
|
+
*/
|
|
34
84
|
export function ProductFeaturesSelector({ value, onChange, productType, storeId, disabled, }) {
|
|
85
|
+
const queryClient = useQueryClient();
|
|
35
86
|
const platformQuery = useQuery({
|
|
36
87
|
queryKey: ["product-features", "platform", "active"],
|
|
37
88
|
queryFn: async () => unwrapItems(await apiClient.get(`${ADMIN_ENDPOINTS.PRODUCT_FEATURES}?scope=platform&isActive=true`)),
|
|
@@ -45,28 +96,76 @@ export function ProductFeaturesSelector({ value, onChange, productType, storeId,
|
|
|
45
96
|
});
|
|
46
97
|
const platformFeatures = applyProductType(platformQuery.data ?? [], productType);
|
|
47
98
|
const storeFeatures = applyProductType(storeQuery.data ?? [], productType);
|
|
48
|
-
const
|
|
99
|
+
const options = React.useMemo(() => {
|
|
100
|
+
const toOption = (f, scopeSuffix) => ({
|
|
101
|
+
value: f.id,
|
|
102
|
+
// Description folded in so it is searchable — see the docstring.
|
|
103
|
+
label: f.description
|
|
104
|
+
? `${f.label} — ${f.description} ${scopeSuffix}`
|
|
105
|
+
: `${f.label} ${scopeSuffix}`,
|
|
106
|
+
});
|
|
107
|
+
return [
|
|
108
|
+
...platformFeatures.map((f) => toOption(f, "(platform)")),
|
|
109
|
+
...storeFeatures.map((f) => toOption(f, "(your store)")),
|
|
110
|
+
];
|
|
111
|
+
}, [platformFeatures, storeFeatures]);
|
|
49
112
|
const atLimit = value.length >= MAX_FEATURES_PER_PRODUCT;
|
|
50
113
|
const overLimit = value.length > MAX_FEATURES_PER_PRODUCT;
|
|
51
|
-
|
|
114
|
+
/*
|
|
115
|
+
* 🛑 The cap is applied to the INCOMING array, not to a per-row disabled
|
|
116
|
+
* state. Truncating keeps the earliest selections, which is what a user who
|
|
117
|
+
* has just exceeded the limit expects (the one they clicked last is the one
|
|
118
|
+
* that fails), and it also silently repairs an over-limit value loaded from
|
|
119
|
+
* an older document instead of blocking save behind a banner.
|
|
120
|
+
*/
|
|
121
|
+
const handleChange = (next) => {
|
|
52
122
|
if (disabled)
|
|
53
123
|
return;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
if (atLimit)
|
|
59
|
-
return;
|
|
60
|
-
onChange([...value, id]);
|
|
61
|
-
};
|
|
62
|
-
const renderRow = (f) => {
|
|
63
|
-
const checked = selectedSet.has(f.id);
|
|
64
|
-
const cantSelect = !checked && atLimit;
|
|
65
|
-
return (_jsxs(Label, { className: rowClass(checked, cantSelect), children: [_jsx(Checkbox, { bare: true, className: "mt-0.5", checked: checked, disabled: disabled || cantSelect, onChange: () => toggle(f.id), "aria-label": f.label }), _jsxs(Div, { className: "flex-1", children: [_jsx(Text, { as: "span", className: "block", color: "primary", weight: "medium", children: f.label }), f.description && (_jsx(Text, { as: "span", className: "block mt-0.5", color: "muted", children: f.description }))] })] }, f.id));
|
|
124
|
+
onChange(next.length > MAX_FEATURES_PER_PRODUCT
|
|
125
|
+
? next.slice(0, MAX_FEATURES_PER_PRODUCT)
|
|
126
|
+
: next);
|
|
66
127
|
};
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
128
|
+
/*
|
|
129
|
+
* Create is wired ONLY for a seller editing their own store, and only to the
|
|
130
|
+
* store-scope route — see the component docstring for why offering the admin
|
|
131
|
+
* one here would be a control that 403s.
|
|
132
|
+
*
|
|
133
|
+
* Extracted rather than written inline so the submit handler sits at a
|
|
134
|
+
* readable brace depth; it was five levels deep inside the JSX spread.
|
|
135
|
+
*/
|
|
136
|
+
const createBadge = React.useCallback(async (values) => {
|
|
137
|
+
const created = await apiClient.post(SELLER_ENDPOINTS.FEATURES, {
|
|
138
|
+
label: String(values.label ?? "").trim(),
|
|
139
|
+
description: values.description ? String(values.description).trim() : undefined,
|
|
140
|
+
icon: String(values.icon ?? "star").trim(),
|
|
141
|
+
// Scoped to the type being edited; "all" would quietly widen the badge
|
|
142
|
+
// to every listing type the seller owns.
|
|
143
|
+
productTypes: [productType],
|
|
144
|
+
category: "custom",
|
|
145
|
+
isActive: true,
|
|
146
|
+
displayOrder: 0,
|
|
147
|
+
});
|
|
148
|
+
const doc = created?.data ??
|
|
149
|
+
created;
|
|
150
|
+
// Refetch so the new badge becomes a real option rather than a one-off
|
|
151
|
+
// chip that disappears on the next render.
|
|
152
|
+
await queryClient.invalidateQueries({
|
|
153
|
+
queryKey: ["product-features", "store", "active", storeId],
|
|
154
|
+
});
|
|
155
|
+
return { value: doc.id, label: doc.label };
|
|
156
|
+
}, [productType, queryClient, storeId]);
|
|
157
|
+
const createProps = storeId
|
|
158
|
+
? {
|
|
159
|
+
createLabel: "Create a badge for my store",
|
|
160
|
+
drawerTitle: "New store feature badge",
|
|
161
|
+
createSubmitLabel: "Create badge",
|
|
162
|
+
createFields: CREATE_FIELDS,
|
|
163
|
+
onCreateSubmit: createBadge,
|
|
164
|
+
}
|
|
165
|
+
: {};
|
|
166
|
+
const isLoading = platformQuery.isLoading || (!!storeId && storeQuery.isLoading);
|
|
167
|
+
const hasAny = isLoading || options.length > 0;
|
|
168
|
+
return (_jsxs(Stack, { gap: "sm", children: [_jsxs(Div, { children: [_jsx(Heading, { level: 3, className: "mb-1", color: "muted", size: "sm", weight: "semibold", children: "Feature badges" }), _jsxs(Text, { size: "xs", color: "muted", children: ["Selected: ", value.length, " of ", MAX_FEATURES_PER_PRODUCT, ". Badges appear on product cards and detail pages."] })] }), overLimit && (_jsxs(Div, { className: OVER_LIMIT_BANNER_CLASS, children: ["Too many features selected \u2014 remove", " ", value.length - MAX_FEATURES_PER_PRODUCT, " before saving."] })), hasAny ? (_jsx(PaginatedSelect, { multiple: true, value: value, onChange: handleChange, options: options, disabled: disabled, ariaLabel: "Feature badges", placeholder: atLimit
|
|
169
|
+
? `All ${MAX_FEATURES_PER_PRODUCT} badge slots used`
|
|
170
|
+
: "Search feature badges…", searchPlaceholder: "Search badges by name or description\u2026", noResultsText: "No matching feature badge.", ...createProps })) : (_jsx(Div, { className: EMPTY_STATE_CLASS, children: "No applicable feature badges available." }))] }));
|
|
72
171
|
}
|
|
@@ -6,6 +6,7 @@ import { useToast } from "../../../ui/index.js";
|
|
|
6
6
|
import { useOptionalSession } from "../../../react/contexts/SessionContext.js";
|
|
7
7
|
import { isAdminUser, isTesterUser } from "../../auth/role-predicates.js";
|
|
8
8
|
import { PRODUCT_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
9
|
+
import { LISTING_PARAM_NAMES } from "../../../utils/listing-params.js";
|
|
9
10
|
export function useProducts(params = {}, opts) {
|
|
10
11
|
const { showToast } = useToast();
|
|
11
12
|
const sp = new URLSearchParams();
|
|
@@ -36,8 +37,24 @@ export function useProducts(params = {}, opts) {
|
|
|
36
37
|
sp.set("listingType", params.listingType);
|
|
37
38
|
if (params.storeId)
|
|
38
39
|
sp.set("storeId", params.storeId);
|
|
40
|
+
/*
|
|
41
|
+
* 🛑 THE SHARED CONSTANT, not a hand-written string. This read
|
|
42
|
+
* `sp.set("sorts", …)` while `parsePublicProductParams` read
|
|
43
|
+
* `TABLE_KEYS.SORT` (= "sort") — two spellings of one concept, neither
|
|
44
|
+
* derived from the other, so they could not be kept in step by anything.
|
|
45
|
+
* Every client-side fetch therefore arrived with a sort the server could not
|
|
46
|
+
* see and fell back to `-createdAt`, while the dropdown displayed the sort
|
|
47
|
+
* the user had picked. Measured: `?sorts=auctionEndDate` returned byte-identical
|
|
48
|
+
* row order to sending no sort at all.
|
|
49
|
+
*
|
|
50
|
+
* "We have shared constants and it still broke" is exactly this: the
|
|
51
|
+
* constants existed, and this call site did not use them. The server now
|
|
52
|
+
* also accepts all three spellings (`s` / `sorts` / `sort`) the way every
|
|
53
|
+
* sibling listing route already did, so the two halves are belt-and-braces —
|
|
54
|
+
* but the literal is what made the gap possible and it is gone.
|
|
55
|
+
*/
|
|
39
56
|
if (params.sort)
|
|
40
|
-
sp.set(
|
|
57
|
+
sp.set(LISTING_PARAM_NAMES.sorts.long, params.sort);
|
|
41
58
|
if (params.page)
|
|
42
59
|
sp.set("page", String(params.page));
|
|
43
60
|
if (params.perPage)
|
|
@@ -20,6 +20,7 @@ const CLS_ICON_PURPLE_BARE = "flex h-10 w-10 flex-shrink-0 items-center justify-
|
|
|
20
20
|
import Link from "next/link.js";
|
|
21
21
|
import { Button, Div, Grid, Heading, IconButton, RichText, Row, Section, Span, StarRating, Stack, Text } from "../../../ui/index.js";
|
|
22
22
|
import { MediaImage } from "../../media/MediaImage.js";
|
|
23
|
+
import { MediaVideo } from "../../media/MediaVideo.js";
|
|
23
24
|
import { maskName } from "../../../security/index.js";
|
|
24
25
|
import { getDefaultLocale } from "../../../core/baseline-resolver.js";
|
|
25
26
|
import { normalizeRichTextHtml } from "../../../utils/string.formatter.js";
|
|
@@ -101,7 +102,7 @@ export function ReviewDetailShell({ review, storeHref }) {
|
|
|
101
102
|
? String(ROUTES.PUBLIC.PROFILE(reviewerProfileId))
|
|
102
103
|
: null;
|
|
103
104
|
const currentImage = lightboxIdx !== null ? images[lightboxIdx] : null;
|
|
104
|
-
return (_jsxs(_Fragment, { children: [_jsx(Div, { surface: "default", className: "border-b border-neutral-200 pt-[2.5rem]", padding: "b-xl", children: _jsxs(Div, { className: "mx-auto max-w-3xl", padding: "x-md", children: [_jsxs(Row, { gap: "sm", className: "mb-4", children: [_jsx(StarRating, { value: review.rating, size: "lg", readOnly: true }), _jsxs(Span, { color: "inverse", weight: "bold", className: "text-[var(--appkit-color-text)] dark:", size: "2xl", children: [review.rating, ".0"] }), review.verified && (_jsx(Span, { layout: "inline-flex", gap: "xs", color: "success", surface: "success-surface", size: "xs", weight: "semibold", rounded: "full", padding: "pill-md", children: "\u2713 Verified Purchase" })), review.featured && (_jsx(Span, { size: "xs", weight: "semibold", className: CLS_RATING_PILL, children: "\u2605 Featured" }))] }), review.title && (_jsx(Heading, { color: "inverse", level: 1, className: "text-[var(--appkit-color-text)] dark: mb-4 leading-snug", size: "2xl", weight: "bold", children: review.title })), _jsxs(Row, { gap: "sm", children: [review.userAvatar ? (_jsx(Div, { className: "relative h-11 w-11 flex-shrink-0 ring-2 ring-white ring-[var(--appkit-color-border-subtle)]", rounded: "full", overflow: "hidden", children: _jsx(MediaImage, { src: review.userAvatar, alt: displayName, size: "avatar" }) })) : (_jsx(Row, { textWeight: "bold", textSize: "base", centered: true, className: "h-11 w-11 flex-shrink-0 bg-primary/10 text-primary ring-2 ring-white ring-[var(--appkit-color-border-subtle)]", rounded: "full", children: initials })), _jsxs(Div, { className: "min-w-0", children: [reviewerHref ? (_jsx(Link, { href: reviewerHref, className: "text-[length:var(--appkit-text-sm)] font-semibold text-[var(--appkit-color-text)] dark:text-white hover:text-primary transition-colors", children: displayName })) : (_jsx(Span, { color: "inverse", size: "sm", weight: "semibold", className: "text-[var(--appkit-color-text)] dark:", children: review.isAnonymous ? "Anonymous" : displayName })), date && (_jsx(Text, { size: "xs", color: "muted", className: "mt-0.5", children: date }))] })] })] }) }), _jsxs(Stack, { gap: "xl", className: "mx-auto max-w-3xl", paddingY: "y-xl", paddingX: "x-md", children: [review.comment && (_jsx(Section, { children: _jsx(RichText, { html: normalizeRichTextHtml(review.comment), proseClass: "prose prose-neutral dark:prose-invert max-w-none prose-p:leading-relaxed prose-headings:font-semibold prose-img:rounded-lg prose-a:text-primary", className: "text-[var(--appkit-color-text-muted)]" }) })), images.length > 0 && (_jsxs(Section, { children: [_jsxs(Heading, { level: 2, className: "tracking-wide mb-3", color: "faint", size: "sm", weight: "semibold", transform: "uppercase", children: ["Photos (", images.length, ")"] }), _jsx(Grid, { gap: "xs", className: "grid-cols-3 sm:grid-cols-4", children: images.map((img, i) => (_jsxs(Button, { variant: "ghost", type: "button", onClick: () => setLightboxIdx(i), "aria-label": `View photo ${i + 1}`, rounded: "xl", paddingX: "none", paddingY: "none", className: "group relative aspect-square overflow-hidden border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-elevated)] focus-visible:ring-2 focus-visible:ring-primary", children: [_jsx(MediaImage, { src: img, alt: `Review image ${i + 1}`, size: "card", className: "transition-transform duration-300 group-hover:scale-105" }), _jsx(Row, { centered: true, className: "absolute inset-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity bg-[rgba(0,0,0,0.3)]", children: _jsx(Span, { color: "inverse", size: "xl", children: "\uD83D\uDD0D" }) })] }, i))) })] })), review.video && (_jsxs(Section, { children: [_jsx(Heading, { level: 2, className: "tracking-wide mb-3", color: "faint", size: "sm", weight: "semibold", transform: "uppercase", children: "Video" }), _jsx(Div, { className: `${__O.hidden} border border-[var(--appkit-color-border)] bg-[rgb(0,0,0)] aspect-video`, rounded: "xl", children: _jsx(
|
|
105
|
+
return (_jsxs(_Fragment, { children: [_jsx(Div, { surface: "default", className: "border-b border-neutral-200 pt-[2.5rem]", padding: "b-xl", children: _jsxs(Div, { className: "mx-auto max-w-3xl", padding: "x-md", children: [_jsxs(Row, { gap: "sm", className: "mb-4", children: [_jsx(StarRating, { value: review.rating, size: "lg", readOnly: true }), _jsxs(Span, { color: "inverse", weight: "bold", className: "text-[var(--appkit-color-text)] dark:", size: "2xl", children: [review.rating, ".0"] }), review.verified && (_jsx(Span, { layout: "inline-flex", gap: "xs", color: "success", surface: "success-surface", size: "xs", weight: "semibold", rounded: "full", padding: "pill-md", children: "\u2713 Verified Purchase" })), review.featured && (_jsx(Span, { size: "xs", weight: "semibold", className: CLS_RATING_PILL, children: "\u2605 Featured" }))] }), review.title && (_jsx(Heading, { color: "inverse", level: 1, className: "text-[var(--appkit-color-text)] dark: mb-4 leading-snug", size: "2xl", weight: "bold", children: review.title })), _jsxs(Row, { gap: "sm", children: [review.userAvatar ? (_jsx(Div, { className: "relative h-11 w-11 flex-shrink-0 ring-2 ring-white ring-[var(--appkit-color-border-subtle)]", rounded: "full", overflow: "hidden", children: _jsx(MediaImage, { src: review.userAvatar, alt: displayName, size: "avatar" }) })) : (_jsx(Row, { textWeight: "bold", textSize: "base", centered: true, className: "h-11 w-11 flex-shrink-0 bg-primary/10 text-primary ring-2 ring-white ring-[var(--appkit-color-border-subtle)]", rounded: "full", children: initials })), _jsxs(Div, { className: "min-w-0", children: [reviewerHref ? (_jsx(Link, { href: reviewerHref, className: "text-[length:var(--appkit-text-sm)] font-semibold text-[var(--appkit-color-text)] dark:text-white hover:text-primary transition-colors", children: displayName })) : (_jsx(Span, { color: "inverse", size: "sm", weight: "semibold", className: "text-[var(--appkit-color-text)] dark:", children: review.isAnonymous ? "Anonymous" : displayName })), date && (_jsx(Text, { size: "xs", color: "muted", className: "mt-0.5", children: date }))] })] })] }) }), _jsxs(Stack, { gap: "xl", className: "mx-auto max-w-3xl", paddingY: "y-xl", paddingX: "x-md", children: [review.comment && (_jsx(Section, { children: _jsx(RichText, { html: normalizeRichTextHtml(review.comment), proseClass: "prose prose-neutral dark:prose-invert max-w-none prose-p:leading-relaxed prose-headings:font-semibold prose-img:rounded-lg prose-a:text-primary", className: "text-[var(--appkit-color-text-muted)]" }) })), images.length > 0 && (_jsxs(Section, { children: [_jsxs(Heading, { level: 2, className: "tracking-wide mb-3", color: "faint", size: "sm", weight: "semibold", transform: "uppercase", children: ["Photos (", images.length, ")"] }), _jsx(Grid, { gap: "xs", className: "grid-cols-3 sm:grid-cols-4", children: images.map((img, i) => (_jsxs(Button, { variant: "ghost", type: "button", onClick: () => setLightboxIdx(i), "aria-label": `View photo ${i + 1}`, rounded: "xl", paddingX: "none", paddingY: "none", className: "group relative aspect-square overflow-hidden border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-elevated)] focus-visible:ring-2 focus-visible:ring-primary", children: [_jsx(MediaImage, { src: img, alt: `Review image ${i + 1}`, size: "card", className: "transition-transform duration-300 group-hover:scale-105" }), _jsx(Row, { centered: true, className: "absolute inset-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity bg-[rgba(0,0,0,0.3)]", children: _jsx(Span, { color: "inverse", size: "xl", children: "\uD83D\uDD0D" }) })] }, i))) })] })), review.video && (_jsxs(Section, { children: [_jsx(Heading, { level: 2, className: "tracking-wide mb-3", color: "faint", size: "sm", weight: "semibold", transform: "uppercase", children: "Video" }), _jsx(Div, { className: `${__O.hidden} border border-[var(--appkit-color-border)] bg-[rgb(0,0,0)] aspect-video relative`, rounded: "xl", children: _jsx(MediaVideo, { src: review.video.url, thumbnailUrl: review.video.thumbnailUrl, alt: "Review video", objectFit: "contain" }) })] })), _jsxs(Section, { className: "flex items-center gap-[1rem] border-t border-neutral-100", padding: "y-md", children: [_jsx(Div, { textSize: "sm", className: "text-[var(--appkit-color-text-muted)]", children: helpfulCount > 0 && (_jsxs(Span, { children: [_jsx(Span, { color: "inverse", weight: "bold", className: "text-[var(--appkit-color-text)] dark:", children: helpfulCount }), " ", helpfulCount === 1 ? "person" : "people", " found this helpful"] })) }), _jsxs(Button, { variant: "outline", type: "button", onClick: handleVote, disabled: voted || voting, gap: "sm", rounded: "lg", paddingX: "md", paddingY: "sm", textSize: "sm", weight: "medium", className: `ml-auto transition-colors ${voted
|
|
105
106
|
? CLS_HELPFUL_ACTIVE
|
|
106
107
|
: "border-[var(--appkit-color-border)] text-[var(--appkit-color-text-muted)] hover:border-primary hover:text-primary dark:hover:border-primary dark:hover:text-primary disabled:opacity-50"}`, children: [_jsx(Span, { "aria-hidden": "true", children: voted ? "✓" : "👍" }), voted ? "Marked helpful" : voting ? "Saving…" : "Helpful?"] })] }), _jsxs(Section, { className: "grid gap-[0.75rem] sm:grid-cols-3", children: [productHref && (_jsxs(Link, { href: productHref, className: CLS_RELATED_LINK, children: [_jsx(Span, { className: CLS_ICON_ORANGE, children: "\uD83D\uDCE6" }), _jsxs(Div, { className: "min-w-0", children: [_jsx(Text, { className: CLS_RELATED_LABEL, children: "Product" }), _jsx(Text, { className: CLS_RELATED_TITLE, children: review.productTitle ?? "View Product" })] })] })), sellerHref && (_jsxs(Link, { href: sellerHref, className: CLS_RELATED_LINK, children: [_jsx(Span, { className: CLS_ICON_BLUE, children: "\uD83C\uDFEA" }), _jsxs(Div, { className: "min-w-0", children: [_jsx(Text, { className: CLS_RELATED_LABEL, children: "Seller" }), _jsx(Text, { className: CLS_RELATED_TITLE, children: "View Seller" })] })] })), reviewerHref ? (_jsxs(Link, { href: reviewerHref, className: CLS_RELATED_LINK, children: [_jsx(Span, { className: CLS_ICON_PURPLE, children: "\uD83D\uDC64" }), _jsxs(Div, { className: "min-w-0", children: [_jsx(Text, { className: CLS_RELATED_LABEL, children: "Reviewer" }), _jsx(Text, { className: CLS_RELATED_TITLE, children: displayName })] })] })) : (_jsxs(Row, { surface: "default", gap: "sm", className: `border border-neutral-200 dark:border-[var(--appkit-color-border)] ${__P.p4}`, rounded: "xl", children: [_jsx(Span, { size: "xl", className: CLS_ICON_PURPLE_BARE, children: "\uD83D\uDC64" }), _jsxs(Div, { className: "min-w-0", children: [_jsx(Span, { size: "xs", className: "block mb-0.5", color: "faint", children: "Reviewer" }), _jsx(Span, { color: "inverse", size: "sm", weight: "medium", className: "block text-[var(--appkit-color-text)] dark: truncate", children: "Anonymous" })] })] }))] })] }), lightboxIdx !== null && currentImage && (_jsxs(Row, { centered: true, className: "fixed inset-0 z-50 bg-[rgba(0,0,0,0.95)]", onClick: closeLightbox, role: "dialog", "aria-modal": "true", "aria-label": "Image lightbox", children: [_jsx(IconButton, { type: "button", onClick: closeLightbox, "aria-label": "Close lightbox", variant: "scrim", rounded: "full", className: "absolute top-4 right-4 z-10 h-10 w-10", children: _jsx(Span, { size: "xl", children: "\u00D7" }) }), _jsxs(Div, { textSize: "sm", className: "absolute top-4 left-1/2 -translate-x-1/2 text-white/70", children: [lightboxIdx + 1, " / ", images.length] }), images.length > 1 && (_jsx(IconButton, { type: "button", onClick: (e) => { e.stopPropagation(); prevImage(); }, "aria-label": "Previous image", variant: "scrim", rounded: "full", className: "absolute left-4 top-1/2 -translate-y-1/2 z-10 h-12 w-12", children: _jsx(Span, { size: "2xl", children: "\u2039" }) })), _jsx(Row, { centered: true, className: "relative h-[85vh] w-[85vw]", onClick: (e) => e.stopPropagation(), children: _jsx(MediaImage, { src: currentImage, alt: `Review photo ${lightboxIdx + 1}`, size: "hero", objectFit: "contain", rounded: "lg", shadow: "2xl" }) }), images.length > 1 && (_jsx(IconButton, { type: "button", onClick: (e) => { e.stopPropagation(); nextImage(); }, "aria-label": "Next image", variant: "scrim", rounded: "full", className: "absolute right-4 top-1/2 -translate-y-1/2 z-10 h-12 w-12", children: _jsx(Span, { size: "2xl", children: "\u203A" }) })), images.length > 1 && (_jsx(Row, { justify: "center", gap: "xs", className: "absolute bottom-4 left-0 right-0", padding: "x-md", children: images.map((img, i) => (_jsx(Button, { variant: "ghost", type: "button", onClick: (e) => { e.stopPropagation(); setLightboxIdx(i); }, "aria-label": `Go to image ${i + 1}`, rounded: "md", paddingX: "none", paddingY: "none", className: `h-12 w-12 flex-shrink-0 overflow-hidden border-2 transition-all ${i === lightboxIdx
|
|
107
108
|
? "border-white scale-110"
|
|
@@ -14,7 +14,7 @@ const CLS_BAR = "h-full rounded-full bg-warning-surface transition-all duration-
|
|
|
14
14
|
*/
|
|
15
15
|
export function ReviewSummary({ averageRating, totalReviews, distribution, className = "", }) {
|
|
16
16
|
const stars = [5, 4, 3, 2, 1];
|
|
17
|
-
return (_jsxs(Stack, { align: "center", className: `sm:[flex-direction:row] sm: sm:gap-[2rem] ${className}`, gap: "md", children: [_jsxs(Stack, { className: "sm:items-
|
|
17
|
+
return (_jsxs(Stack, { align: "center", className: `sm:[flex-direction:row] sm: sm:gap-[2rem] ${className}`, gap: "md", children: [_jsxs(Stack, { className: /* audit-variant-ok: RESPONSIVE alignment override; `align` is base-breakpoint only */ "sm:items-start", align: "center", gap: "xs", children: [_jsx(Span, { color: "inverse", weight: "bold", className: "leading-none text-[var(--appkit-color-text)] dark:", size: "5xl", children: averageRating.toFixed(1) }), _jsx(StarRating, { value: averageRating, size: "md", readOnly: true }), _jsxs(Text, { size: "sm", className: "text-[var(--appkit-color-text-muted)]", children: [totalReviews.toLocaleString(), " review", totalReviews !== 1 ? "s" : ""] })] }), _jsx(Stack, { className: "flex-1", gap: "sm", children: stars.map((star) => {
|
|
18
18
|
const count = distribution[star] ?? 0;
|
|
19
19
|
const pct = totalReviews > 0 ? Math.round((count / totalReviews) * 100) : 0;
|
|
20
20
|
return (_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Span, { size: "xs", weight: "medium", className: "w-4 text-[var(--appkit-color-text-muted)] tabular-nums", align: "end", children: star }), _jsx(Span, { size: "xs", className: CLS_STAR, children: "\u2605" }), _jsx(Div, { className: "flex-1 h-2 bg-[var(--appkit-color-surface)] overflow-hidden", rounded: "full", role: "progressbar", "aria-valuenow": pct, "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": `${star} star: ${pct}%`, children: _jsx(ProgressBarFill, { pct: pct, className: CLS_BAR }) }), _jsxs(Span, { size: "xs", className: "w-8 tabular-nums", color: "faint", align: "end", children: [pct, "%"] })] }, star));
|
|
@@ -67,7 +67,7 @@ export const happyPathPages = [
|
|
|
67
67
|
"Wait for the grid to render.",
|
|
68
68
|
"Read every category name shown at the top level.",
|
|
69
69
|
],
|
|
70
|
-
expectedBehaviour: "The index lists the tier-0 roots of the category forest
|
|
70
|
+
expectedBehaviour: "The index lists the tier-0 roots of the category forest — SIX of them: Spinning Tops, Trading Cards, Collectible Figures, Hot Wheels, Model Kits and Living Collectibles. It lists ONLY roots: the forest is 1,185 rows and the index used to render all of them flat.",
|
|
71
71
|
expectedUiState: "Both 'Spinning Tops' and 'Living Collectibles' are visible as cards or links. The page is not empty and does not render only leaf categories.",
|
|
72
72
|
expectedData: { rootCategoriesVisible: 2 },
|
|
73
73
|
endResult: "Nothing is written; reloading shows the same roots.",
|
|
@@ -51,7 +51,7 @@ export const authored = {
|
|
|
51
51
|
startPage: "/admin/categories",
|
|
52
52
|
steps: [
|
|
53
53
|
"Sign in as admin@letitrip.in / TempPass123!.",
|
|
54
|
-
"Open /admin/categories and read the tree, noting the
|
|
54
|
+
"Open /admin/categories and read the tree, noting the six roots and how depth is shown.",
|
|
55
55
|
"Create a child category named 'QA Category admin-crud' under category-beyblade-burst.",
|
|
56
56
|
"Save, RELOAD, and read its parent, its tier and its position in the tree.",
|
|
57
57
|
"Open /categories in a private window and find it beneath its parent.",
|