@mohasinac/appkit 4.52.2 → 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/features/layout/AppLayoutShell.js +8 -1
- package/dist/features/layout/BackToTop.js +2 -2
- package/dist/features/layout/BottomActions.js +19 -4
- package/dist/features/layout/BottomChrome.d.ts +0 -29
- package/dist/features/layout/BottomChrome.js +72 -7
- package/dist/features/products/components/ProductFeaturesSelector.d.ts +38 -1
- package/dist/features/products/components/ProductFeaturesSelector.js +133 -34
- 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/index.js +4 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +80 -0
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/RichTextEditor.js +1 -1
- package/package.json +1 -1
package/dist/.build-stamp.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"stampVersion": 1,
|
|
3
|
-
"version": "4.52.
|
|
4
|
-
"head": "
|
|
3
|
+
"version": "4.52.3",
|
|
4
|
+
"head": "22b37a38962d4c3dd484539154eae60f5c523161",
|
|
5
5
|
"branch": "main",
|
|
6
|
-
"dirty":
|
|
7
|
-
"builtAt": "2026-10-
|
|
6
|
+
"dirty": true,
|
|
7
|
+
"builtAt": "2026-10-15T01:16:10.361Z"
|
|
8
8
|
}
|
|
@@ -200,5 +200,12 @@ export function AppLayoutShell({ children, navItems, sidebarItems = [], sidebarS
|
|
|
200
200
|
// main-to-footer — it never clears the bottom of the page, which is
|
|
201
201
|
// what the fixed nav + tier actually overlap. That reservation now
|
|
202
202
|
// lives on the footer's own padding-bottom.
|
|
203
|
-
|
|
203
|
+
//
|
|
204
|
+
// The TOP tier is the mirror image and does belong here: at `lg` the
|
|
205
|
+
// chrome tier hangs under the header as a fixed bar, so without this
|
|
206
|
+
// it would cover the first rows of the page. `--top-chrome-height`
|
|
207
|
+
// is 0 below `lg` and 0 whenever no bar is published, so this
|
|
208
|
+
// reserves exactly nothing until there is something to reserve —
|
|
209
|
+
// which is why it can be unconditional rather than route-gated.
|
|
210
|
+
className: "w-full flex-1 flex flex-col pt-[var(--top-chrome-height,0px)] transition-[padding-top] duration-300 ease-out", children: _jsx(Div, { padding: "y-lg", className: `flex-1 ${contentClassName ?? "mx-auto w-full max-w-screen-xl px-[var(--appkit-space-5)] md:px-[var(--appkit-space-6)] lg:px-[var(--appkit-space-8)]"}`, children: children }) })] }), _jsx(BackToTop, {}), _jsx(FooterLayout, { ...footer }), _jsx(BottomChrome, { children: _jsx(BottomActions, {}) }), showBottomNav && (_jsx(BottomNavbar, { user: user, userId: userId, homeHref: homeHref, shopHref: shopHref, cartHref: cartHref, wishlistHref: wishlistHref, profileHref: profileHref, loginHref: loginHref, onSearchToggle: () => setSearchOpen((prev) => !prev), navItems: navItems, onMoreToggle: handleTogglePublicSidebar })), _jsx(UnsavedChangesModal, {})] }) }));
|
|
204
211
|
}
|
|
@@ -4,7 +4,7 @@ import Link from "next/link.js";
|
|
|
4
4
|
import { usePathname } from "next/navigation.js";
|
|
5
5
|
import { useEffect, useState } from "react";
|
|
6
6
|
import { ChevronUp, X } from "lucide-react";
|
|
7
|
-
import {
|
|
7
|
+
import { Div, IconButton } from "../../ui/index.js";
|
|
8
8
|
import { useHandMode } from "../../_internal/client/hand-mode/index.js";
|
|
9
9
|
/**
|
|
10
10
|
* BackToTop — floating scroll-to-top button.
|
|
@@ -49,7 +49,7 @@ export function BackToTop({ threshold = 400, ariaLabel = "Back to top", classNam
|
|
|
49
49
|
const handleDismiss = () => {
|
|
50
50
|
setDismissed(true);
|
|
51
51
|
};
|
|
52
|
-
return (_jsxs(Div, { layout: "flex", align: "center", gap: "1", className: `fixed bottom-[calc(var(--keyboard-inset-height,0px)+var(--bottom-nav-height,4rem)+var(--bottom-chrome-height,0px)+var(--appkit-space-4))] transition-[bottom] duration-300 ease-out ${hand === "left" ? "left-4" : "right-4"} z-[var(--appkit-z-back-to-top)] ${className}`, children: [_jsx(
|
|
52
|
+
return (_jsxs(Div, { layout: "flex", align: "center", gap: "1", className: `fixed bottom-[calc(var(--keyboard-inset-height,0px)+var(--bottom-nav-height,4rem)+var(--bottom-chrome-height,0px)+var(--appkit-space-4))] transition-[bottom] duration-300 ease-out ${hand === "left" ? "left-4" : "right-4"} z-[var(--appkit-z-back-to-top)] ${className}`, children: [_jsx(IconButton, { type: "button", variant: "outline", size: "touch", rounded: "full", "aria-label": ariaLabel, onClick: () => window.scrollTo({ top: 0, behavior: "smooth" }), icon: _jsx(ChevronUp, { "aria-hidden": "true" }) }), _jsx(IconButton, { type: "button", variant: "outline", size: "sm", rounded: "full", "aria-label": "Hide back-to-top button", onClick: handleDismiss, icon: _jsx(X, { "aria-hidden": "true" }) })] }));
|
|
53
53
|
}
|
|
54
54
|
/** SkipToMain — visually hidden link that becomes visible on focus for keyboard users. */
|
|
55
55
|
export function SkipToMain({ mainId = "main-content" }) {
|
|
@@ -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,7 +282,13 @@ 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)
|
|
@@ -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
|
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WHY: Authored six-part procedures for the design-ux/action-bar-placement page.
|
|
3
|
+
* WHAT: 6 case(s), keyed by full checklist id.
|
|
4
|
+
*
|
|
5
|
+
* @tag domain:tester
|
|
6
|
+
* @tag layer:seed
|
|
7
|
+
* @tag pattern:none
|
|
8
|
+
* @tag access:server-only
|
|
9
|
+
* @tag consumers:seed-data/authored/index.ts
|
|
10
|
+
* @tag sideEffects:none
|
|
11
|
+
*/
|
|
12
|
+
export const authored = {
|
|
13
|
+
"checklist-design-ux-action-bar-placement-back-to-top-dismiss-visible": {
|
|
14
|
+
roles: ["guest"],
|
|
15
|
+
startPage: "/products",
|
|
16
|
+
steps: [
|
|
17
|
+
"Open /products and scroll down more than about 400 pixels.",
|
|
18
|
+
"Look at the floating controls in the bottom corner of the window.",
|
|
19
|
+
"Click the smaller of the two controls.",
|
|
20
|
+
"Navigate to /auctions and scroll past 400 pixels again.",
|
|
21
|
+
],
|
|
22
|
+
expectedBehaviour: "The dismiss control is an icon button sized as a true square, so its glyph has room to render. Dismissal is in-memory and resets on route change, so it never hides the control for the rest of the session.",
|
|
23
|
+
expectedUiState: "TWO round controls are visible: a larger one showing an upward chevron, and a smaller one showing a clearly visible ✕. The prior symptom was the smaller control rendering as an EMPTY outlined pill — present and clickable, with no ✕ inside it, because its content box computed to zero width. After clicking the ✕, both controls disappear.",
|
|
24
|
+
endResult: "On /auctions both controls are back after scrolling. Nothing is persisted — a reload also brings them back.",
|
|
25
|
+
},
|
|
26
|
+
"checklist-design-ux-action-bar-placement-action-bar-top-on-desktop": {
|
|
27
|
+
roles: ["guest"],
|
|
28
|
+
startPage: "/products",
|
|
29
|
+
steps: [
|
|
30
|
+
"Maximise the browser window so it is wider than about 1024 pixels.",
|
|
31
|
+
"Open any product detail page from /products.",
|
|
32
|
+
"Look immediately below the site header.",
|
|
33
|
+
"Scroll the page down and watch the bar.",
|
|
34
|
+
],
|
|
35
|
+
expectedBehaviour: "At `lg` and up the chrome tier is pinned under the header rather than to the bottom edge, and its shadow is cast downward onto the page.",
|
|
36
|
+
expectedUiState: "The full-width action bar sits directly beneath the header with no gap, and stays there while the page scrolls. Its drop shadow falls DOWNWARD. It must NOT be at the bottom of the window — that was the prior behaviour at every width.",
|
|
37
|
+
endResult: "Nothing persists; this is layout only. The bar stays under the header for the whole visit at this width.",
|
|
38
|
+
},
|
|
39
|
+
"checklist-design-ux-action-bar-placement-action-bar-bottom-on-mobile": {
|
|
40
|
+
roles: ["guest"],
|
|
41
|
+
startPage: "/products",
|
|
42
|
+
steps: [
|
|
43
|
+
"On the same product detail page, narrow the browser window to phone width (under about 1024 pixels), or switch to device emulation.",
|
|
44
|
+
"Look at the bottom of the window.",
|
|
45
|
+
"Slowly widen the window back past the 1024-pixel line, then narrow it again.",
|
|
46
|
+
],
|
|
47
|
+
expectedBehaviour: "One component serves both layouts, switching on a media query, so the bar must move between the two positions rather than being duplicated or dropped.",
|
|
48
|
+
expectedUiState: "At phone width the action bar is at the BOTTOM, stacked immediately above the bottom navigation bar, with its shadow falling UPWARD. While resizing across the breakpoint the bar moves cleanly from one end to the other — it is never visible in BOTH places at once, and never disappears entirely at any width.",
|
|
49
|
+
endResult: "No state is written. The bar's position always matches the current window width, including after a reload at either width.",
|
|
50
|
+
},
|
|
51
|
+
"checklist-design-ux-action-bar-placement-action-bar-no-content-overlap": {
|
|
52
|
+
roles: ["guest"],
|
|
53
|
+
startPage: "/products",
|
|
54
|
+
steps: [
|
|
55
|
+
"At desktop width, open a product detail page that shows an action bar.",
|
|
56
|
+
"Read the very first line of page content under the header.",
|
|
57
|
+
"Now open a page with no action bar at all, such as /about.",
|
|
58
|
+
"Look at the space immediately under the header.",
|
|
59
|
+
],
|
|
60
|
+
expectedBehaviour: "The bar is fixed, so it is out of flow and would cover content unless space is reserved. `<Main>` reserves exactly the published tier height, which is zero when no bar exists — so the reservation appears and disappears with the bar rather than being a constant allowance.",
|
|
61
|
+
expectedUiState: "With a bar present, the first heading and first row of content are fully visible and not clipped behind it. On /about, with no bar, there is NO empty band under the header — the content starts where it always did.",
|
|
62
|
+
endResult: "Nothing persists. Navigating between a page with a bar and one without must not leave a residual gap on the page that has none.",
|
|
63
|
+
},
|
|
64
|
+
"checklist-design-ux-action-bar-placement-action-bar-panels-open-into-screen": {
|
|
65
|
+
roles: ["admin"],
|
|
66
|
+
startPage: "/admin/products",
|
|
67
|
+
steps: [
|
|
68
|
+
"Sign in as admin@letitrip.in / TempPass123! and open /admin/products at desktop width.",
|
|
69
|
+
"Tick the checkboxes on two rows to enter bulk mode.",
|
|
70
|
+
"Open the bulk action-type picker on the bar.",
|
|
71
|
+
"Narrow the window to phone width and open the same picker again.",
|
|
72
|
+
],
|
|
73
|
+
expectedBehaviour: "The panel is positioned against the bar, so its open direction must follow whichever screen edge the bar is attached to. Opening away from the screen would put the panel partly or wholly out of view.",
|
|
74
|
+
expectedUiState: "At desktop width the picker expands DOWNWARD over the page content, below the bar. At phone width it expands UPWARD from the bar. In both cases the whole panel is on screen and scrollable, and no part of it is cut off by the window edge.",
|
|
75
|
+
endResult: "Closing the picker leaves the bulk selection intact. Clearing the selection removes the bar and the panel together.",
|
|
76
|
+
},
|
|
77
|
+
"checklist-design-ux-action-bar-placement-back-to-top-clears-the-bar": {
|
|
78
|
+
roles: ["guest"],
|
|
79
|
+
startPage: "/products",
|
|
80
|
+
steps: [
|
|
81
|
+
"At phone width, open a product detail page with an action bar and scroll past 400 pixels.",
|
|
82
|
+
"Look at the vertical gap between the back-to-top control and the action bar.",
|
|
83
|
+
"Slowly widen the window past the desktop breakpoint, watching the control.",
|
|
84
|
+
],
|
|
85
|
+
expectedBehaviour: "The back-to-top offset is expressed against the measured tier rather than against any individual bar, so it tracks the bar's height automatically — and when the tier moves to the top on desktop, the bottom measurement collapses to zero and the control settles lower.",
|
|
86
|
+
expectedUiState: "At phone width the control floats clear ABOVE the action bar, which itself sits above the bottom navigation — none of the three overlap. As the window widens past the breakpoint the control moves DOWN into the space the bar vacated. At no point during the resize do the control and any bar overlap.",
|
|
87
|
+
endResult: "Position is derived from layout alone and nothing is stored; reloading at either width reproduces the same spacing.",
|
|
88
|
+
},
|
|
89
|
+
};
|