@mohasinac/appkit 4.57.0 → 4.58.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 (134) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/beys/data.d.ts +44 -0
  3. package/dist/_internal/server/features/beys/data.js +107 -0
  4. package/dist/_internal/server/features/beys/og.d.ts +42 -0
  5. package/dist/_internal/server/features/beys/og.js +89 -0
  6. package/dist/_internal/server/features/parts/data.js +3 -2
  7. package/dist/_internal/shared/actions/action-registry.d.ts +1 -1
  8. package/dist/_internal/shared/actions/action-registry.js +86 -0
  9. package/dist/client.d.ts +2 -0
  10. package/dist/client.js +1 -0
  11. package/dist/constants/api-endpoints.d.ts +12 -0
  12. package/dist/constants/api-endpoints.js +10 -0
  13. package/dist/constants/field-names.d.ts +46 -0
  14. package/dist/constants/field-names.js +46 -0
  15. package/dist/features/admin/components/AdminBeysView.d.ts +27 -0
  16. package/dist/features/admin/components/AdminBeysView.js +178 -0
  17. package/dist/features/admin/components/AdminPartsView.d.ts +27 -0
  18. package/dist/features/admin/components/AdminPartsView.js +160 -0
  19. package/dist/features/admin/components/OwnerQuickActions.d.ts +19 -2
  20. package/dist/features/admin/components/OwnerQuickActions.js +31 -3
  21. package/dist/features/admin/components/RecordOwnerQuickActions.d.ts +31 -0
  22. package/dist/features/admin/components/RecordOwnerQuickActions.js +128 -0
  23. package/dist/features/admin/components/ReferenceRecordEditor.d.ts +45 -0
  24. package/dist/features/admin/components/ReferenceRecordEditor.js +153 -0
  25. package/dist/features/admin/schemas/firestore.d.ts +1 -1
  26. package/dist/features/auctions/components/MarketplaceAuctionCard.js +15 -2
  27. package/dist/features/auth/permissions/constants.d.ts +1 -1
  28. package/dist/features/auth/permissions/constants.js +12 -1
  29. package/dist/features/beys/components/BeyDetailPageView.d.ts +50 -0
  30. package/dist/features/beys/components/BeyDetailPageView.js +98 -0
  31. package/dist/features/beys/schemas/firestore.d.ts +417 -0
  32. package/dist/features/beys/schemas/firestore.js +65 -0
  33. package/dist/features/blog/components/BlogFeaturedCard.js +15 -2
  34. package/dist/features/blog/components/BlogPostView.js +2 -2
  35. package/dist/features/categories/components/BrandDetailPageView.js +4 -2
  36. package/dist/features/categories/components/BundleDetailView.js +2 -2
  37. package/dist/features/categories/components/CategoryDetailPageView.js +36 -3
  38. package/dist/features/categories/components/CategoryGrid.js +24 -2
  39. package/dist/features/categories/components/FranchiseDetailPageView.js +2 -3
  40. package/dist/features/events/components/EventCard.js +15 -2
  41. package/dist/features/grouped/components/GroupedListingDetailView.js +2 -3
  42. package/dist/features/homepage/components/CustomCardsSection.js +67 -50
  43. package/dist/features/homepage/components/ShopByCategorySection.js +23 -10
  44. package/dist/features/homepage/schemas/index.d.ts +10 -10
  45. package/dist/features/parts/components/PartDetailPageView.js +5 -3
  46. package/dist/features/parts/components/PartsSearchBox.js +79 -4
  47. package/dist/features/parts/schemas/admin-edit.d.ts +81 -0
  48. package/dist/features/parts/schemas/admin-edit.js +89 -0
  49. package/dist/features/parts/schemas/firestore.d.ts +2 -1
  50. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +15 -2
  51. package/dist/features/products/components/MarketplaceBundleCard.js +15 -2
  52. package/dist/features/products/components/MarketplacePrizeDrawCard.js +15 -2
  53. package/dist/features/products/components/ProductGrid.js +18 -2
  54. package/dist/features/products/schemas/firestore.d.ts +18 -0
  55. package/dist/features/seller/components/CategoryInlineSelect.d.ts +10 -1
  56. package/dist/features/seller/components/CategoryInlineSelect.js +53 -5
  57. package/dist/features/seller/components/SellerProductShell.d.ts +39 -0
  58. package/dist/features/seller/components/SellerProductShell.js +31 -3
  59. package/dist/index.d.ts +8 -0
  60. package/dist/index.js +13 -0
  61. package/dist/next/routing/route-map.d.ts +13 -1
  62. package/dist/next/routing/route-map.js +37 -0
  63. package/dist/schemas/registry.d.ts +6 -6
  64. package/dist/seed/_helpers/category-parts-glossary-map.js +38 -38
  65. package/dist/seed/_helpers/category-parts.js +39 -39
  66. package/dist/seed/bey-products-seed-data.d.ts +18 -0
  67. package/dist/seed/bey-products-seed-data.js +18011 -0
  68. package/dist/seed/beys/chunk0.d.ts +17 -0
  69. package/dist/seed/beys/chunk0.js +36733 -0
  70. package/dist/seed/beys/chunk1.d.ts +17 -0
  71. package/dist/seed/beys/chunk1.js +35728 -0
  72. package/dist/seed/beys/chunk2.d.ts +17 -0
  73. package/dist/seed/beys/chunk2.js +35857 -0
  74. package/dist/seed/beys/chunk3.d.ts +17 -0
  75. package/dist/seed/beys/chunk3.js +34476 -0
  76. package/dist/seed/beys-seed-data.d.ts +18 -0
  77. package/dist/seed/beys-seed-data.js +11 -0
  78. package/dist/seed/homepage-sections-seed-data.js +16 -0
  79. package/dist/seed/index.d.ts +2 -0
  80. package/dist/seed/index.js +3 -0
  81. package/dist/seed/media-assets-seed-data.js +4 -4
  82. package/dist/seed/products-catalogue/chunk0.d.ts +12 -0
  83. package/dist/seed/products-catalogue/chunk0.js +7262 -0
  84. package/dist/seed/products-catalogue/chunk1.d.ts +12 -0
  85. package/dist/seed/products-catalogue/chunk1.js +7290 -0
  86. package/dist/seed/products-catalogue/chunk10.d.ts +12 -0
  87. package/dist/seed/products-catalogue/chunk10.js +7290 -0
  88. package/dist/seed/products-catalogue/chunk11.d.ts +12 -0
  89. package/dist/seed/products-catalogue/chunk11.js +7005 -0
  90. package/dist/seed/products-catalogue/chunk12.d.ts +12 -0
  91. package/dist/seed/products-catalogue/chunk12.js +7002 -0
  92. package/dist/seed/products-catalogue/chunk13.d.ts +12 -0
  93. package/dist/seed/products-catalogue/chunk13.js +7002 -0
  94. package/dist/seed/products-catalogue/chunk14.d.ts +12 -0
  95. package/dist/seed/products-catalogue/chunk14.js +7002 -0
  96. package/dist/seed/products-catalogue/chunk15.d.ts +12 -0
  97. package/dist/seed/products-catalogue/chunk15.js +7002 -0
  98. package/dist/seed/products-catalogue/chunk16.d.ts +12 -0
  99. package/dist/seed/products-catalogue/chunk16.js +7002 -0
  100. package/dist/seed/products-catalogue/chunk17.d.ts +12 -0
  101. package/dist/seed/products-catalogue/chunk17.js +7002 -0
  102. package/dist/seed/products-catalogue/chunk18.d.ts +12 -0
  103. package/dist/seed/products-catalogue/chunk18.js +4398 -0
  104. package/dist/seed/products-catalogue/chunk2.d.ts +12 -0
  105. package/dist/seed/products-catalogue/chunk2.js +7268 -0
  106. package/dist/seed/products-catalogue/chunk3.d.ts +12 -0
  107. package/dist/seed/products-catalogue/chunk3.js +7264 -0
  108. package/dist/seed/products-catalogue/chunk4.d.ts +12 -0
  109. package/dist/seed/products-catalogue/chunk4.js +7268 -0
  110. package/dist/seed/products-catalogue/chunk5.d.ts +12 -0
  111. package/dist/seed/products-catalogue/chunk5.js +7258 -0
  112. package/dist/seed/products-catalogue/chunk6.d.ts +12 -0
  113. package/dist/seed/products-catalogue/chunk6.js +7268 -0
  114. package/dist/seed/products-catalogue/chunk7.d.ts +12 -0
  115. package/dist/seed/products-catalogue/chunk7.js +7311 -0
  116. package/dist/seed/products-catalogue/chunk8.d.ts +12 -0
  117. package/dist/seed/products-catalogue/chunk8.js +7304 -0
  118. package/dist/seed/products-catalogue/chunk9.d.ts +12 -0
  119. package/dist/seed/products-catalogue/chunk9.js +7309 -0
  120. package/dist/seed/products-catalogue-seed-data.d.ts +2 -22
  121. package/dist/seed/products-catalogue-seed-data.js +58 -86382
  122. package/dist/seed/seed-purpose.js +9 -0
  123. package/dist/server.d.ts +2 -0
  124. package/dist/server.js +8 -0
  125. package/dist/styles.css +468 -1
  126. package/dist/tailwind-utilities.css +1 -1
  127. package/dist/ui/components/PosterCard.d.ts +99 -0
  128. package/dist/ui/components/PosterCard.js +109 -0
  129. package/dist/ui/components/PosterCard.style.css +466 -0
  130. package/dist/ui/components/index.style.css +1 -0
  131. package/dist/ui/index.d.ts +2 -0
  132. package/dist/ui/index.js +2 -0
  133. package/package.json +1 -1
  134. package/scripts/seed-cli.mjs +108 -1
@@ -0,0 +1,178 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ /**
4
+ * AdminBeysView — browse and correct the generated Beyblade database.
5
+ *
6
+ * ── 🛑 WHY THIS IS A CORRECTION SURFACE, NOT A CRUD SCREEN ────────────────
7
+ *
8
+ * `beys` is 1,988 rows built by committed generators from a crawled corpus. So
9
+ * there is no "Add Beyblade" button: a row authored here would have no source,
10
+ * would vanish on the next regeneration, and would make the claim "this dataset
11
+ * rebuilds from `appkit/src/seed/` alone" false. Adding one is a change to the
12
+ * generator.
13
+ *
14
+ * What this surface IS for is the thing the corpus demonstrably needs — fixing
15
+ * a wrong extraction. On mode data alone the source was wrong five times in one
16
+ * session: an inverted tip transition, a mechanism attributed to the wrong
17
+ * part, a part with no modes at all, and two misexpanded abbreviations. A
18
+ * correction made here survives the next reseed, field by field
19
+ * (`adminEditedFields`).
20
+ *
21
+ * @tag domain:beys
22
+ * @tag layer:feature
23
+ * @tag access:client
24
+ * @tag consumers:app/[locale]/admin/beys/page.tsx
25
+ */
26
+ import React from "react";
27
+ import { Heading, ListingLayout, Stack, Text } from "../../../ui/index.js";
28
+ import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints.js";
29
+ import { BEY_FIELDS } from "../../../constants/field-names.js";
30
+ import { sortBy } from "../../../constants/sort.js";
31
+ import { ROUTES } from "../../../next/routing/route-map.js";
32
+ import { DataListingView } from "./DataListingView.js";
33
+ /**
34
+ * 🛑 Rows are read from WHICHEVER envelope shape arrives.
35
+ *
36
+ * `apiClient.get()` unwraps `{success, data}` and returns `data` itself, so an
37
+ * endpoint that puts rows directly in `data` hands `mapRows` an ARRAY while one
38
+ * that nests them hands it an object. Reading `.data` off an array yields
39
+ * undefined — which is exactly how the categories listing rendered "No
40
+ * categories found" against a 200 carrying 58 rows. This route returns the
41
+ * object shape, and tolerating both is what keeps that from mattering.
42
+ */
43
+ function extractRows(response) {
44
+ if (Array.isArray(response))
45
+ return response;
46
+ if (!response || typeof response !== "object")
47
+ return [];
48
+ const obj = response;
49
+ if (Array.isArray(obj.data))
50
+ return obj.data;
51
+ return Array.isArray(obj.items) ? obj.items : [];
52
+ }
53
+ function extractTotal(response) {
54
+ if (Array.isArray(response) || !response || typeof response !== "object")
55
+ return undefined;
56
+ const total = response.total;
57
+ return typeof total === "number" ? total : undefined;
58
+ }
59
+ /*
60
+ * audit-create-affordance-ok: this listing is deliberately inbound-only.
61
+ * The 1,988 rows are produced by committed generators from a crawled
62
+ * corpus, so there is no POST endpoint and no create route key to point a
63
+ * button at — a Beyblade authored by hand would have no source, would vanish
64
+ * on the next regeneration, and would make "this dataset rebuilds from
65
+ * appkit/src/seed/ alone" false. Adding one is a change to the generator.
66
+ * The surface exists to CORRECT a wrong extraction and to REMOVE a row that
67
+ * should not exist, which are the two operations the data actually needs.
68
+ */
69
+ const COLUMNS = [
70
+ {
71
+ key: "primary",
72
+ header: "Beyblade",
73
+ sortable: true,
74
+ render: (row) => (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { weight: "semibold", color: "primary", children: row.primary }), _jsx(Text, { size: "xs", color: "muted", children: row.secondary })] })),
75
+ },
76
+ {
77
+ key: "weight",
78
+ header: "Mass",
79
+ className: "w-28",
80
+ render: (row) => (_jsx(Text, { size: "sm", color: "muted", children: row.weight })),
81
+ },
82
+ {
83
+ key: "edited",
84
+ header: "Corrected",
85
+ className: "w-28",
86
+ /*
87
+ * 🛑 Shown as a COLUMN, because a hand-corrected row behaves differently
88
+ * from its neighbours — the generator no longer owns the fields somebody
89
+ * fixed. Leaving that invisible is how a later maintainer concludes the
90
+ * generator is producing a value it is actually being prevented from
91
+ * writing. Text, not a colour-only dot (colour is never the only signal).
92
+ */
93
+ render: (row) => row.edited ? (_jsx(Text, { size: "xs", weight: "medium", children: "By hand" })) : (_jsx(Text, { size: "xs", color: "faint", children: "Generated" })),
94
+ },
95
+ ];
96
+ const ADMIN_BEYS_CONFIG = {
97
+ portal: "admin",
98
+ title: "Beyblades",
99
+ subtitle: "The generated Beyblade database. Corrections made here are kept when the database is regenerated.",
100
+ searchPlaceholder: "Search Beyblades by name — Dranzer F, Variares…",
101
+ /*
102
+ * 🛑 `searchCommit: "enter"`, not debounce. The route's search is an indexed
103
+ * PREFIX range, so a partial word is a different query rather than a
104
+ * narrowing one — debouncing would fire a range query per pause and show
105
+ * confusing intermediate results. Enter also makes the prefix-only behaviour
106
+ * legible: you typed a prefix and asked for it.
107
+ */
108
+ searchCommit: "enter",
109
+ /*
110
+ * 🛑 DECLARED, so the placeholder can be CHECKED rather than trusted.
111
+ * The route matches exactly one field — an indexed prefix range on
112
+ * `name` — and the placeholder must not imply more. It is a prefix and
113
+ * it is case-sensitive; `mode` says so where a reader will see it.
114
+ */
115
+ search: {
116
+ placeholder: "Search Beyblades by name — a prefix, case-sensitive",
117
+ fields: ["name"],
118
+ },
119
+ emptyLabel: "No Beyblade matches that name",
120
+ filterKeys: [],
121
+ /*
122
+ * 🛑 Through `sortBy`, not a bare "name". The route currently ALWAYS
123
+ * orders by name and ignores the `sorts` param, so this is the only
124
+ * option — but writing it as a literal is what lets a field rename
125
+ * leave a sort string that silently sorts nothing.
126
+ */
127
+ defaultSort: sortBy(BEY_FIELDS.NAME, "ASC"),
128
+ pageSize: 25,
129
+ queryKey: ["admin", "beys"],
130
+ endpoint: ADMIN_ENDPOINTS.BEYS,
131
+ sortOptions: [{ value: sortBy(BEY_FIELDS.NAME, "ASC"), label: "Name A-Z" }],
132
+ columns: COLUMNS,
133
+ buildFilters: () => undefined,
134
+ getTotal: (response, rows) => extractTotal(response) ?? rows.length,
135
+ mapRows: (response) => extractRows(response).map((raw) => {
136
+ const r = raw;
137
+ /*
138
+ * 🛑 `≈` when the mass was SUMMED from the parts rather than stated by
139
+ * the source. 93% of beys have a mass but only 248 state it, and a
140
+ * derived figure shown as an exact one is the easiest way for this
141
+ * dataset to start lying — the same marking the detail page and the OG
142
+ * card use.
143
+ */
144
+ const summed = typeof r.weightGFromParts === "number" ? r.weightGFromParts : null;
145
+ const weight = typeof r.weightG === "number"
146
+ ? `${r.weightG} g`
147
+ : summed !== null
148
+ ? `≈ ${Math.round(summed * 10) / 10} g`
149
+ : "—";
150
+ return {
151
+ id: String(r.id ?? ""),
152
+ primary: String(r.name ?? r.id ?? "Unnamed Beyblade"),
153
+ /*
154
+ * System first: it is the one line a collector uses to place a bey in
155
+ * time, and it is present on 94% of them. The product code follows
156
+ * because two vendor releases of one model differ by little else.
157
+ */
158
+ secondary: [r.system, Array.isArray(r.productCodes) ? r.productCodes[0] : null]
159
+ .filter(Boolean)
160
+ .join(" · ") || "—",
161
+ weight,
162
+ edited: r.adminEdited === true,
163
+ };
164
+ }),
165
+ /*
166
+ * 🛑 `rowHrefTemplate`, not `rowHref`. Verified against the interface
167
+ * rather than guessed — there is no `rowHref` field, and an unknown key on
168
+ * an object literal typed by an interface is a compile error I could not
169
+ * have seen under this session's no-build cap.
170
+ */
171
+ rowHrefTemplate: (row) => String(ROUTES.ADMIN.BEYS_EDIT(row.id)),
172
+ };
173
+ export function AdminBeysView({ children, ...props }) {
174
+ if (React.Children.count(children) > 0) {
175
+ return (_jsxs(ListingLayout, { portal: "admin", ...props, children: [_jsx(Heading, { level: 1, className: "sr-only", children: "Beyblades" }), children] }));
176
+ }
177
+ return (_jsxs(_Fragment, { children: [_jsx(Heading, { level: 1, className: "sr-only", children: "Beyblades" }), _jsx(DataListingView, { config: ADMIN_BEYS_CONFIG })] }));
178
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * AdminPartsView — browse and correct the generated part glossary.
3
+ *
4
+ * ── 🛑 WHY THIS IS A CORRECTION SURFACE, NOT A CRUD SCREEN ────────────────
5
+ *
6
+ * `partGlossary` is 2,488 rows built by committed generators from a crawled
7
+ * corpus. So there is no "Add part" button: a row authored here would have no
8
+ * source, would vanish on the next regeneration, and would make the claim
9
+ * "this dataset rebuilds from `appkit/src/seed/` alone" false. Adding a part is
10
+ * a change to the generator.
11
+ *
12
+ * What this surface IS for is the thing the corpus demonstrably needs — fixing
13
+ * a wrong extraction. On mode data alone the source was wrong five times in one
14
+ * session: an inverted tip transition, a mechanism attributed to the wrong
15
+ * part, a part with no modes at all, and two misexpanded abbreviations. A
16
+ * correction made here survives the next reseed, field by field
17
+ * (`adminEditedFields`).
18
+ *
19
+ * @tag domain:parts
20
+ * @tag layer:feature
21
+ * @tag access:client
22
+ * @tag consumers:app/[locale]/admin/parts/page.tsx
23
+ */
24
+ import React from "react";
25
+ import type { ListingLayoutProps } from "../../../ui";
26
+ export type AdminPartsViewProps = ListingLayoutProps;
27
+ export declare function AdminPartsView({ children, ...props }: AdminPartsViewProps): React.JSX.Element;
@@ -0,0 +1,160 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ /**
4
+ * AdminPartsView — browse and correct the generated part glossary.
5
+ *
6
+ * ── 🛑 WHY THIS IS A CORRECTION SURFACE, NOT A CRUD SCREEN ────────────────
7
+ *
8
+ * `partGlossary` is 2,488 rows built by committed generators from a crawled
9
+ * corpus. So there is no "Add part" button: a row authored here would have no
10
+ * source, would vanish on the next regeneration, and would make the claim
11
+ * "this dataset rebuilds from `appkit/src/seed/` alone" false. Adding a part is
12
+ * a change to the generator.
13
+ *
14
+ * What this surface IS for is the thing the corpus demonstrably needs — fixing
15
+ * a wrong extraction. On mode data alone the source was wrong five times in one
16
+ * session: an inverted tip transition, a mechanism attributed to the wrong
17
+ * part, a part with no modes at all, and two misexpanded abbreviations. A
18
+ * correction made here survives the next reseed, field by field
19
+ * (`adminEditedFields`).
20
+ *
21
+ * @tag domain:parts
22
+ * @tag layer:feature
23
+ * @tag access:client
24
+ * @tag consumers:app/[locale]/admin/parts/page.tsx
25
+ */
26
+ import React from "react";
27
+ import { Heading, ListingLayout, Stack, Text } from "../../../ui/index.js";
28
+ import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints.js";
29
+ import { PART_GLOSSARY_FIELDS } from "../../../constants/field-names.js";
30
+ import { sortBy } from "../../../constants/sort.js";
31
+ import { ROUTES } from "../../../next/routing/route-map.js";
32
+ import { DataListingView } from "./DataListingView.js";
33
+ /**
34
+ * 🛑 Rows are read from WHICHEVER envelope shape arrives.
35
+ *
36
+ * `apiClient.get()` unwraps `{success, data}` and returns `data` itself, so an
37
+ * endpoint that puts rows directly in `data` hands `mapRows` an ARRAY while one
38
+ * that nests them hands it an object. Reading `.data` off an array yields
39
+ * undefined — which is exactly how the categories listing rendered "No
40
+ * categories found" against a 200 carrying 58 rows. This route returns the
41
+ * object shape, and tolerating both is what keeps that from mattering.
42
+ */
43
+ function extractRows(response) {
44
+ if (Array.isArray(response))
45
+ return response;
46
+ if (!response || typeof response !== "object")
47
+ return [];
48
+ const obj = response;
49
+ if (Array.isArray(obj.data))
50
+ return obj.data;
51
+ return Array.isArray(obj.items) ? obj.items : [];
52
+ }
53
+ function extractTotal(response) {
54
+ if (Array.isArray(response) || !response || typeof response !== "object")
55
+ return undefined;
56
+ const total = response.total;
57
+ return typeof total === "number" ? total : undefined;
58
+ }
59
+ /*
60
+ * audit-create-affordance-ok: this listing is deliberately inbound-only.
61
+ * The 2,488 rows are produced by committed generators from a crawled
62
+ * corpus, so there is no POST endpoint and no create route key to point a
63
+ * button at — a part authored by hand would have no source, would vanish
64
+ * on the next regeneration, and would make "this dataset rebuilds from
65
+ * appkit/src/seed/ alone" false. Adding one is a change to the generator.
66
+ * The surface exists to CORRECT a wrong extraction and to REMOVE a row that
67
+ * should not exist, which are the two operations the data actually needs.
68
+ */
69
+ const COLUMNS = [
70
+ {
71
+ key: "primary",
72
+ header: "Part",
73
+ sortable: true,
74
+ render: (row) => (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { weight: "semibold", color: "primary", children: row.primary }), _jsx(Text, { size: "xs", color: "muted", children: row.secondary })] })),
75
+ },
76
+ {
77
+ key: "weight",
78
+ header: "Weight",
79
+ className: "w-24",
80
+ render: (row) => (_jsx(Text, { size: "sm", color: "muted", children: row.weight })),
81
+ },
82
+ {
83
+ key: "edited",
84
+ header: "Corrected",
85
+ className: "w-28",
86
+ /*
87
+ * 🛑 Shown as a COLUMN, because a hand-corrected row behaves differently
88
+ * from its neighbours — the generator no longer owns the fields somebody
89
+ * fixed. Leaving that invisible is how a later maintainer concludes the
90
+ * generator is producing a value it is actually being prevented from
91
+ * writing. Text, not a colour-only dot (colour is never the only signal).
92
+ */
93
+ render: (row) => row.edited ? (_jsx(Text, { size: "xs", weight: "medium", children: "By hand" })) : (_jsx(Text, { size: "xs", color: "faint", children: "Generated" })),
94
+ },
95
+ ];
96
+ const ADMIN_PARTS_CONFIG = {
97
+ portal: "admin",
98
+ title: "Parts",
99
+ subtitle: "The generated part glossary. Corrections made here are kept when the database is regenerated.",
100
+ searchPlaceholder: "Search parts by name — Rubber Flat, Dranzer…",
101
+ /*
102
+ * 🛑 `searchCommit: "enter"`, not debounce. The route's search is an indexed
103
+ * PREFIX range, so a partial word is a different query rather than a
104
+ * narrowing one — debouncing would fire a range query per pause and show
105
+ * confusing intermediate results. Enter also makes the prefix-only behaviour
106
+ * legible: you typed a prefix and asked for it.
107
+ */
108
+ searchCommit: "enter",
109
+ /*
110
+ * 🛑 DECLARED, so the placeholder can be CHECKED rather than trusted.
111
+ * The route matches exactly one field — an indexed prefix range on
112
+ * `name` — and the placeholder must not imply more. It is a prefix and
113
+ * it is case-sensitive; `mode` says so where a reader will see it.
114
+ */
115
+ search: {
116
+ placeholder: "Search parts by name — a prefix, case-sensitive",
117
+ fields: ["name"],
118
+ },
119
+ emptyLabel: "No part matches that name",
120
+ filterKeys: [],
121
+ /*
122
+ * 🛑 Through `sortBy`, not a bare "name". The route currently ALWAYS
123
+ * orders by name and ignores the `sorts` param, so this is the only
124
+ * option — but writing it as a literal is what lets a field rename
125
+ * leave a sort string that silently sorts nothing.
126
+ */
127
+ defaultSort: sortBy(PART_GLOSSARY_FIELDS.NAME, "ASC"),
128
+ pageSize: 25,
129
+ queryKey: ["admin", "parts"],
130
+ endpoint: ADMIN_ENDPOINTS.PARTS,
131
+ sortOptions: [{ value: sortBy(PART_GLOSSARY_FIELDS.NAME, "ASC"), label: "Name A-Z" }],
132
+ columns: COLUMNS,
133
+ buildFilters: () => undefined,
134
+ getTotal: (response, rows) => extractTotal(response) ?? rows.length,
135
+ mapRows: (response) => extractRows(response).map((raw) => {
136
+ const r = raw;
137
+ const weight = typeof r.weightG === "number" ? `${r.weightG} g` : "—";
138
+ return {
139
+ id: String(r.id ?? ""),
140
+ primary: String(r.name ?? r.id ?? "Unnamed part"),
141
+ /* The classification is what disambiguates a bare code like "105". */
142
+ secondary: [r.classification, r.code].filter(Boolean).join(" · ") || "—",
143
+ weight,
144
+ edited: r.adminEdited === true,
145
+ };
146
+ }),
147
+ /*
148
+ * 🛑 `rowHrefTemplate`, not `rowHref`. Verified against the interface
149
+ * rather than guessed — there is no `rowHref` field, and an unknown key on
150
+ * an object literal typed by an interface is a compile error I could not
151
+ * have seen under this session's no-build cap.
152
+ */
153
+ rowHrefTemplate: (row) => String(ROUTES.ADMIN.PARTS_EDIT(row.id)),
154
+ };
155
+ export function AdminPartsView({ children, ...props }) {
156
+ if (React.Children.count(children) > 0) {
157
+ return (_jsxs(ListingLayout, { portal: "admin", ...props, children: [_jsx(Heading, { level: 1, className: "sr-only", children: "Parts" }), children] }));
158
+ }
159
+ return (_jsxs(_Fragment, { children: [_jsx(Heading, { level: 1, className: "sr-only", children: "Parts" }), _jsx(DataListingView, { config: ADMIN_PARTS_CONFIG })] }));
160
+ }
@@ -1,5 +1,5 @@
1
1
  /** Which registry bucket supplies the labels and the confirm copy. */
2
- export type OwnerQuickActionsResource = "PRODUCT" | "CATEGORY" | "BRAND" | "FRANCHISE" | "BUNDLE" | "GROUP" | "SUBLISTING" | "EVENT" | "BLOG" | "REVIEW" | "STORE" | "USER" | "CAROUSEL" | "SECTION";
2
+ export type OwnerQuickActionsResource = "PRODUCT" | "CATEGORY" | "BRAND" | "FRANCHISE" | "PART" | "BEY" | "BUNDLE" | "GROUP" | "SUBLISTING" | "EVENT" | "BLOG" | "REVIEW" | "STORE" | "USER" | "CAROUSEL" | "SECTION";
3
3
  export interface OwnerQuickActionsProps {
4
4
  resource: OwnerQuickActionsResource;
5
5
  /** Where the pencil goes — that record's dashboard editor. */
@@ -24,6 +24,23 @@ export interface OwnerQuickActionsProps {
24
24
  * `detail` sits in a header row beside the title.
25
25
  */
26
26
  placement?: "card" | "detail";
27
+ /**
28
+ * 🛑 Set this whenever `placement="detail"` sits on a DARK cover hero.
29
+ *
30
+ * `detail` otherwise renders the `ghost` variant, which is
31
+ * `color: var(--appkit-color-zinc-600)` — a dark grey that is correct on a
32
+ * page-surface header row and invisible on a cover photo. Every detail view
33
+ * that has a hero already computes a `hasCover` flag and flips each of its
34
+ * siblings to `color="inverse"`; this toolbar was the one element in that
35
+ * block that did not, so the pencil rendered near-black on near-black and
36
+ * read as "admins get no edit button" (Root Cause #67 — a theme-relative ink
37
+ * on a theme-invariant dark surface).
38
+ *
39
+ * Pass the SAME flag the rest of the hero branches on, not a literal `true`:
40
+ * a coverless hero falls back to the page surface, where the scrim's white
41
+ * ink would be invisible in the mirror-image way.
42
+ */
43
+ onMedia?: boolean;
27
44
  className?: string;
28
45
  }
29
- export declare function OwnerQuickActions({ resource, editHref, onDelete, onClone, ownerId, storeId, recordLabel, placement, className, }: OwnerQuickActionsProps): import("react").JSX.Element | null;
46
+ export declare function OwnerQuickActions({ resource, editHref, onDelete, onClone, ownerId, storeId, recordLabel, placement, onMedia, className, }: OwnerQuickActionsProps): import("react").JSX.Element | null;
@@ -57,6 +57,15 @@ import { toUserMessage } from "../../../errors/error-display-map.js";
57
57
  const RESOURCE_LEAVES = {
58
58
  PRODUCT: { bucket: "PRODUCT", edit: "edit", remove: "delete", duplicate: "duplicate" },
59
59
  CATEGORY: { bucket: "CATEGORY", edit: "edit", remove: "delete" },
60
+ /*
61
+ * Both get a bin. Unlike the three pencil-only resources below, the blast
62
+ * radius of removing one reference row is SMALL and bounded: a bey keeps
63
+ * its parts list (`partName` sits beside `partId` for exactly this), a
64
+ * boxed set keeps its contents, and a category page keeps its listings.
65
+ * Nothing cascades, and the committed seed can put the row back.
66
+ */
67
+ PART: { bucket: "PART", edit: "edit", remove: "delete" },
68
+ BEY: { bucket: "BEY", edit: "edit", remove: "delete" },
60
69
  BRAND: { bucket: "BRAND", edit: "edit", remove: "delete" },
61
70
  BUNDLE: { bucket: "BUNDLE", edit: "edit", remove: "delete" },
62
71
  GROUP: { bucket: "GROUP", edit: "edit", remove: "delete" },
@@ -93,7 +102,7 @@ const RESOURCE_LEAVES = {
93
102
  USER: { bucket: "ADMIN", edit: "manage-user" },
94
103
  SECTION: { bucket: "ADMIN", edit: "edit-section" },
95
104
  };
96
- export function OwnerQuickActions({ resource, editHref, onDelete, onClone, ownerId, storeId, recordLabel, placement = "card", className = "", }) {
105
+ export function OwnerQuickActions({ resource, editHref, onDelete, onClone, ownerId, storeId, recordLabel, placement = "card", onMedia = false, className = "", }) {
97
106
  const session = useOptionalSession();
98
107
  const isOwnerOrAdmin = useIsOwner(ownerId);
99
108
  const isAdmin = useIsAdmin();
@@ -164,6 +173,25 @@ export function OwnerQuickActions({ resource, editHref, onDelete, onClone, owner
164
173
  * resource, not to each of its call sites, or the next call site re-opens it.
165
174
  */
166
175
  const showDelete = Boolean(onDelete) && Boolean(leaves.remove);
176
+ /*
177
+ * 🛑 The scrim is for ANY dark backdrop, not only a card.
178
+ *
179
+ * `detail` used to hard-map to `ghost` (`color: var(--appkit-color-zinc-600)`),
180
+ * which is right on a page-surface header row and invisible on a cover photo
181
+ * — and every detail view with a hero puts this toolbar inside that hero,
182
+ * beside siblings it has already flipped to `color="inverse"`. The pencil
183
+ * therefore rendered near-black on near-black and read as "admins get no
184
+ * edit button at all". See `onMedia` above and Root Cause #67.
185
+ */
186
+ const onDark = placement === "card" || onMedia;
187
+ const neutralVariant = onDark ? "scrim" : "ghost";
188
+ /*
189
+ * Danger ink is dropped on a dark backdrop for the same reason: `danger`
190
+ * resolves to a theme-relative red that disappears against the hero. The bin
191
+ * keeps its confirm dialog, which is where the destructive intent is
192
+ * actually communicated — colour was never the safeguard.
193
+ */
194
+ const dangerVariant = onDark ? "scrim" : "danger";
167
195
  return (_jsxs(_Fragment, { children: [_jsxs(Row, { gap: "xs", align: "center", className: className,
168
196
  /*
169
197
  * Stops the click reaching an ancestor `<Link>` or the card's own
@@ -172,7 +200,7 @@ export function OwnerQuickActions({ resource, editHref, onDelete, onClone, owner
172
200
  */
173
201
  onClick: (e) => {
174
202
  e.stopPropagation();
175
- }, children: [_jsx(IconButton, { "aria-label": editDef?.ariaLabel ?? editDef?.label ?? "Edit", size: "touch", variant: placement === "card" ? "scrim" : "ghost", icon: _jsx(Icon, { name: "edit" }), onClick: () => {
203
+ }, children: [_jsx(IconButton, { "aria-label": editDef?.ariaLabel ?? editDef?.label ?? "Edit", size: "touch", variant: neutralVariant, icon: _jsx(Icon, { name: "edit" }), onClick: () => {
176
204
  /*
177
205
  * `window.location`, not `next/link`. This component is rendered
178
206
  * inside cards whose whole body is already an anchor, and nesting
@@ -183,7 +211,7 @@ export function OwnerQuickActions({ resource, editHref, onDelete, onClone, owner
183
211
  if (typeof window !== "undefined")
184
212
  window.location.assign(editHref);
185
213
  } }), showClone && (_jsx(IconButton, { "aria-label": (leaves.duplicate ? bucket[leaves.duplicate]?.ariaLabel ?? bucket[leaves.duplicate]?.label : undefined) ||
186
- "Duplicate", size: "touch", variant: placement === "card" ? "scrim" : "ghost", icon: _jsx(Icon, { name: "duplicate" }), onClick: () => void onClone?.() })), showDelete && (_jsx(IconButton, { "aria-label": deleteDef?.ariaLabel ?? deleteDef?.label ?? "Delete", size: "touch", variant: placement === "card" ? "scrim" : "danger", icon: _jsx(Icon, { name: "delete" }), onClick: () => setConfirmOpen(true) }))] }), showDelete && (_jsx(ConfirmDeleteModal, { isOpen: confirmOpen, onClose: () => setConfirmOpen(false), onConfirm: handleConfirmedDelete, isDeleting: deleting, title: confirmation?.title ?? "Delete this record?", message: recordLabel && confirmation
214
+ "Duplicate", size: "touch", variant: neutralVariant, icon: _jsx(Icon, { name: "duplicate" }), onClick: () => void onClone?.() })), showDelete && (_jsx(IconButton, { "aria-label": deleteDef?.ariaLabel ?? deleteDef?.label ?? "Delete", size: "touch", variant: dangerVariant, icon: _jsx(Icon, { name: "delete" }), onClick: () => setConfirmOpen(true) }))] }), showDelete && (_jsx(ConfirmDeleteModal, { isOpen: confirmOpen, onClose: () => setConfirmOpen(false), onConfirm: handleConfirmedDelete, isDeleting: deleting, title: confirmation?.title ?? "Delete this record?", message: recordLabel && confirmation
187
215
  ? `${confirmation.body}\n\nDeleting: ${recordLabel}`
188
216
  : confirmation?.body, confirmText: confirmation?.confirmLabel ?? deleteDef?.label ?? "Delete", variant: "danger" }))] }));
189
217
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The admin-owned resources this wrapper can delete.
3
+ *
4
+ * 🛑 Deliberately NOT every `OwnerQuickActionsResource`. `STORE`, `USER` and
5
+ * `SECTION` omit a `remove` leaf in `RESOURCE_LEAVES` because their blast
6
+ * radius is far wider than the page a bin would sit on, and `REVIEW` has no
7
+ * admin edit route to send the pencil to. Adding one here without adding its
8
+ * leaf there still renders no bin — the resource owns that decision, which is
9
+ * the point of gating it in two places.
10
+ */
11
+ export type RecordQuickActionResource = "CATEGORY" | "BRAND" | "BUNDLE" | "GROUP" | "EVENT" | "BLOG" | "FRANCHISE" | "SUBLISTING" | "PART" | "BEY";
12
+ export interface RecordOwnerQuickActionsProps {
13
+ resource: RecordQuickActionResource;
14
+ /** The record's document id — what the endpoint and the editor are keyed on. */
15
+ recordId: string;
16
+ /** Names the record in the delete confirmation. */
17
+ recordLabel?: string;
18
+ /**
19
+ * Forwarded to `OwnerQuickActions` so a store-owned record (a grouped
20
+ * listing, a store-authored bundle) stays visible to the seller who owns it
21
+ * and not only to an admin. `SessionUser.storeId` holds the store SLUG, not
22
+ * the Auth UID — two different id spaces, hence two props.
23
+ */
24
+ ownerId?: string;
25
+ storeId?: string;
26
+ placement?: "card" | "detail";
27
+ /** Pass the hero's own `hasCover` flag. See `OwnerQuickActions.onMedia`. */
28
+ onMedia?: boolean;
29
+ className?: string;
30
+ }
31
+ export declare function RecordOwnerQuickActions({ resource, recordId, recordLabel, ownerId, storeId, placement, onMedia, className, }: RecordOwnerQuickActionsProps): import("react").JSX.Element;
@@ -0,0 +1,128 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ /**
4
+ * The client half of `OwnerQuickActions` for every admin-owned collection.
5
+ *
6
+ * ## 🛑 Why a wrapper is REQUIRED, not a convenience
7
+ *
8
+ * `OwnerQuickActions` takes `onDelete` / `onClone` as FUNCTIONS, and every
9
+ * detail view that renders it (`CategoryDetailPageView`, `BrandDetailPageView`,
10
+ * `BundleDetailView`, …) is a Server Component. A closure cannot cross that
11
+ * boundary — React throws
12
+ *
13
+ * Event handlers cannot be passed to Client Component props.
14
+ *
15
+ * at render. `ProductOwnerQuickActions` records that this exact mistake took
16
+ * down every `/products/[slug]` page in production, with an opaque digest per
17
+ * handler.
18
+ *
19
+ * That is why ten detail views were passing `editHref` and nothing else: the
20
+ * bin and the clone are rendered behind `{showDelete && …}` / `{showClone && …}`,
21
+ * so with no handler they silently do not exist. It read as "admins get no
22
+ * delete button", and the cause was a serialization boundary, not a permission.
23
+ *
24
+ * ## 🛑 Why ONE wrapper and not one per resource
25
+ *
26
+ * `ProductOwnerQuickActions` is resource-specific because products are the one
27
+ * collection a SELLER can also own, so its endpoints live under
28
+ * `SELLER_ENDPOINTS` and its clone has a bespoke redirect to the copy's editor.
29
+ * Everything here is admin-owned and differs only by three strings, so eight
30
+ * near-identical files would trip the Duplication Framework's Rule of Three
31
+ * immediately. One table, one component.
32
+ *
33
+ * ## Why the redirect lands on the ADMIN list
34
+ *
35
+ * You deleted the record from its own public page, so that page is now a 404 —
36
+ * staying is not an option and the public index tells you nothing about whether
37
+ * the delete worked. The admin list is where the absence is verifiable.
38
+ * `ProductOwnerQuickActions` differs deliberately: a seller may delete a
39
+ * product and has no admin list to land on.
40
+ *
41
+ * @tag domain:admin
42
+ * @tag layer:components
43
+ * @tag access:client
44
+ */
45
+ import { useCallback } from "react";
46
+ import { OwnerQuickActions } from "./OwnerQuickActions.js";
47
+ import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints.js";
48
+ import { ROUTES } from "../../../next/routing/route-map.js";
49
+ const WIRING = {
50
+ CATEGORY: {
51
+ endpoint: ADMIN_ENDPOINTS.CATEGORY_BY_ID,
52
+ editHref: (id) => String(ROUTES.ADMIN.CATEGORIES_EDIT(id)),
53
+ afterDelete: String(ROUTES.ADMIN.CATEGORIES),
54
+ },
55
+ PART: {
56
+ endpoint: ADMIN_ENDPOINTS.PART_BY_ID,
57
+ editHref: (id) => String(ROUTES.ADMIN.PARTS_EDIT(id)),
58
+ afterDelete: String(ROUTES.ADMIN.PARTS),
59
+ },
60
+ BEY: {
61
+ endpoint: ADMIN_ENDPOINTS.BEY_BY_ID,
62
+ editHref: (id) => String(ROUTES.ADMIN.BEYS_EDIT(id)),
63
+ afterDelete: String(ROUTES.ADMIN.BEYS),
64
+ },
65
+ BRAND: {
66
+ endpoint: ADMIN_ENDPOINTS.BRAND_BY_ID,
67
+ editHref: (id) => String(ROUTES.ADMIN.BRANDS_EDIT(id)),
68
+ afterDelete: String(ROUTES.ADMIN.BRANDS),
69
+ },
70
+ BUNDLE: {
71
+ endpoint: ADMIN_ENDPOINTS.BUNDLE_BY_ID,
72
+ editHref: (id) => String(ROUTES.ADMIN.BUNDLES_EDIT(id)),
73
+ afterDelete: String(ROUTES.ADMIN.BUNDLES),
74
+ },
75
+ GROUP: {
76
+ endpoint: ADMIN_ENDPOINTS.GROUPED_LISTING_BY_ID,
77
+ editHref: (id) => String(ROUTES.ADMIN.GROUPED_LISTINGS_EDIT(id)),
78
+ afterDelete: String(ROUTES.ADMIN.GROUPED_LISTINGS),
79
+ },
80
+ EVENT: {
81
+ endpoint: ADMIN_ENDPOINTS.EVENT_BY_ID,
82
+ editHref: (id) => String(ROUTES.ADMIN.EVENTS_EDIT(id)),
83
+ afterDelete: String(ROUTES.ADMIN.EVENTS),
84
+ },
85
+ BLOG: {
86
+ endpoint: ADMIN_ENDPOINTS.BLOG_BY_ID,
87
+ editHref: (id) => String(ROUTES.ADMIN.BLOG_EDIT(id)),
88
+ afterDelete: String(ROUTES.ADMIN.BLOG),
89
+ },
90
+ /*
91
+ * A franchise row IS a category (`categoryType: "franchise"`), so it shares
92
+ * the category endpoint and editor — the same reasoning `RESOURCE_LEAVES`
93
+ * uses to give it the category copy. Only the landing page differs.
94
+ */
95
+ FRANCHISE: {
96
+ endpoint: ADMIN_ENDPOINTS.CATEGORY_BY_ID,
97
+ editHref: (id) => String(ROUTES.ADMIN.CATEGORIES_EDIT(id)),
98
+ afterDelete: String(ROUTES.ADMIN.CATEGORIES),
99
+ },
100
+ SUBLISTING: {
101
+ endpoint: ADMIN_ENDPOINTS.SUBLISTING_CATEGORY_BY_ID,
102
+ editHref: (id) => String(ROUTES.ADMIN.SUBLISTING_CATEGORIES_EDIT(id)),
103
+ afterDelete: String(ROUTES.ADMIN.SUBLISTING_CATEGORIES),
104
+ },
105
+ };
106
+ export function RecordOwnerQuickActions({ resource, recordId, recordLabel, ownerId, storeId, placement = "detail", onMedia, className, }) {
107
+ const wiring = WIRING[resource];
108
+ /*
109
+ * 🛑 THROWS on a failed DELETE rather than returning quietly.
110
+ *
111
+ * `OwnerQuickActions.handleConfirmedDelete` catches and toasts, and success
112
+ * navigates away — so a swallowed failure is indistinguishable from "it
113
+ * worked but the page did not move", and the obvious user response is to
114
+ * press the button again. Returning early on `!res.ok` is the bug
115
+ * `ProductOwnerQuickActions` documents having had.
116
+ */
117
+ const handleDelete = useCallback(async () => {
118
+ const res = await fetch(wiring.endpoint(recordId), { method: "DELETE" });
119
+ if (!res.ok) {
120
+ const body = (await res.json().catch(() => null));
121
+ throw Object.assign(new Error("Delete failed"), { code: body?.code });
122
+ }
123
+ if (typeof window !== "undefined") {
124
+ window.location.assign(wiring.afterDelete);
125
+ }
126
+ }, [wiring, recordId]);
127
+ return (_jsx(OwnerQuickActions, { resource: resource, placement: placement, onMedia: onMedia, className: className, recordLabel: recordLabel, ownerId: ownerId, storeId: storeId, editHref: wiring.editHref(recordId), onDelete: handleDelete }));
128
+ }