@mohasinac/appkit 4.45.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.
Files changed (160) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/categories/live-counts.d.ts +41 -0
  3. package/dist/_internal/server/features/categories/live-counts.js +72 -0
  4. package/dist/_internal/server/features/checkout/expand-order-items.js +17 -0
  5. package/dist/_internal/server/features/orders/adapters.js +29 -0
  6. package/dist/_internal/server/features/parts/assembly.d.ts +126 -0
  7. package/dist/_internal/server/features/parts/assembly.js +246 -0
  8. package/dist/_internal/server/features/parts/data.d.ts +65 -0
  9. package/dist/_internal/server/features/parts/data.js +170 -0
  10. package/dist/_internal/server/features/parts/og.d.ts +34 -0
  11. package/dist/_internal/server/features/parts/og.js +66 -0
  12. package/dist/_internal/server/features/search/search-everything.d.ts +75 -0
  13. package/dist/_internal/server/features/search/search-everything.js +120 -0
  14. package/dist/_internal/server/features/search/search-sources.d.ts +17 -0
  15. package/dist/_internal/server/features/search/search-sources.js +138 -0
  16. package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.d.ts +70 -0
  17. package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.js +302 -0
  18. package/dist/_internal/server/jobs/core/countersReconcile.js +128 -19
  19. package/dist/_internal/server/jobs/core/jobRunners.js +12 -0
  20. package/dist/_internal/server/jobs/core/onCategoryWrite.js +73 -3
  21. package/dist/_internal/server/jobs/core/onProductWrite.js +93 -43
  22. package/dist/_internal/server/jobs/core/productViewWindows.d.ts +2 -0
  23. package/dist/_internal/server/jobs/core/productViewWindows.js +173 -0
  24. package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
  25. package/dist/_internal/server/jobs/core/testDataCleanup.js +27 -1
  26. package/dist/_internal/server/jobs/handlers/pageViewRollup.d.ts +20 -0
  27. package/dist/_internal/server/jobs/handlers/pageViewRollup.js +25 -1
  28. package/dist/_internal/shared/actions/action-registry.js +36 -0
  29. package/dist/_internal/shared/features/promotions/schema.d.ts +2 -2
  30. package/dist/_internal/shared/features/shipping/protections.d.ts +93 -1
  31. package/dist/_internal/shared/features/shipping/protections.js +127 -0
  32. package/dist/_internal/shared/search/part-aliases.d.ts +44 -0
  33. package/dist/_internal/shared/search/part-aliases.js +104 -0
  34. package/dist/constants/api-endpoints.d.ts +18 -12
  35. package/dist/constants/api-endpoints.js +6 -8
  36. package/dist/constants/field-names.d.ts +0 -2
  37. package/dist/constants/field-names.js +0 -2
  38. package/dist/constants/index.d.ts +1 -1
  39. package/dist/constants/index.js +1 -1
  40. package/dist/core/index.d.ts +0 -3
  41. package/dist/core/index.js +0 -2
  42. package/dist/errors/messages.d.ts +0 -7
  43. package/dist/errors/messages.js +0 -8
  44. package/dist/features/admin/components/AdminSectionsView.js +83 -32
  45. package/dist/features/admin/components/AdminSiteSettingsView.js +6 -2
  46. package/dist/features/admin/components/sections/sectionTemplates.d.ts +117 -0
  47. package/dist/features/admin/components/sections/sectionTemplates.js +213 -0
  48. package/dist/features/admin/schemas/firestore.d.ts +15 -0
  49. package/dist/features/admin/types/index.d.ts +0 -1
  50. package/dist/features/auth/permissions/constants.d.ts +1 -1
  51. package/dist/features/auth/permissions/constants.js +0 -2
  52. package/dist/features/categories/api/[id]/description-template/route.js +40 -1
  53. package/dist/features/categories/components/BrandDetailPageView.js +14 -3
  54. package/dist/features/categories/components/BreadcrumbTrail.d.ts +24 -1
  55. package/dist/features/categories/components/BreadcrumbTrail.js +45 -4
  56. package/dist/features/categories/components/CategoryDetailPageView.js +89 -14
  57. package/dist/features/categories/components/CategoryTemplatePicker.js +9 -1
  58. package/dist/features/categories/repository/categories.repository.d.ts +65 -0
  59. package/dist/features/categories/repository/categories.repository.js +168 -22
  60. package/dist/features/categories/repository/category-counts.d.ts +143 -0
  61. package/dist/features/categories/repository/category-counts.js +224 -0
  62. package/dist/features/categories/schemas/category-counts.d.ts +155 -0
  63. package/dist/features/categories/schemas/category-counts.js +116 -0
  64. package/dist/features/categories/schemas/firestore.d.ts +0 -2
  65. package/dist/features/categories/schemas/firestore.js +0 -1
  66. package/dist/features/categories/utils/breadcrumb-chain.d.ts +194 -0
  67. package/dist/features/categories/utils/breadcrumb-chain.js +202 -0
  68. package/dist/features/corporate/schemas/index.d.ts +4 -4
  69. package/dist/features/orders/schemas/firestore.d.ts +13 -0
  70. package/dist/features/orders/types/index.d.ts +30 -0
  71. package/dist/features/parts/components/AssemblyDrawer.d.ts +107 -0
  72. package/dist/features/parts/components/AssemblyDrawer.js +250 -0
  73. package/dist/features/parts/components/ModelCompositionPanel.d.ts +37 -0
  74. package/dist/features/parts/components/ModelCompositionPanel.js +34 -0
  75. package/dist/features/parts/components/PartDetailPageView.d.ts +48 -0
  76. package/dist/features/parts/components/PartDetailPageView.js +20 -0
  77. package/dist/features/parts/components/PartsIndexPageView.d.ts +33 -0
  78. package/dist/features/parts/components/PartsIndexPageView.js +17 -0
  79. package/dist/features/parts/schemas/firestore.d.ts +257 -0
  80. package/dist/features/parts/schemas/firestore.js +181 -0
  81. package/dist/features/parts/utils/colourway-match.d.ts +62 -0
  82. package/dist/features/parts/utils/colourway-match.js +98 -0
  83. package/dist/features/parts/utils/part-breakdown.d.ts +53 -0
  84. package/dist/features/parts/utils/part-breakdown.js +77 -0
  85. package/dist/features/products/components/ProductDetailPageView.d.ts +14 -1
  86. package/dist/features/products/components/ProductDetailPageView.js +22 -3
  87. package/dist/features/products/components/ProductTabsShell.d.ts +3 -1
  88. package/dist/features/products/components/ProductTabsShell.js +4 -1
  89. package/dist/features/products/repository/category-assignment.d.ts +132 -0
  90. package/dist/features/products/repository/category-assignment.js +304 -0
  91. package/dist/features/products/repository/products.repository.d.ts +58 -1
  92. package/dist/features/products/repository/products.repository.js +255 -28
  93. package/dist/features/products/schemas/firestore.d.ts +147 -6
  94. package/dist/features/products/schemas/firestore.js +49 -0
  95. package/dist/features/search/components/SearchResultsPageView.d.ts +33 -0
  96. package/dist/features/search/components/SearchResultsPageView.js +21 -0
  97. package/dist/features/seller/components/CategoryInlineSelect.d.ts +17 -1
  98. package/dist/features/seller/components/CategoryInlineSelect.js +14 -3
  99. package/dist/features/seller/components/SellerProductShell.d.ts +26 -1
  100. package/dist/features/seller/components/SellerProductShell.js +30 -1
  101. package/dist/features/seller/components/SellerProductsView.js +129 -3
  102. package/dist/features/tester/seed-data/authored/admin__site-system.js +0 -15
  103. package/dist/features/tester/seed-data/authored/content-discovery__parts-database.d.ts +2 -0
  104. package/dist/features/tester/seed-data/authored/content-discovery__parts-database.js +166 -0
  105. package/dist/features/tester/seed-data/authored/index.js +2 -0
  106. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +28 -1
  107. package/dist/index.d.ts +11 -16
  108. package/dist/index.js +26 -18
  109. package/dist/next/routing/route-map.d.ts +6 -2
  110. package/dist/next/routing/route-map.js +14 -1
  111. package/dist/providers/db-firebase/base.repository.d.ts +31 -0
  112. package/dist/providers/db-firebase/base.repository.js +31 -0
  113. package/dist/repositories/index.d.ts +1 -2
  114. package/dist/repositories/index.js +0 -1
  115. package/dist/seed/_helpers/category-content-generated.d.ts +24 -0
  116. package/dist/seed/_helpers/category-content-generated.js +1837 -0
  117. package/dist/seed/_helpers/category-content.d.ts +29 -0
  118. package/dist/seed/_helpers/category-content.js +98 -1
  119. package/dist/seed/_helpers/category-forest.js +104 -24
  120. package/dist/seed/_helpers/category-parts.d.ts +9 -0
  121. package/dist/seed/_helpers/category-parts.js +4594 -0
  122. package/dist/seed/carousel-slides-seed-data.js +81 -8
  123. package/dist/seed/categories-seed-data.js +44 -6
  124. package/dist/seed/homepage-sections-seed-data.js +170 -9
  125. package/dist/seed/index.d.ts +2 -0
  126. package/dist/seed/index.js +13 -0
  127. package/dist/seed/model-composition-seed-data.d.ts +15 -0
  128. package/dist/seed/model-composition-seed-data.js +8918 -0
  129. package/dist/seed/part-glossary-seed-data.d.ts +15 -0
  130. package/dist/seed/part-glossary-seed-data.js +10729 -0
  131. package/dist/seed/products-standard-seed-data.js +52 -0
  132. package/dist/seed/seed-purpose.d.ts +103 -4
  133. package/dist/seed/seed-purpose.js +162 -11
  134. package/dist/seo/json-ld.d.ts +10 -1
  135. package/dist/seo/json-ld.js +9 -1
  136. package/dist/server.d.ts +7 -0
  137. package/dist/server.js +11 -0
  138. package/dist/styles.css +1 -1
  139. package/dist/tailwind-utilities.css +1 -1
  140. package/dist/utils/id-generators.d.ts +30 -0
  141. package/dist/utils/id-generators.js +31 -1
  142. package/dist/utils/search-txt-builders.js +22 -0
  143. package/dist/utils/search-txt.d.ts +22 -0
  144. package/dist/utils/search-txt.js +17 -0
  145. package/dist/values/success-messages.d.ts +0 -4
  146. package/dist/values/success-messages.js +0 -5
  147. package/package.json +1 -1
  148. package/scripts/seed-cli.mjs +292 -16
  149. package/dist/core/copilot-log.repository.d.ts +0 -44
  150. package/dist/core/copilot-log.repository.js +0 -61
  151. package/dist/features/copilot/components/AdminCopilotView.d.ts +0 -21
  152. package/dist/features/copilot/components/AdminCopilotView.js +0 -51
  153. package/dist/features/copilot/components/index.d.ts +0 -2
  154. package/dist/features/copilot/components/index.js +0 -1
  155. package/dist/features/copilot/hooks/useCopilotChat.d.ts +0 -25
  156. package/dist/features/copilot/hooks/useCopilotChat.js +0 -65
  157. package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +0 -15
  158. package/dist/features/copilot/hooks/useCopilotFeedback.js +0 -15
  159. package/dist/features/copilot/index.d.ts +0 -3
  160. package/dist/features/copilot/index.js +0 -3
@@ -16,6 +16,10 @@ export const PRODUCT_INDEXED_FIELDS = [
16
16
  "storeId",
17
17
  "status",
18
18
  "category",
19
+ /* Indexed: `array-contains` on the ASSIGNED list is what the category
20
+ * delete cascade queries to find a product filed here as its SECOND
21
+ * category — `findBy("category", id)` sees only the default leaf. */
22
+ "categoryIds",
19
23
  "featured",
20
24
  "listingType",
21
25
  "isPromoted",
@@ -74,6 +78,9 @@ export const PRODUCT_PUBLIC_FIELDS = [
74
78
  "title",
75
79
  "description",
76
80
  "category",
81
+ /* Public: the chips on a detail page render the assignments. */
82
+ "categoryIds",
83
+ "categoryIdNames",
77
84
  "subcategory",
78
85
  "brand",
79
86
  "price",
@@ -92,6 +99,35 @@ export const PRODUCT_PUBLIC_FIELDS = [
92
99
  // Buyer-facing: drives the "Final Sale" card pill and the detail-page
93
100
  // Delivery & Returns line. Absent means final sale — read via isFinalSale().
94
101
  "finalSale",
102
+ // Buyer-facing by necessity: "lot of 12" is the single most important thing
103
+ // about a lot listing, and a buyer who cannot see it thinks they are bidding
104
+ // on one item.
105
+ "isLot",
106
+ "lotSize",
107
+ /* D13/D14 — buyer-facing by definition: a colourway is WHY someone buys that
108
+ * particular spare, and a unit label is how they tell two identical listings
109
+ * apart. `assembledFrom` is listed too — it names product ids that are
110
+ * already public listings, and "built from these parts" is provenance a
111
+ * buyer should see. */
112
+ "unitLabel",
113
+ "partGlossaryId",
114
+ "colourway",
115
+ "mouldVariant",
116
+ "assembledFrom",
117
+ /* D8 — public because "N people viewed this this week" is the honest social
118
+ * proof H2 is built on, and it is a figure we measured. 🛑 Deliberately NOT
119
+ * in PRODUCT_UPDATABLE_FIELDS: a seller who could write their own view count
120
+ * turns a measurement into a claim. */
121
+ "views7d",
122
+ "views30d",
123
+ /* 🛑 D13/D14. Listed here or an update STRIPS them: the write schema has no
124
+ * `.passthrough()`, so a field absent from this list is dropped before it
125
+ * reaches Firestore while the write still returns 200 (Root Cause #101). */
126
+ "unitLabel",
127
+ "partGlossaryId",
128
+ "colourway",
129
+ "mouldVariant",
130
+ "assembledFrom",
95
131
  "listingType",
96
132
  "auctionEndDate",
97
133
  "startingBid",
@@ -147,6 +183,12 @@ export const PRODUCT_UPDATABLE_FIELDS = [
147
183
  "title",
148
184
  "description",
149
185
  "category",
186
+ /* I1 — the ASSIGNED list. 🛑 Named here or an update STRIPS it while still
187
+ * returning 200 (Root Cause #101), which is exactly how a UI-created
188
+ * product lost its chain. `categorySlugs`/`categoryNames` stay OUT: they
189
+ * are derived, and a caller that could set them could desync the closure
190
+ * from the assignments it is supposed to be the closure OF. */
191
+ "categoryIds",
150
192
  "subcategory",
151
193
  "brand",
152
194
  "price",
@@ -159,6 +201,13 @@ export const PRODUCT_UPDATABLE_FIELDS = [
159
201
  "shippingInfo",
160
202
  "returnPolicy",
161
203
  "finalSale",
204
+ /* 🛑 Listed here or an update STRIPS them. The write schema has no
205
+ * `.passthrough()`, so a field absent from this list is dropped before it
206
+ * reaches Firestore while the write still returns 200 — exactly how a
207
+ * UI-created product lost its `categorySlugs` chain (Root Cause #101) and how
208
+ * `compareAtPrice` is silently discarded today (F1 #25). */
209
+ "isLot",
210
+ "lotSize",
162
211
  "pickupAddressId",
163
212
  "condition",
164
213
  "insurance",
@@ -0,0 +1,33 @@
1
+ /**
2
+ * D2 Fix 4 — the cross-resource search RESULTS page.
3
+ *
4
+ * Replaces a pure redirect. Before this, `/search` mapped each of 15
5
+ * `SearchResourceType` values to a browse route and `redirect()`ed, so a buyer
6
+ * searching "dranzer" could not see that we have a product, a category, a blog
7
+ * post and an FAQ about it — the dropdown only chose which single listing page
8
+ * to send them to.
9
+ *
10
+ * Measured on production: "burst" returns 21 hits across SIX groups
11
+ * (listings, categories, brands, stores, articles, FAQs); "zzzznope" and "!!!"
12
+ * return none.
13
+ *
14
+ * 🛑 STILL `noindex`, and that is deliberate rather than inherited. A search
15
+ * results page is the classic crawl trap: one URL per query string, thin
16
+ * content, and no canonical answer to index. The redirect it replaces was
17
+ * `noindex` too, so this changes the page's usefulness to a HUMAN without
18
+ * changing its relationship to a crawler. The `moreHref` links go to the real
19
+ * indexable listing pages.
20
+ *
21
+ * 🛑 NO PRICES ANYWHERE. Prices are hidden from signed-out visitors and this
22
+ * is the one surface where gating every row would be most of the markup.
23
+ * A title plus a link is honest, needs no `<GatedPrice>`, and cannot leak —
24
+ * there is nothing on the row to leak.
25
+ */
26
+ import React from "react";
27
+ import type { SearchEverythingResult } from "../../../_internal/server/features/search/search-everything";
28
+ export interface SearchResultsPageViewProps {
29
+ result: SearchEverythingResult;
30
+ /** Rendered above the groups — the consumer owns the input control. */
31
+ renderSearchInput?: () => React.ReactNode;
32
+ }
33
+ export declare function SearchResultsPageView({ result, renderSearchInput, }: SearchResultsPageViewProps): React.JSX.Element;
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Section, Container, Stack, Row, Div, Heading, Text, Span, TextLink } from "../../../ui/index.js";
3
+ export function SearchResultsPageView({ result, renderSearchInput, }) {
4
+ const { q, terms, groups, totalHits } = result;
5
+ const hasQuery = q.trim().length > 0;
6
+ /*
7
+ * 🛑 THREE states, not two, and the middle one is the honest part.
8
+ *
9
+ * no query — prompt, no claim about results
10
+ * query, 0 terms — the query normalised away ("!!!", "—"). Saying
11
+ * "no results for !!!" implies we looked; we could
12
+ * not. A2's bug was the inverse of this: a
13
+ * punctuation-only query returned the ENTIRE
14
+ * published catalogue as search results.
15
+ * query, 0 hits — genuinely nothing matched
16
+ */
17
+ const normalisedAway = hasQuery && terms.length === 0;
18
+ return (_jsx(Section, { padding: "page", children: _jsx(Container, { children: _jsxs(Stack, { gap: "loose", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 1, children: hasQuery ? `Search: ${q}` : "Search" }), hasQuery && !normalisedAway && (_jsx(Text, { color: "muted", size: "sm", children: totalHits === 0
19
+ ? "Nothing matched."
20
+ : `${totalHits}${groups.some((g) => g.truncated) ? "+" : ""} result${totalHits === 1 ? "" : "s"} across ${groups.length} section${groups.length === 1 ? "" : "s"}.` }))] }), renderSearchInput?.(), !hasQuery && (_jsx(Text, { color: "muted", children: "Search listings, categories, brands, stores, articles and help pages at once." })), normalisedAway && (_jsx(Text, { color: "muted", children: "That search had no searchable words in it. Try a product, part or category name." })), groups.map((group) => (_jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { justify: "between", align: "center", wrap: true, gap: "sm", children: [_jsxs(Heading, { level: 2, size: "lg", children: [group.label, group.truncated && (_jsxs(Span, { size: "sm", color: "muted", children: [" ", "(", group.hits.length, "+)"] }))] }), _jsxs(TextLink, { href: group.moreHref, children: ["See all ", group.label.toLowerCase(), " \u2192"] })] }), _jsx(Stack, { gap: "xs", children: group.hits.map((hit) => (_jsxs(Div, { surface: "card", padding: "sm", rounded: "md", children: [_jsx(TextLink, { href: hit.href, children: hit.title }), hit.detail && (_jsx(Text, { size: "xs", color: "muted", children: hit.detail }))] }, `${group.key}-${hit.id}`))) })] }, group.key))), hasQuery && !normalisedAway && totalHits === 0 && (_jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { color: "muted", children: ["No listings, categories, brands, stores, articles or help pages matched \u201C", q, "\u201D."] }), _jsx(TextLink, { href: "/products", children: "Browse all listings \u2192" })] }))] }) }) }));
21
+ }
@@ -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,
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { formatPhysicalLocation } from "../../../utils/physical-location.js";
4
4
  import { normalizeError } from "../../../errors/normalize.js";
5
+ import { trackError, ErrorCategory, ErrorSeverity, } from "../../../monitoring/error-tracking.js";
5
6
  import { toUserMessage } from "../../../errors/error-display-map.js";
6
7
  import { SIEVE_OP, Stack, sieveFilter } from "@mohasinac/appkit/client";
7
8
  import { LOW_STOCK_THRESHOLD } from "../../store-extensions/constants/alert-metrics.js";
@@ -9,7 +10,7 @@ import { sortBy } from "@mohasinac/appkit/client";
9
10
  import { useState, useCallback } from "react";
10
11
  import { useEntityDelete } from "../../../react/hooks/useEntityDelete.js";
11
12
  import { useActionDispatch } from "../../../react/hooks/use-action-dispatch.js";
12
- import { Eye, EyeOff, Pencil, Trash2, Printer, MapPin, Boxes } from "lucide-react";
13
+ import { Eye, EyeOff, Pencil, Trash2, Printer, MapPin, Boxes, Wrench, Scissors } from "lucide-react";
13
14
  import { PhysicalLocationModal } from "./PhysicalLocationModal.js";
14
15
  import { BulkStockModal } from "./BulkStockModal.js";
15
16
  import { useUrlTable } from "../../../react/hooks/useUrlTable.js";
@@ -30,6 +31,7 @@ import { SellerProductsCards } from "./SellerProductsCards.js";
30
31
  import { SellerProductsFilterFields } from "./SellerProductsFilterDrawer.js";
31
32
  import { listingBadgeVariant } from "../../products/utils/listing-badge-variant.js";
32
33
  import { DataListingView } from "../../admin/components/DataListingView.js";
34
+ import { AssemblyDrawer, } from "../../parts/components/AssemblyDrawer.js";
33
35
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry.js";
34
36
  import { buildBulkAction } from "../../../_internal/shared/actions/bulk-helpers.js";
35
37
  const FILTER_KEYS = [
@@ -148,6 +150,29 @@ const PRODUCT_COLUMNS = [
148
150
  * Both stock fields have been `canFilter`/`canSort` at the repository layer
149
151
  * the whole time; only the view omitted them.
150
152
  */
153
+ /*
154
+ * D8 — views, which were collected on ~30 surfaces and joined to NOTHING.
155
+ * A seller could not see how many people looked at their listing: the metric
156
+ * every competitor platform leads with.
157
+ *
158
+ * 🛑 Three states, and the middle one is the whole reason this is a column
159
+ * rather than a number in a subtitle. `undefined` means NOT YET ROLLED UP —
160
+ * the nightly pass has not run — and renders an em-dash; `0` means measured
161
+ * and nobody looked, and renders `0`. Collapsing them with `?? 0` is exactly
162
+ * what made the admin Top Products table show "0 views" on every row as
163
+ * though it were a measurement (A5).
164
+ */
165
+ {
166
+ key: "views",
167
+ header: "7d views",
168
+ className: "w-24 text-right",
169
+ render: (row) => {
170
+ if (typeof row.views7d !== "number") {
171
+ return _jsx(Span, { size: "xs", color: "faint", children: "\u2014" });
172
+ }
173
+ return (_jsx(Span, { size: "xs", color: row.views7d > 0 ? "primary" : "muted", numeric: true, children: row.views7d.toLocaleString() }));
174
+ },
175
+ },
151
176
  {
152
177
  key: "stock",
153
178
  header: "Stock",
@@ -227,6 +252,94 @@ export function SellerProductsView({ onDeleteProduct, onCreateClick, }) {
227
252
  const [setLocationOpen, setSetLocationOpen] = useState(false);
228
253
  const [selectedIdsForLocation, setSelectedIdsForLocation] = useState([]);
229
254
  const [setStockOpen, setSetStockOpen] = useState(false);
255
+ /*
256
+ * ── D13: assembly ──────────────────────────────────────────────────────
257
+ *
258
+ * 🛑 The drawer's data is fetched when it OPENS, not with the page. 50 part
259
+ * listings and a composition per row would be a read nobody asked for on a
260
+ * view that renders 50 rows — and the drawer is used on a handful of them.
261
+ */
262
+ const [assembleFor, setAssembleFor] = useState(null);
263
+ const [assembleData, setAssembleData] = useState(null);
264
+ const [assembleLoading, setAssembleLoading] = useState(false);
265
+ const openAssembly = useCallback(async (row, direction) => {
266
+ setAssembleFor({ id: row.id, title: row.primary ?? row.id, direction });
267
+ setAssembleData(null);
268
+ setAssembleLoading(true);
269
+ try {
270
+ const res = await fetch(SELLER_ENDPOINTS.PRODUCT_ASSEMBLE(row.id));
271
+ const body = (await res.json().catch(() => null));
272
+ if (!res.ok)
273
+ throw new Error(body?.error ?? "Could not load your parts");
274
+ setAssembleData({
275
+ compositions: body?.data?.compositions ?? [],
276
+ options: body?.data?.options ?? [],
277
+ targetAvailable: body?.data?.targetAvailable ?? 0,
278
+ });
279
+ }
280
+ catch (err) {
281
+ const e = normalizeError(err);
282
+ /*
283
+ * 🛑 Closed and reported, never left open and empty. An empty picker
284
+ * with no explanation is the state in which a seller concludes they
285
+ * hold no parts — which may be false.
286
+ */
287
+ setAssembleFor(null);
288
+ /*
289
+ * 🛑 AUTHORED copy, not `e.message`. Unlike the 409 (which carries
290
+ * `{needs, has}` and composes a sentence around the real figures),
291
+ * a load failure has no number worth surfacing — so this is one honest
292
+ * line, and the raw text goes to the recorder where it belongs.
293
+ */
294
+ showToast("Could not load your parts. Please try again.", "error");
295
+ trackError(e.message, ErrorCategory.API, ErrorSeverity.MEDIUM, {
296
+ component: "SellerProductsView",
297
+ action: "openAssembly",
298
+ });
299
+ }
300
+ finally {
301
+ setAssembleLoading(false);
302
+ }
303
+ }, [showToast]);
304
+ const submitAssembly = useCallback(async (input) => {
305
+ const id = assembleFor?.id;
306
+ if (!id)
307
+ throw new Error("No listing selected");
308
+ const res = await fetch(SELLER_ENDPOINTS.PRODUCT_ASSEMBLE(id), {
309
+ method: "POST",
310
+ headers: { "Content-Type": "application/json" },
311
+ body: JSON.stringify(input),
312
+ });
313
+ const body = (await res.json().catch(() => null));
314
+ /*
315
+ * 🛑 THROWS with the server's own message, which the drawer renders. A
316
+ * 409 reads "Not enough available: needs 6, has 5" — the one sentence
317
+ * that tells the seller what to do. Swallowing it into a generic failure
318
+ * would turn "restock" into "retry".
319
+ */
320
+ if (!res.ok) {
321
+ /*
322
+ * 🛑 Carries the structured DETAIL to the drawer and never a rendered
323
+ * sentence. The drawer composes its own copy around `{needs, has}`;
324
+ * `message` here is for the recorder (Root Cause #86 —
325
+ * `audit-raw-error-text` blocks showing a thrown `.message`).
326
+ */
327
+ const err = new Error(body?.error ?? "assembly failed");
328
+ /*
329
+ * Typed rather than `unknown`: the 409's shape is OURS — the planner
330
+ * throws `{ kind, productId, needs, has }` — so declaring it is what
331
+ * lets the drawer compose a sentence around real figures instead of
332
+ * casting at the boundary (Root Cause #57/#70: a cast at a boundary is
333
+ * where this class of bug lives).
334
+ */
335
+ err.detail = { code: body?.code, ...(body?.details ?? {}) };
336
+ throw err;
337
+ }
338
+ return {
339
+ unitsBuilt: body?.data?.unitsBuilt ?? input.units,
340
+ consumed: body?.data?.consumed ?? [],
341
+ };
342
+ }, [assembleFor?.id]);
230
343
  const [selectedIdsForStock, setSelectedIdsForStock] = useState([]);
231
344
  const [stockBusy, setStockBusy] = useState(false);
232
345
  /*
@@ -472,6 +585,9 @@ export function SellerProductsView({ onDeleteProduct, onCreateClick, }) {
472
585
  price: priceRaw ? `₹${priceRaw.toLocaleString("en-IN")}` : "—",
473
586
  stock: stockLabel || "—",
474
587
  stockValue,
588
+ // Carried through ONLY when the document really has it — see the
589
+ // field's own note on undefined-vs-zero.
590
+ ...(typeof item.views7d === "number" ? { views7d: item.views7d } : {}),
475
591
  physicalLocation: item.physicalLocation &&
476
592
  typeof item.physicalLocation.zone === "string"
477
593
  ? item.physicalLocation
@@ -524,8 +640,18 @@ export function SellerProductsView({ onDeleteProduct, onCreateClick, }) {
524
640
  : `/products/${row.id}`,
525
641
  renderRowActions: (row) => {
526
642
  const isPublished = (statusOverrides.get(row.id) ?? row.status) === "published";
527
- return (_jsxs(Row, { gap: "xs", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); handleEdit(row); }, "aria-label": ACTIONS.STORE["edit-listing"].ariaLabel, children: _jsx(Pencil, { className: "w-4 h-4" }) }), _jsx(Button, { variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); void handleTogglePublish(row); }, "aria-label": isPublished ? "Unpublish" : "Publish", title: isPublished ? "Unpublish" : "Publish", disabled: publishingId === row.id, children: isPublished ? _jsx(EyeOff, { className: "w-4 h-4" }) : _jsx(Eye, { className: "w-4 h-4" }) }), _jsx(Button, { variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); void handleDuplicate(row); }, "aria-label": "Duplicate listing", title: "Duplicate", children: "\u29C9" }), onDeleteProduct && (_jsx(Button, { variant: "ghost", size: "sm", action: ACTIONS.STORE["delete-listing"], onClick: (e) => { e.stopPropagation(); void handleDelete(row); }, disabled: deletingId === row.id, className: "text-[var(--appkit-color-error)] hover:bg-[var(--appkit-color-border-subtle)]", children: _jsx(Trash2, { className: "w-4 h-4" }) }))] }));
643
+ return (_jsxs(Row, { gap: "xs", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); handleEdit(row); }, "aria-label": ACTIONS.STORE["edit-listing"].ariaLabel, children: _jsx(Pencil, { className: "w-4 h-4" }) }), _jsx(Button, { variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); void openAssembly(row, "assemble"); }, "aria-label": ACTIONS.STORE["assemble-from-parts"].ariaLabel, title: ACTIONS.STORE["assemble-from-parts"].label, disabled: assembleLoading, children: _jsx(Wrench, { className: "w-4 h-4" }) }), _jsx(Button, { variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); void openAssembly(row, "part-out"); }, "aria-label": ACTIONS.STORE["part-out-listing"].ariaLabel, title: ACTIONS.STORE["part-out-listing"].label, disabled: assembleLoading, children: _jsx(Scissors, { className: "w-4 h-4" }) }), _jsx(Button, { variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); void handleTogglePublish(row); }, "aria-label": isPublished ? "Unpublish" : "Publish", title: isPublished ? "Unpublish" : "Publish", disabled: publishingId === row.id, children: isPublished ? _jsx(EyeOff, { className: "w-4 h-4" }) : _jsx(Eye, { className: "w-4 h-4" }) }), _jsx(Button, { variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); void handleDuplicate(row); }, "aria-label": "Duplicate listing", title: "Duplicate", children: "\u29C9" }), onDeleteProduct && (_jsx(Button, { variant: "ghost", size: "sm", action: ACTIONS.STORE["delete-listing"], onClick: (e) => { e.stopPropagation(); void handleDelete(row); }, disabled: deletingId === row.id, className: "text-[var(--appkit-color-error)] hover:bg-[var(--appkit-color-border-subtle)]", children: _jsx(Trash2, { className: "w-4 h-4" }) }))] }));
528
644
  },
529
645
  };
530
- return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), setLocationOpen && (_jsx(PhysicalLocationModal, { count: selectedIdsForLocation.length, onSave: (loc) => handleSetLocation(loc, selectedIdsForLocation), onClose: () => setSetLocationOpen(false) })), setStockOpen && (_jsx(BulkStockModal, { count: selectedIdsForStock.length, busy: stockBusy, onSave: (qty) => handleSetStock(qty, selectedIdsForStock), onClose: () => setSetStockOpen(false) }))] }));
646
+ return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), setLocationOpen && (_jsx(PhysicalLocationModal, { count: selectedIdsForLocation.length, onSave: (loc) => handleSetLocation(loc, selectedIdsForLocation), onClose: () => setSetLocationOpen(false) })), setStockOpen && (_jsx(BulkStockModal, { count: selectedIdsForStock.length, busy: stockBusy, onSave: (qty) => handleSetStock(qty, selectedIdsForStock), onClose: () => setSetStockOpen(false) })), assembleFor && assembleData && (_jsx(AssemblyDrawer, { isOpen: true,
647
+ /*
648
+ * 🛑 No refetch on close, and that is a stated limitation rather than
649
+ * a silent one: `DataListingView` owns the query and exposes no
650
+ * refetch handle to a consumer. The drawer reports the real
651
+ * before/after per component, so the seller SEES what moved — but the
652
+ * row behind it still shows the pre-build figure until the next
653
+ * navigation. Wiring a refetch means giving `ListingViewConfig` a
654
+ * handle, which is a shared-surface change and its own commit.
655
+ */
656
+ onClose: () => { setAssembleFor(null); setAssembleData(null); }, direction: assembleFor.direction, targetTitle: assembleFor.title, targetAvailable: assembleData.targetAvailable, compositions: assembleData.compositions, options: assembleData.options, onSubmit: submitAssembly }))] }));
531
657
  }
@@ -424,21 +424,6 @@ export const authored = {
424
424
  expectedUiState: "The page renders logs, or a readable message naming what is unavailable. It does not show a stack trace, and it does not take the rest of the admin panel with it.",
425
425
  endResult: "Read-only; nothing persists.",
426
426
  },
427
- "checklist-admin-site-system-copilot-admin": {
428
- roles: ["admin"],
429
- startPage: "/admin",
430
- steps: [
431
- "Sign in as admin@letitrip.in / TempPass123!.",
432
- "Open /admin/copilot and read what it offers.",
433
- "Read whether it states which model or provider it uses.",
434
- "Submit a simple request and read the response.",
435
- "Read whether any error surfaces a provider key or a raw response body.",
436
- "Read whether the feature declines cleanly when unconfigured.",
437
- ],
438
- expectedBehaviour: "The feature works or declines cleanly, and never surfaces a provider credential or a raw upstream error to the browser. Every 5xx message is scrubbed before it reaches a client, with the real one kept server-side for the error recorder.",
439
- expectedUiState: "A response renders, or a readable message names what is missing. No provider key, no raw upstream body, no stack trace.",
440
- endResult: "Read-only; nothing persists.",
441
- },
442
427
  "checklist-admin-site-system-team-admin": {
443
428
  roles: ["admin"],
444
429
  startPage: "/admin/team",
@@ -0,0 +1,2 @@
1
+ import type { AuthoredCase } from "./_types";
2
+ export declare const authored: Record<string, AuthoredCase>;