@mohasinac/appkit 4.46.0 β†’ 4.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.46.0",
4
- "head": "34ce98d7f80659ca1052873c7444d69dde36c07b",
3
+ "version": "4.47.0",
4
+ "head": "f72c5f183b70c66b626c7809ffb1a4b0112e7cb1",
5
5
  "branch": "main",
6
6
  "dirty": true,
7
- "builtAt": "2026-10-13T00:19:07.494Z"
7
+ "builtAt": "2026-10-13T05:22:53.600Z"
8
8
  }
@@ -28,6 +28,29 @@ export interface BreadcrumbTrailProps {
28
28
  */
29
29
  showCurrent?: boolean;
30
30
  className?: string;
31
+ /**
32
+ * I5b β€” collapse the middle of a long trail behind a dropdown.
33
+ *
34
+ * πŸ›‘ Needed independently of multi-assignment: a tier-4 part leaf is SEVEN
35
+ * crumbs (`All Categories > Spinning Tops > Beyblade Metal > Metal Parts >
36
+ * Performance Tips > Rubber Flat`) and 390px cannot hold it. D4b measured
37
+ * that depth and never addressed how it wraps.
38
+ *
39
+ * Defaults ON, because the only trails long enough to collapse are the ones
40
+ * that already overflow.
41
+ */
42
+ collapse?: boolean;
43
+ /**
44
+ * I5b β€” the OTHER categories this listing is filed under, as one extra crumb.
45
+ *
46
+ * Pass `categoryIds`/`categoryIdNames` straight through from the product;
47
+ * `alternateAssignments` decides inline-vs-dropdown and returns nothing at
48
+ * all for a single assignment, which is the overwhelming majority.
49
+ */
50
+ alternates?: {
51
+ ids?: readonly string[] | null;
52
+ names?: readonly string[] | null;
53
+ };
31
54
  }
32
55
  /**
33
56
  * BreadcrumbTrail β€” a category-aware breadcrumb component.
@@ -45,4 +68,4 @@ export interface BreadcrumbTrailProps {
45
68
  * />
46
69
  * ```
47
70
  */
48
- export declare function BreadcrumbTrail({ category, rootLabel, rootHref, basePath, showCurrent, className, }: BreadcrumbTrailProps): React.JSX.Element;
71
+ export declare function BreadcrumbTrail({ category, rootLabel, rootHref, basePath, showCurrent, className, collapse, alternates, }: BreadcrumbTrailProps): React.JSX.Element;
@@ -1,6 +1,10 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React from "react";
2
3
  import { Breadcrumb } from "../../../ui/index.js";
4
+ import { Text } from "../../../ui/components/Typography.js";
5
+ import { TextLink } from "../../../ui/components/TextLink.js";
3
6
  import { ROUTES } from "../../../next/routing/route-map.js";
7
+ import { alternateAssignments, collapseTrail } from "../utils/breadcrumb-chain.js";
4
8
  /**
5
9
  * BreadcrumbTrail β€” a category-aware breadcrumb component.
6
10
  *
@@ -17,8 +21,8 @@ import { ROUTES } from "../../../next/routing/route-map.js";
17
21
  * />
18
22
  * ```
19
23
  */
20
- export function BreadcrumbTrail({ category, rootLabel = "All Categories", rootHref = String(ROUTES.PUBLIC.CATEGORIES), basePath = String(ROUTES.PUBLIC.CATEGORIES), showCurrent = true, className, }) {
21
- const items = [
24
+ export function BreadcrumbTrail({ category, rootLabel = "All Categories", rootHref = String(ROUTES.PUBLIC.CATEGORIES), basePath = String(ROUTES.PUBLIC.CATEGORIES), showCurrent = true, className, collapse = true, alternates, }) {
25
+ const full = [
22
26
  // Root
23
27
  { label: rootLabel, href: rootHref },
24
28
  // Ancestors (sorted by tier ascending so root β†’ leaf order)
@@ -32,5 +36,42 @@ export function BreadcrumbTrail({ category, rootLabel = "All Categories", rootHr
32
36
  // Current category (no href so it renders as the active item)
33
37
  ...(showCurrent ? [{ label: category.name }] : []),
34
38
  ];
35
- return _jsx(Breadcrumb, { items: items, className: className });
39
+ /*
40
+ * I5b β€” the alternates become ONE extra crumb, never a second row. Two rows
41
+ * of crumbs above a title is a pattern no competitor in the crawl uses and it
42
+ * costs vertical space above the fold on a phone.
43
+ */
44
+ const alt = alternateAssignments({
45
+ categoryIds: alternates?.ids ?? null,
46
+ categoryIdNames: alternates?.names ?? null,
47
+ hrefFor: (slug) => `${basePath}/${slug}`,
48
+ });
49
+ /*
50
+ * πŸ›‘ Collapsing is PRESENTATION. The JSON-LD is emitted elsewhere, from the
51
+ * full chain, and must stay that way β€” a crawler never receives a truncated
52
+ * hierarchy because a phone could not show it.
53
+ */
54
+ const split = collapse ? collapseTrail(toCrumbs(full)) : null;
55
+ const items = split && split.collapsed.length > 0
56
+ ? [
57
+ ...split.lead.map(fromCrumb),
58
+ /*
59
+ * The collapsed group as ONE crumb whose label names what it hides.
60
+ * πŸ›‘ Rendered as a real crumb with no href rather than a menu,
61
+ * because `Breadcrumb` is a plain list primitive β€” and the hidden
62
+ * ancestors are still reachable: every one of them is an ancestor of
63
+ * the crumbs either side, so no path is lost, only shortened.
64
+ */
65
+ { label: `… ${split.collapsed.length} more` },
66
+ ...split.tail.map(fromCrumb),
67
+ ]
68
+ : full;
69
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: items, className: className }), alt.mode !== "none" && (_jsxs(Text, { size: "xs", color: "muted", children: ["Also in", " ", alt.items.map((a, i) => (_jsxs(React.Fragment, { children: [i > 0 && " Β· ", _jsx(TextLink, { href: a.url ?? "#", children: a.name })] }, a.url ?? a.name)))] }))] }));
70
+ }
71
+ /** `BreadcrumbItem` ⇄ `BreadcrumbCrumb` β€” two names for one shape. */
72
+ function toCrumbs(items) {
73
+ return items.map((i) => ({ name: i.label, url: i.href }));
74
+ }
75
+ function fromCrumb(c) {
76
+ return c.url ? { label: c.name, href: c.url } : { label: c.name };
36
77
  }
@@ -6,5 +6,21 @@ export interface CategoryInlineSelectProps {
6
6
  disabled?: boolean;
7
7
  /** Show the "+ Create new category" option. Defaults to true β€” sellers can create categories inline. */
8
8
  allowCreate?: boolean;
9
+ /**
10
+ * I2 β€” the ADDITIONAL assignments, beyond `value`.
11
+ *
12
+ * πŸ›‘ Omitting this pair keeps the component exactly single-select, which is
13
+ * what every existing call site gets. Multi-assignment is opt-in per surface
14
+ * rather than switched on globally, because `value`/`onChange` is a `string`
15
+ * contract that five call sites rely on and changing it under them is Root
16
+ * Cause #20 (a public prop change without its consumers in the same commit).
17
+ */
18
+ extraValues?: string[];
19
+ onExtraValuesChange?: (ids: string[]) => void;
20
+ /**
21
+ * How many assignments are allowed in total, `value` included. Defaults to
22
+ * `MAX_CATEGORY_ASSIGNMENTS`, which is what the repository refuses above.
23
+ */
24
+ maxAssignments?: number;
9
25
  }
10
- export declare function CategoryInlineSelect({ value, onChange, placeholder, disabled, allowCreate, }: CategoryInlineSelectProps): React.JSX.Element;
26
+ export declare function CategoryInlineSelect({ value, onChange, placeholder, disabled, allowCreate, extraValues, onExtraValuesChange, maxAssignments, }: CategoryInlineSelectProps): React.JSX.Element;
@@ -1,9 +1,12 @@
1
1
  "use client";
2
- import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { PaginatedSelect } from "../../../ui/components/PaginatedSelect.js";
4
4
  import { CategoryQuickCreateForm } from "../../admin/components/CategoryQuickCreateForm.js";
5
5
  import { apiClient } from "../../../http/index.js";
6
6
  import { ADMIN_ENDPOINTS, CATEGORY_ENDPOINTS } from "../../../constants/api-endpoints.js";
7
+ import { Stack } from "../../../ui/components/Layout.js";
8
+ import { Text } from "../../../ui/components/Typography.js";
9
+ import { MAX_CATEGORY_ASSIGNMENTS } from "../../products/repository/category-assignment.js";
7
10
  // Admin: paginated + server-side search via /api/admin/categories (admin/mod only).
8
11
  // Response via successResponse({ data: items, total }) β†’ apiClient unwraps to { data, total, ... }.
9
12
  async function loadAdminCategoryOptions(query, page) {
@@ -35,9 +38,17 @@ async function loadPublicCategoryOptions(query, _page) {
35
38
  const items = q ? all.filter((c) => c.label.toLowerCase().includes(q)) : all;
36
39
  return { items, hasMore: false, nextPage: 2 };
37
40
  }
38
- export function CategoryInlineSelect({ value, onChange, placeholder = "Search categories…", disabled, allowCreate = true, }) {
41
+ export function CategoryInlineSelect({ value, onChange, placeholder = "Search categories…", disabled, allowCreate = true, extraValues, onExtraValuesChange, maxAssignments = MAX_CATEGORY_ASSIGNMENTS, }) {
39
42
  const loadOptions = allowCreate ? loadAdminCategoryOptions : loadPublicCategoryOptions;
40
- return (_jsx(PaginatedSelect, { value: value || null, onChange: (v) => onChange(v ?? ""), loadOptions: loadOptions, placeholder: placeholder, disabled: disabled, createLabel: "category", drawerTitle: "Create Category", renderCreateForm: allowCreate
43
+ const primary = (_jsx(PaginatedSelect, { value: value || null, onChange: (v) => onChange(v ?? ""), loadOptions: loadOptions, placeholder: placeholder, disabled: disabled, createLabel: "category", drawerTitle: "Create Category", renderCreateForm: allowCreate
41
44
  ? ({ onCreated, onCancel }) => (_jsx(CategoryQuickCreateForm, { onSaved: (id, name) => onCreated({ value: id, label: name }), onCancel: onCancel }))
42
45
  : undefined }));
46
+ // Single-select unless the caller opted in β€” byte-identical to before.
47
+ if (!onExtraValuesChange)
48
+ return primary;
49
+ const extras = extraValues ?? [];
50
+ const remaining = Math.max(0, maxAssignments - 1 - extras.length);
51
+ return (_jsxs(Stack, { gap: "sm", children: [primary, _jsx(PaginatedSelect, { multiple: true, value: extras, onChange: (v) => onExtraValuesChange(Array.isArray(v) ? v : []), loadOptions: loadOptions, placeholder: remaining > 0
52
+ ? `Also list under… (up to ${remaining} more)`
53
+ : "No more categories can be added", disabled: disabled || remaining <= 0 }), _jsx(Text, { size: "xs", color: "muted", children: "Optional. Add a category from a DIFFERENT tree \u2014 a franchise, for example. Two categories from the same tree will be refused, because the broader one is already implied by the narrower." })] }));
43
54
  }
@@ -6,6 +6,16 @@ export interface SellerProductDraft {
6
6
  slug?: string;
7
7
  description?: string;
8
8
  category?: string;
9
+ /**
10
+ * I2 β€” the assignment list, DEFAULT FIRST, with `category` mirroring `[0]`.
11
+ *
12
+ * πŸ›‘ Declared on the TYPE as well as the Zod schema. The schema is
13
+ * `.passthrough()`, so an undeclared key survives at runtime β€” and stays
14
+ * untyped, so the picker could not read `values.categoryIds` at all. Both
15
+ * halves are needed, which is the same trap as a field missing from
16
+ * `PRODUCT_UPDATABLE_FIELDS` (Root Cause #101) one layer out.
17
+ */
18
+ categoryIds?: string[];
9
19
  brand?: string;
10
20
  condition?: string;
11
21
  tags?: string[];
@@ -134,10 +144,25 @@ export interface SellerProductShellProps {
134
144
  onDiscard?: () => void;
135
145
  isLoading?: boolean;
136
146
  storeSlug?: string;
137
- /** Render a custom category selector (e.g. PaginatedSelect with createLabel). */
147
+ /**
148
+ * Render a custom category selector (e.g. PaginatedSelect with createLabel).
149
+ *
150
+ * I2 β€” `extraValues`/`onExtraValuesChange` carry the ADDITIONAL assignments.
151
+ * A selector that ignores them stays exactly single-select, which is what
152
+ * every surface except this one does.
153
+ */
138
154
  renderCategorySelector?: (props: {
139
155
  value: string;
140
156
  onChange: (v: string) => void;
157
+ /**
158
+ * πŸ›‘ OPTIONAL, and that is the whole point. `QuickProductForm` is a second
159
+ * consumer of this slot and making these required broke it immediately β€”
160
+ * Root Cause #20, a public prop change without its consumers, which the
161
+ * comment above literally warns about. A selector that ignores them stays
162
+ * exactly single-select.
163
+ */
164
+ extraValues?: string[];
165
+ onExtraValuesChange?: (ids: string[]) => void;
141
166
  }) => React.ReactNode;
142
167
  /** Render a custom brand selector. */
143
168
  renderBrandSelector?: (props: {
@@ -46,6 +46,15 @@ const sellerProductSchema = z.object({
46
46
  description: z.string().max(10000).optional().or(z.literal("")),
47
47
  price: z.number().min(0).optional(),
48
48
  category: z.string().optional(),
49
+ /**
50
+ * I2 β€” the assignment list, DEFAULT FIRST. `category` mirrors `[0]`.
51
+ *
52
+ * Declared even though this schema is `.passthrough()`, because the draft's
53
+ * TYPE is what the render site reads `values.categoryIds` off β€” passthrough
54
+ * keeps an undeclared key at runtime and still leaves it untyped, so the
55
+ * picker could not see it.
56
+ */
57
+ categoryIds: z.array(z.string()).max(4).optional(),
49
58
  brand: z.string().optional(),
50
59
  isActive: z.boolean().optional(),
51
60
  images: z
@@ -106,7 +115,27 @@ function StepBasic({ values, onChange, renderCategorySelector, renderBrandSelect
106
115
  condition: CONDITION_OPTIONS.find((o) => o.value === (values.condition ?? "new"))?.label ?? "",
107
116
  siteName: "LetItRip",
108
117
  };
109
- return (_jsxs(Stack, { gap: "md", children: [_jsx(FormField, { name: "title", label: "Title", type: "text", value: values.title ?? "", onChange: (v) => onChange({ title: v }), placeholder: "e.g. Charizard Base Set PSA 9" }), _jsxs(FormGroup, { columns: 2, children: [renderCategorySelector ? (_jsxs(_Fragment, { children: [_jsx(Text, { className: "text-[var(--appkit-color-text)] mb-1", size: "sm", weight: "medium", children: "Category" }), renderCategorySelector({ value: values.category ?? "", onChange: (v) => onChange({ category: v }) })] })) : (_jsx(FormField, { name: "category", label: "Category", type: "text", value: values.category ?? "", onChange: (v) => onChange({ category: v }), placeholder: "e.g. Trading Cards" })), _jsx(FormField, { name: "condition", label: "Condition", type: "select", value: values.condition ?? "new", onChange: (v) => onChange({ condition: v }), options: CONDITION_OPTIONS })] }), renderTemplateSelector?.({
118
+ return (_jsxs(Stack, { gap: "md", children: [_jsx(FormField, { name: "title", label: "Title", type: "text", value: values.title ?? "", onChange: (v) => onChange({ title: v }), placeholder: "e.g. Charizard Base Set PSA 9" }), _jsxs(FormGroup, { columns: 2, children: [renderCategorySelector ? (_jsxs(_Fragment, { children: [_jsx(Text, { className: "text-[var(--appkit-color-text)] mb-1", size: "sm", weight: "medium", children: "Category" }), renderCategorySelector({
119
+ value: values.category ?? "",
120
+ onChange: (v) => onChange({ category: v }),
121
+ /*
122
+ * πŸ›‘ `categoryIds[0]` is the DEFAULT assignment and is already
123
+ * rendered by the primary picker above, so the extras list is
124
+ * everything AFTER it. Passing the whole array would show the
125
+ * primary twice and let a seller "remove" it from a control that
126
+ * does not own it.
127
+ */
128
+ extraValues: (values.categoryIds ?? []).slice(1),
129
+ onExtraValuesChange: (ids) => onChange({
130
+ /*
131
+ * Rebuilt primary-first on every change, because the server
132
+ * derives the default by DEPTH and reorders the stored array
133
+ * anyway β€” sending it in a different order would make the
134
+ * form and the saved document disagree until the next load.
135
+ */
136
+ categoryIds: [values.category ?? "", ...ids].filter(Boolean),
137
+ }),
138
+ })] })) : (_jsx(FormField, { name: "category", label: "Category", type: "text", value: values.category ?? "", onChange: (v) => onChange({ category: v }), placeholder: "e.g. Trading Cards" })), _jsx(FormField, { name: "condition", label: "Condition", type: "select", value: values.condition ?? "new", onChange: (v) => onChange({ condition: v }), options: CONDITION_OPTIONS })] }), renderTemplateSelector?.({
110
139
  categoryId: values.category ?? "",
111
140
  condition: values.condition ?? "new",
112
141
  context: templateContext,
@@ -0,0 +1,24 @@
1
+ /**
2
+ * GENERATED by scripts/gen-category-content.mjs β€” do not edit by hand.
3
+ *
4
+ * `contentBody` per category, composed from FACTS ONLY (I7). Every clause
5
+ * traces to a committed source: canonical-beys.json for a model leaf,
6
+ * part-glossary.json for a part leaf, the forest's own child lists above them.
7
+ *
8
+ * πŸ›‘ A node with nothing factual to say is ABSENT from this map rather than
9
+ * carrying an empty body β€” `CategoryHighlightsAndFaqSection` renders nothing
10
+ * when its inputs are empty, and an empty paragraph reads as a defect.
11
+ *
12
+ * πŸ›‘ No price band appears here. `MARKET_BANDS` omits a band rather than
13
+ * guessing one, and a body that stated an unmeasured "typical price" would be
14
+ * the same guess with better typography.
15
+ *
16
+ * πŸ›‘ No listing COUNT appears here either. Counts live in `categoryCounts` and
17
+ * move; a sentence saying "48 listings" is wrong the next day and nothing
18
+ * reports it.
19
+ *
20
+ * Model leaves with a body: 213 of 216
21
+ * Part leaves with a body: 532 of 752
22
+ */
23
+ export declare const GENERATED_CATEGORY_CONTENT: Record<string, string>;
24
+ export declare const GENERATED_CATEGORY_CONTENT_COUNT = 745;