@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,45 @@
1
+ /**
2
+ * ReferenceRecordEditor โ€” correct one row of a GENERATED reference database.
3
+ *
4
+ * โ”€โ”€ ๐Ÿ›‘ ONE COMPONENT, TWO FIELD LISTS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
5
+ *
6
+ * Parts and beys differ only in WHICH fields a human may own. Loading the row,
7
+ * PATCHing it, surfacing a server error on the right field, and telling the
8
+ * operator what a correction means for the next reseed are identical โ€” so they
9
+ * are written once. The Duplication Framework's "same prop surface, same
10
+ * return shape" test points here, and the alternative was two files whose
11
+ * error handling would drift.
12
+ *
13
+ * โ”€โ”€ ๐Ÿ›‘ WHAT THIS DELIBERATELY IS NOT โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
14
+ *
15
+ * It is not a create form. Both collections are generated, so a row authored
16
+ * by hand has no source and vanishes on the next regeneration. There is no
17
+ * code path here that POSTs.
18
+ *
19
+ * It also shows no DERIVED field as editable. A bey's summed mass, its ratings
20
+ * and its coverage records are computed from the composition and each carries a
21
+ * coverage claim; a hand-typed derived value is a number whose stated
22
+ * provenance is no longer true.
23
+ *
24
+ * @tag domain:admin
25
+ * @tag layer:feature
26
+ * @tag access:client
27
+ */
28
+ import React from "react";
29
+ /** One editable field. `kind` picks the input, nothing more. */
30
+ export interface ReferenceEditorField {
31
+ name: string;
32
+ label: string;
33
+ kind?: "text" | "number";
34
+ hint?: string;
35
+ }
36
+ export interface ReferenceRecordEditorProps {
37
+ /** `ADMIN_ENDPOINTS.PART_BY_ID(id)` / `BEY_BY_ID(id)` โ€” already applied. */
38
+ endpoint: string;
39
+ /** What the record is called, for copy. "part" / "Beyblade". */
40
+ noun: string;
41
+ fields: readonly ReferenceEditorField[];
42
+ /** Where the reference page for this record lives, so the operator can check their work. */
43
+ publicHref?: string;
44
+ }
45
+ export declare function ReferenceRecordEditor({ endpoint, noun, fields, publicHref, }: ReferenceRecordEditorProps): React.JSX.Element;
@@ -0,0 +1,153 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * ReferenceRecordEditor โ€” correct one row of a GENERATED reference database.
5
+ *
6
+ * โ”€โ”€ ๐Ÿ›‘ ONE COMPONENT, TWO FIELD LISTS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
7
+ *
8
+ * Parts and beys differ only in WHICH fields a human may own. Loading the row,
9
+ * PATCHing it, surfacing a server error on the right field, and telling the
10
+ * operator what a correction means for the next reseed are identical โ€” so they
11
+ * are written once. The Duplication Framework's "same prop surface, same
12
+ * return shape" test points here, and the alternative was two files whose
13
+ * error handling would drift.
14
+ *
15
+ * โ”€โ”€ ๐Ÿ›‘ WHAT THIS DELIBERATELY IS NOT โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
16
+ *
17
+ * It is not a create form. Both collections are generated, so a row authored
18
+ * by hand has no source and vanishes on the next regeneration. There is no
19
+ * code path here that POSTs.
20
+ *
21
+ * It also shows no DERIVED field as editable. A bey's summed mass, its ratings
22
+ * and its coverage records are computed from the composition and each carries a
23
+ * coverage claim; a hand-typed derived value is a number whose stated
24
+ * provenance is no longer true.
25
+ *
26
+ * @tag domain:admin
27
+ * @tag layer:feature
28
+ * @tag access:client
29
+ */
30
+ import React from "react";
31
+ import { Alert, Button, Form, FieldInput, Stack, Text, TextLink } from "../../../ui/index.js";
32
+ import { apiClient } from "../../../http/index.js";
33
+ import { useToast } from "../../../ui/components/toast-context.js";
34
+ import { normalizeError } from "../../../errors/normalize.js";
35
+ import { toUserMessage } from "../../../errors/error-display-map.js";
36
+ export function ReferenceRecordEditor({ endpoint, noun, fields, publicHref, }) {
37
+ const { showToast } = useToast();
38
+ const [values, setValues] = React.useState({});
39
+ const [owned, setOwned] = React.useState([]);
40
+ const [loading, setLoading] = React.useState(true);
41
+ const [saving, setSaving] = React.useState(false);
42
+ const [loadError, setLoadError] = React.useState(null);
43
+ React.useEffect(() => {
44
+ let cancelled = false;
45
+ (async () => {
46
+ try {
47
+ const row = await apiClient.get(endpoint);
48
+ if (cancelled)
49
+ return;
50
+ const next = {};
51
+ for (const f of fields) {
52
+ const v = row?.[f.name];
53
+ /*
54
+ * ๐Ÿ›‘ An ABSENT field becomes an empty string, not "undefined". Both
55
+ * render, and only one of them is honest โ€” and on save an empty
56
+ * string is dropped rather than written, so an untouched empty field
57
+ * cannot blank a value the generator owns.
58
+ */
59
+ next[f.name] = v === null || v === undefined ? "" : String(v);
60
+ }
61
+ setValues(next);
62
+ setOwned(Array.isArray(row?.adminEditedFields) ? row.adminEditedFields : []);
63
+ }
64
+ catch (err) {
65
+ const e = normalizeError(err);
66
+ if (!cancelled)
67
+ setLoadError(e.message);
68
+ }
69
+ finally {
70
+ if (!cancelled)
71
+ setLoading(false);
72
+ }
73
+ })();
74
+ return () => {
75
+ cancelled = true;
76
+ };
77
+ }, [endpoint, fields]);
78
+ /**
79
+ * Build the PATCH body.
80
+ *
81
+ * ๐Ÿ›‘ Only NON-EMPTY values are sent, and numbers are coerced.
82
+ *
83
+ * An empty input means "I did not set this", not "write an empty string".
84
+ * Sending it would both blank a real value AND mark the field as
85
+ * admin-owned, freezing the blank in place against every future reseed โ€”
86
+ * a one-click way to permanently delete data from a generated row.
87
+ */
88
+ const buildPatch = React.useCallback(() => {
89
+ const patch = {};
90
+ for (const f of fields) {
91
+ const raw = (values[f.name] ?? "").trim();
92
+ if (!raw)
93
+ continue;
94
+ patch[f.name] = f.kind === "number" ? Number(raw) : raw;
95
+ }
96
+ return patch;
97
+ }, [fields, values]);
98
+ /**
99
+ * Put a failure where the operator can act on it.
100
+ *
101
+ * ๐Ÿ›‘ Zod issues go to their OWN FIELD; everything else becomes one toast
102
+ * built by `toUserMessage`. Never the server's own message: a raw fallback
103
+ * is what rendered a Node MODULE_NOT_FOUND with a full require stack inside
104
+ * a bid modal (Root Cause #86), and `mapToHttpError` scrubs 5xx text in
105
+ * production anyway.
106
+ */
107
+ const reportFailure = React.useCallback((err,
108
+ /** Already normalized by the caller โ€” see the note at the catch site. */
109
+ fallbackMessage, setFieldError) => {
110
+ const issues = err?.issues;
111
+ if (issues?.length) {
112
+ for (const issue of issues)
113
+ setFieldError(String(issue.path?.[0] ?? ""), issue.message);
114
+ return;
115
+ }
116
+ const code = err?.code;
117
+ const message = code ? toUserMessage(code, undefined) : fallbackMessage;
118
+ showToast(message, "error");
119
+ }, [showToast]);
120
+ const save = React.useCallback(async (setFieldError, clearErrors) => {
121
+ clearErrors();
122
+ setSaving(true);
123
+ try {
124
+ await apiClient.patch(endpoint, buildPatch());
125
+ showToast(`${noun[0].toUpperCase()}${noun.slice(1)} updated.`, "success");
126
+ }
127
+ catch (err) {
128
+ const e = normalizeError(err);
129
+ reportFailure(err, e.message, setFieldError);
130
+ }
131
+ finally {
132
+ setSaving(false);
133
+ }
134
+ }, [endpoint, buildPatch, noun, showToast, reportFailure]);
135
+ if (loading)
136
+ return _jsx(Text, { color: "muted", children: "Loading\u2026" });
137
+ if (loadError) {
138
+ return (_jsx(Alert, { variant: "error", title: `Could not load this ${noun}`, children: _jsx(Text, { size: "sm", children: loadError }) }));
139
+ }
140
+ /*
141
+ * ๐Ÿ›‘ No `className` on `<Form>` โ€” the `<Stack gap="md">` inside already owns
142
+ * the spacing, so `space-y-4` was a second, competing source of it
143
+ * (audit-variant-prop-coverage: a layout token belongs in a variant prop).
144
+ *
145
+ * ๐Ÿ›‘ And this is a PLAIN comment, above the `return`, not a `{/* โ€ฆ *โฃ/}` JSX
146
+ * one below it. I wrote it as JSX first and it broke the parse: a JSX comment
147
+ * between `return (` and the root element is a second expression, so the
148
+ * return has two children and no wrapper. `tsc` caught it โ€” the first
149
+ * typecheck this session was allowed to run โ€” which is exactly the class of
150
+ * error the 4 GB no-build cap was hiding.
151
+ */
152
+ return (_jsx(Form, { children: ({ setFieldError, clearErrors }) => (_jsxs(Stack, { gap: "md", className: "min-w-0", children: [_jsxs(Alert, { variant: "info", title: "This record is generated", children: [_jsxs(Text, { size: "sm", children: [`This ${noun} is built from a crawled source by a committed generator. A field you `, "change here is marked as yours and will not be overwritten the next time the database is regenerated \u2014 every field you leave alone keeps getting the generator's improvements."] }), owned.length > 0 && (_jsx(Text, { size: "sm", color: "muted", children: `Already corrected by hand: ${owned.join(", ")}` }))] }), fields.map((f) => (_jsx(FieldInput, { name: f.name, label: f.label, type: f.kind === "number" ? "number" : "text", hint: f.hint, value: values[f.name] ?? "", onChange: (v) => setValues((p) => ({ ...p, [f.name]: v })) }, f.name))), _jsx(Button, { type: "button", isLoading: saving, onClick: () => save(setFieldError, clearErrors), children: "Save correction" }), publicHref && (_jsx(Text, { size: "sm", color: "muted", children: _jsx(TextLink, { href: publicHref, children: "View the public reference page" }) }))] })) }));
153
+ }
@@ -73,7 +73,7 @@ export type NotificationUpdateInput = Partial<Pick<NotificationDocument, "isRead
73
73
  export declare const notificationQueryHelpers: {
74
74
  readonly byUser: (userId: string) => readonly ["userId", "==", string];
75
75
  readonly unread: (userId: string) => readonly [readonly ["userId", "==", string], readonly ["isRead", "==", false]];
76
- readonly byType: (type: NotificationType) => readonly ["type", "==", "welcome" | "promotion" | "order_placed" | "order_confirmed" | "order_shipped" | "order_delivered" | "order_cancelled" | "bid_placed" | "bid_outbid" | "bid_won" | "bid_lost" | "auction_ended" | "review_approved" | "review_replied" | "product_available" | "system" | "account_action" | "support_ticket_update" | "scam_report_update" | "offer_received" | "offer_responded" | "offer_expired" | "offer_counter_accepted" | "refund_initiated" | "prize_won" | "prize_reveal_expired" | "emi_installment_due_soon" | "emi_installment_overdue" | "payment_review" | "catalogue_images_stale" | "message_received"];
76
+ readonly byType: (type: NotificationType) => readonly ["type", "==", "system" | "welcome" | "promotion" | "order_placed" | "order_confirmed" | "order_shipped" | "order_delivered" | "order_cancelled" | "bid_placed" | "bid_outbid" | "bid_won" | "bid_lost" | "auction_ended" | "review_approved" | "review_replied" | "product_available" | "account_action" | "support_ticket_update" | "scam_report_update" | "offer_received" | "offer_responded" | "offer_expired" | "offer_counter_accepted" | "refund_initiated" | "prize_won" | "prize_reveal_expired" | "emi_installment_due_soon" | "emi_installment_overdue" | "payment_review" | "catalogue_images_stale" | "message_received"];
77
77
  };
78
78
  export interface TrustBarItem {
79
79
  icon: string;
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { ProductOwnerQuickActions } from "../../products/components/ProductOwnerQuickActions.js";
3
4
  import { normalizeError } from "../../../errors/normalize.js";
4
5
  import { useCallback, useEffect, useRef, useState } from "react";
5
6
  import { useRouter } from "next/navigation.js";
@@ -181,11 +182,23 @@ export function MarketplaceAuctionCard({ product, buyoutPrice, className = "", v
181
182
  countdownClass, mergedLabels, handleWishlist, handleNavigate,
182
183
  };
183
184
  const card = (_jsxs(BaseListingCard, { isSelected: isSelected, isDisabled: isEnded, variant: baseVariant, className: className, onMouseDown: !isSelected ? longPress.onMouseDown : undefined, onMouseUp: !isSelected ? longPress.onMouseUp : undefined, onMouseLeave: !isSelected ? longPress.onMouseLeave : undefined, onTouchStart: !isSelected ? longPress.onTouchStart : undefined, onTouchEnd: !isSelected ? longPress.onTouchEnd : undefined, onTouchCancel: !isSelected ? longPress.onTouchCancel : undefined, children: [renderAuctionCardHero({ product, auctionHref, currentSrc, hovered, images, imageIndex, hasVideo, isEnded, isEndingSoon, isSelected, selectable, mergedLabels, onSelect, setHovered, handleSelect }), _jsx(BaseListingCard.Info, { variant: baseVariant, children: baseVariant === "list" ? renderAuctionCardInfoList(cardInfoProps) : renderAuctionCardInfoGrid(cardInfoProps) })] }));
184
- if (!renderOwnerActions)
185
+ /*
186
+ * ๐Ÿ›‘ Default, not opt-in. Nothing in either repo ever passed
187
+ * `renderOwnerActions`, so this toolbar was unreachable on every grid that
188
+ * mounts this card (Root Cause #8 โ€” a slot shell rendered with zero render
189
+ * props). A card is resource-specific by construction, so it names its own
190
+ * resource rather than being threaded one; the prop stays as an override.
191
+ */
192
+ const ownerActions = renderOwnerActions
193
+ ? renderOwnerActions()
194
+ : product.id
195
+ ? (_jsx(ProductOwnerQuickActions, { productId: String(product.id), storeId: product.storeId ? String(product.storeId) : undefined, recordLabel: product.title, placement: "card" }))
196
+ : null;
197
+ if (!ownerActions)
185
198
  return card;
186
199
  /*
187
200
  * Sibling of the card, never a child โ€” see the prop's docstring. `z-20`
188
201
  * clears this card's own overlays, which all sit at `z-10`.
189
202
  */
190
- return (_jsxs(Div, { className: "relative h-full", children: [card, _jsx(Div, { className: "absolute right-2 bottom-2 z-20", children: renderOwnerActions() })] }));
203
+ return (_jsxs(Div, { className: "relative h-full", children: [card, _jsx(Div, { className: "absolute right-2 bottom-2 z-20", children: ownerActions })] }));
191
204
  }
@@ -11,7 +11,7 @@
11
11
  * Permission naming: admin:resource:action
12
12
  * Ban action naming: plain verb phrase โ€” write_reviews, place_bids, etc.
13
13
  */
14
- export type Permission = "admin:dashboard:view" | "admin:users:read" | "admin:users:write" | "admin:users:delete" | "admin:user-bans:read" | "admin:user-bans:write" | "admin:products:read" | "admin:products:write" | "admin:products:delete" | "admin:orders:read" | "admin:orders:write" | "admin:returns:read" | "admin:returns:write" | "admin:stores:read" | "admin:stores:write" | "admin:store-addresses:read" | "admin:addresses:read" | "admin:addresses:write" | "admin:analytics:view" | "admin:payouts:read" | "admin:payouts:write" | "admin:audit-log:read" | "admin:categories:read" | "admin:categories:write" | "admin:categories:delete" | "admin:brands:read" | "admin:brands:write" | "admin:brands:delete" | "admin:coupons:read" | "admin:coupons:write" | "admin:coupons:delete" | "admin:deals:read" | "admin:deals:write" | "admin:featured:read" | "admin:featured:write" | "admin:reviews:read" | "admin:reviews:write" | "admin:reviews:delete" | "admin:blog:read" | "admin:blog:write" | "admin:blog:delete" | "admin:blog:publish" | "admin:bids:read" | "admin:bids:write" | "admin:offers:read" | "admin:offers:write" | "admin:media:read" | "admin:media:write" | "admin:media:delete" | "admin:site:read" | "admin:site:write" | "admin:settings:write" | "admin:navigation:read" | "admin:navigation:write" | "admin:sections:read" | "admin:sections:write" | "admin:carousel:read" | "admin:carousel:write" | "admin:carousel:delete" | "admin:ads:read" | "admin:ads:write" | "admin:ads:delete" | "admin:faqs:read" | "admin:faqs:write" | "admin:faqs:delete" | "admin:tester-checklist:read" | "admin:tester-checklist:write" | "admin:tester-checklist:delete" | "admin:tester-feedback:read" | "admin:tester-feedback:write" | "admin:newsletter:read" | "admin:newsletter:write" | "admin:contact:read"
14
+ export type Permission = "admin:dashboard:view" | "admin:users:read" | "admin:users:write" | "admin:users:delete" | "admin:user-bans:read" | "admin:user-bans:write" | "admin:products:read" | "admin:products:write" | "admin:products:delete" | "admin:orders:read" | "admin:orders:write" | "admin:returns:read" | "admin:returns:write" | "admin:stores:read" | "admin:stores:write" | "admin:store-addresses:read" | "admin:addresses:read" | "admin:addresses:write" | "admin:analytics:view" | "admin:payouts:read" | "admin:payouts:write" | "admin:audit-log:read" | "admin:categories:read" | "admin:categories:write" | "admin:categories:delete" | "admin:parts:read" | "admin:parts:write" | "admin:parts:delete" | "admin:beys:read" | "admin:beys:write" | "admin:beys:delete" | "admin:brands:read" | "admin:brands:write" | "admin:brands:delete" | "admin:coupons:read" | "admin:coupons:write" | "admin:coupons:delete" | "admin:deals:read" | "admin:deals:write" | "admin:featured:read" | "admin:featured:write" | "admin:reviews:read" | "admin:reviews:write" | "admin:reviews:delete" | "admin:blog:read" | "admin:blog:write" | "admin:blog:delete" | "admin:blog:publish" | "admin:bids:read" | "admin:bids:write" | "admin:offers:read" | "admin:offers:write" | "admin:media:read" | "admin:media:write" | "admin:media:delete" | "admin:site:read" | "admin:site:write" | "admin:settings:write" | "admin:navigation:read" | "admin:navigation:write" | "admin:sections:read" | "admin:sections:write" | "admin:carousel:read" | "admin:carousel:write" | "admin:carousel:delete" | "admin:ads:read" | "admin:ads:write" | "admin:ads:delete" | "admin:faqs:read" | "admin:faqs:write" | "admin:faqs:delete" | "admin:tester-checklist:read" | "admin:tester-checklist:write" | "admin:tester-checklist:delete" | "admin:tester-feedback:read" | "admin:tester-feedback:write" | "admin:newsletter:read" | "admin:newsletter:write" | "admin:contact:read"
15
15
  /** Destructive/mutating contact-submission actions. Added 2026-08-29 โ€”
16
16
  * `DELETE /api/admin/contact-submissions/[id]` declared `admin:contact:delete`,
17
17
  * which is not a member of this union and therefore could never be granted. */
@@ -131,6 +131,12 @@ export const PERMISSION_GROUPS = {
131
131
  "admin:categories:read",
132
132
  "admin:categories:write",
133
133
  "admin:categories:delete",
134
+ "admin:parts:read",
135
+ "admin:parts:write",
136
+ "admin:parts:delete",
137
+ "admin:beys:read",
138
+ "admin:beys:write",
139
+ "admin:beys:delete",
134
140
  "admin:brands:read",
135
141
  "admin:brands:write",
136
142
  "admin:brands:delete",
@@ -281,6 +287,8 @@ export const ROUTE_PERMISSION_MAP = {
281
287
  "/admin/catalogue-approvals": "admin:catalogue:read",
282
288
  "/admin/grouped-listings": "admin:grouped-listings:read",
283
289
  "/admin/categories": "admin:categories:read",
290
+ "/admin/parts": "admin:parts:read",
291
+ "/admin/beys": "admin:beys:read",
284
292
  "/admin/brands": "admin:brands:read",
285
293
  "/admin/coupons": "admin:coupons:read",
286
294
  "/admin/deals": "admin:deals:read",
@@ -437,7 +445,10 @@ export const PERMISSION_DOMAINS = [
437
445
  { label: "Orders & Returns", prefix: "admin:orders:|admin:returns:|admin:offers:" },
438
446
  { label: "Stores", prefix: "admin:stores:|admin:store-addresses:" },
439
447
  { label: "Finance", prefix: "admin:analytics:|admin:payouts:" },
440
- { label: "Catalog", prefix: "admin:categories:|admin:brands:|admin:coupons:|admin:deals:|admin:featured:" },
448
+ {
449
+ label: "Catalog",
450
+ prefix: "admin:categories:|admin:parts:|admin:beys:|admin:brands:|admin:coupons:|admin:deals:|admin:featured:",
451
+ },
441
452
  {
442
453
  label: "Content",
443
454
  prefix: "admin:reviews:|admin:blog:|admin:bids:|admin:media:",
@@ -0,0 +1,50 @@
1
+ /**
2
+ * One bey's public page โ€” what it is, what it is built from, who used it.
3
+ *
4
+ * โ”€โ”€ ๐Ÿ›‘ NO MONEY ON THIS PAGE, DELIBERATELY โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
5
+ *
6
+ * Prices are hidden from signed-out visitors, so any amount here would need
7
+ * `<GatedPrice>` and make this a client component. Listing rows carry a title,
8
+ * a condition and a link; the price lives one click away on the listing, where
9
+ * the gate already is. Same rule as `PartDetailPageView` and `/search`: a row
10
+ * with nothing to gate has nothing to leak, and no `offers.price` is emitted
11
+ * so no paywall declaration is needed either.
12
+ *
13
+ * ๐Ÿ›‘ `price` IS on the document and is deliberately not rendered. It is the
14
+ * source's original retail price (`1404ยฅ`), not our listing price โ€” showing it
15
+ * beside real listings would read as an offer.
16
+ *
17
+ * โ”€โ”€ ๐Ÿ›‘ THE SOURCE LINK IS NOT OPTIONAL โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
18
+ *
19
+ * The facts here are extracted from a CC-BY-SA corpus. Taking facts only and
20
+ * linking back is what keeps us clear of share-alike, and `sourceUrl` was
21
+ * stored on ~5,500 rows while being rendered on NONE of them. Storing it is
22
+ * not discharging the obligation.
23
+ */
24
+ import React from "react";
25
+ import type { BeyDocument, BeyProductDocument } from "../schemas/firestore";
26
+ /** One live listing row. Deliberately money-free โ€” see the header. */
27
+ export interface BeyListingRow {
28
+ id: string;
29
+ title: string;
30
+ slug?: string;
31
+ condition?: string;
32
+ href: string;
33
+ }
34
+ export interface BeyDetailPageViewProps {
35
+ bey: BeyDocument;
36
+ /** Products whose assortment contains this bey. */
37
+ products: readonly BeyProductDocument[];
38
+ /** Live listings, from the shared public product query. */
39
+ listings: readonly BeyListingRow[];
40
+ /** `total` is a FLOOR when the bounded fetch saturated. */
41
+ listingsTruncated?: boolean;
42
+ allListingsHref: string;
43
+ /** A part's own page, when the composition entry resolved to a glossary id. */
44
+ hrefForPart: (partId: string) => string | null;
45
+ /** This bey's category leaf, when one exists (116 beys have no leaf). */
46
+ categoryHref?: string | null;
47
+ /** A character's page, for a resolved owner. */
48
+ hrefForCharacter?: (slug: string) => string;
49
+ }
50
+ export declare function BeyDetailPageView({ bey, products, listings, listingsTruncated, allListingsHref, hrefForPart, categoryHref, hrefForCharacter, }: BeyDetailPageViewProps): React.JSX.Element;
@@ -0,0 +1,98 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Section, Container, Stack, Row, Div, Heading, Text, Span, TextLink, Anchor, Badge, } from "../../../ui/index.js";
3
+ import { SpecTable } from "../../../ui/components/SpecTable.js";
4
+ import { ROUTES } from "../../../next/routing/route-map.js";
5
+ import { PART_SLOT_LABEL } from "../../parts/schemas/firestore.js";
6
+ import { RecordOwnerQuickActions } from "../../admin/components/RecordOwnerQuickActions.js";
7
+ /**
8
+ * The rating axes, in a fixed order.
9
+ *
10
+ * ๐Ÿ›‘ ORDERED EXPLICITLY, never by `Object.keys`. Firestore returns map fields
11
+ * alphabetically, which would put "burst" and "dash" above "attack" and
12
+ * reorder the table per bey depending on which axes it happens to carry โ€” so
13
+ * two beys could not be compared row against row.
14
+ */
15
+ const RATING_AXES = [
16
+ { key: "attack", label: "Attack" },
17
+ { key: "defense", label: "Defense" },
18
+ { key: "stamina", label: "Stamina" },
19
+ { key: "weight", label: "Weight" },
20
+ { key: "speed", label: "Speed" },
21
+ { key: "burst", label: "Burst" },
22
+ { key: "burstResistance", label: "Burst resistance" },
23
+ { key: "dash", label: "Dash" },
24
+ { key: "power", label: "Power" },
25
+ { key: "height", label: "Height" },
26
+ { key: "runTime", label: "Run time" },
27
+ ];
28
+ export function BeyDetailPageView({ bey, products, listings, listingsTruncated, allListingsHref, hrefForPart, categoryHref, hrefForCharacter, }) {
29
+ /*
30
+ * โ”€โ”€ THE SPEC TABLE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
31
+ *
32
+ * Only what the source states. ๐Ÿ›‘ A row is omitted when the fact is absent
33
+ * rather than rendered with an em-dash: "Element โ€”" would claim we looked it
34
+ * up and found none, and `element` is on 45 of 1,988 beys precisely because
35
+ * it is a Beyblade-X-only attribute, not missing data.
36
+ */
37
+ const specRows = [
38
+ ...(bey.type ? [{ name: "Type", value: bey.type }] : []),
39
+ ...(bey.spinDirection ? [{ name: "Spin", value: bey.spinDirection }] : []),
40
+ ...(bey.system ? [{ name: "System", value: bey.system }] : []),
41
+ ...(bey.series ? [{ name: "Series", value: bey.series }] : []),
42
+ ...(bey.element ? [{ name: "Element", value: bey.element }] : []),
43
+ /*
44
+ * ๐Ÿ›‘ MASS: the stated figure wins, and a SUMMED one is labelled as summed
45
+ * and marked approximate when the parts are only partly weighed. Printing
46
+ * a partial sum as a plain total is the one thing this must not do โ€” the
47
+ * generator deliberately refuses to scale it up for the same reason.
48
+ */
49
+ ...(typeof bey.weightG === "number"
50
+ ? [{ name: "Weight", value: String(bey.weightG), unit: "g" }]
51
+ : typeof bey.weightGFromParts === "number"
52
+ ? [
53
+ {
54
+ name: "Weight",
55
+ value: String(bey.weightGFromParts),
56
+ unit: bey.weightGFromPartsCoverage?.complete
57
+ ? "g (summed from parts)"
58
+ : `g (from ${bey.weightGFromPartsCoverage?.weighed ?? 0} of ` +
59
+ `${bey.weightGFromPartsCoverage?.total ?? 0} parts)`,
60
+ approximate: !bey.weightGFromPartsCoverage?.complete,
61
+ },
62
+ ]
63
+ : []),
64
+ ...(bey.productCodes?.length
65
+ ? [{ name: bey.productCodes.length > 1 ? "Product codes" : "Product code", value: bey.productCodes.join(" ยท ") }]
66
+ : []),
67
+ ...(bey.releaseJP ? [{ name: "Released (JP)", value: bey.releaseJP }] : []),
68
+ ...(bey.releaseUS ? [{ name: "Released (US)", value: bey.releaseUS }] : []),
69
+ ...(bey.jpName ? [{ name: "Japanese name", value: bey.jpName }] : []),
70
+ ...(bey.romaji ? [{ name: "Romaji", value: bey.romaji }] : []),
71
+ ...(bey.creator ? [{ name: "Creator", value: bey.creator }] : []),
72
+ ];
73
+ const ratings = bey.ratingsFromParts ?? null;
74
+ const ratingRows = ratings
75
+ ? RATING_AXES.map((a) => ({ ...a, value: ratings[a.key] })).filter((r) => typeof r.value === "number")
76
+ : [];
77
+ const composition = bey.composition ?? [];
78
+ const aliases = (bey.aka ?? []).filter((a) => a && a !== bey.name);
79
+ return (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "lg", children: _jsxs(Stack, { gap: "lg", className: "min-w-0", children: [_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [categoryHref && (_jsx(Text, { size: "sm", color: "muted", children: _jsx(TextLink, { href: categoryHref, children: "Listings for this model" }) })), _jsx(Heading, { level: 1, children: bey.name }), _jsx(RecordOwnerQuickActions, { resource: "BEY", recordId: bey.id, placement: "detail", recordLabel: bey.name }), _jsxs(Row, { gap: "xs", wrap: true, align: "center", className: "min-w-0", children: [bey.type && _jsx(Badge, { variant: "info", children: bey.type }), bey.spinDirection && _jsx(Badge, { variant: "default", children: bey.spinDirection }), bey.system && _jsx(Badge, { variant: "secondary", children: bey.system })] }), aliases.length > 0 && (_jsxs(Text, { size: "sm", color: "muted", children: ["Also known as: ", aliases.join(" ยท ")] }))] }), composition.length > 0 && (_jsxs(Stack, { gap: "sm", className: "min-w-0", children: [_jsx(Heading, { level: 2, size: "lg", children: "Parts" }), _jsx(Stack, { gap: "xs", className: "min-w-0", children: composition.map((c, i) => {
80
+ const href = c.partId ? hrefForPart(c.partId) : null;
81
+ const label = PART_SLOT_LABEL[c.slot] ?? c.slot;
82
+ return (_jsxs(Row, { gap: "md", align: "center", className: "min-w-0", children: [_jsx(Text, { size: "sm", color: "muted", className: "w-36 flex-shrink-0", children: label }), href ? (_jsx(TextLink, { href: href, children: c.partName })) : (_jsx(Span, { children: c.partName }))] }, `${c.slot}-${c.partName}-${i}`));
83
+ }) })] })), ratingRows.length > 0 && (_jsxs(Stack, { gap: "sm", className: "min-w-0", children: [_jsx(Heading, { level: 2, size: "lg", children: "Ratings" }), _jsx(SpecTable, { rows: ratingRows.map((r) => ({ name: r.label, value: String(r.value) })) }), _jsxs(Text, { size: "xs", color: "faint", children: ["Averaged from this bey\u2019s parts and rescaled to 0\u2013100 for comparison across generations", bey.ratingsFromPartsCoverage
84
+ ? `; from ${bey.ratingsFromPartsCoverage.rated ?? 0} of ` +
85
+ `${bey.ratingsFromPartsCoverage.total} parts`
86
+ : "", "."] })] })), bey.modes?.length ? (_jsxs(Stack, { gap: "sm", className: "min-w-0", children: [_jsx(Heading, { level: 2, size: "lg", children: "Modes" }), _jsx(Stack, { gap: "md", className: "min-w-0", children: bey.modes.map((mode) => (_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [_jsxs(Row, { gap: "sm", align: "center", wrap: true, className: "min-w-0", children: [_jsx(Text, { weight: "semibold", children: mode.name }), _jsx(Text, { size: "xs", color: "muted", children: mode.switchKind === "speed" || mode.switchKind === "impact"
87
+ ? "changes on its own"
88
+ : "set before launch" })] }), _jsx(Text, { size: "sm", children: mode.behaviour }), _jsx(Text, { size: "sm", color: "muted", children: mode.trigger })] }, mode.name))) })] })) : null, (bey.ownerNames?.length || bey.specialMoves?.length || bey.appearAnime) && (_jsxs(Stack, { gap: "sm", className: "min-w-0", children: [_jsx(Heading, { level: 2, size: "lg", children: "In the anime" }), _jsx(SpecTable, { rows: [
89
+ ...(bey.ownerNames?.length
90
+ ? [{ name: "Used by", value: bey.ownerNames.join(" ยท ") }]
91
+ : []),
92
+ ...(bey.appearAnime ? [{ name: "First appearance", value: bey.appearAnime }] : []),
93
+ ...(bey.appearManga ? [{ name: "In the manga", value: bey.appearManga }] : []),
94
+ ...(bey.specialMoves?.length
95
+ ? [{ name: "Special moves", value: bey.specialMoves.join(" ยท ") }]
96
+ : []),
97
+ ] }), hrefForCharacter && (bey.ownerCharacters?.length ?? 0) > 0 && (_jsx(Row, { gap: "xs", wrap: true, align: "center", className: "min-w-0", children: bey.ownerCharacters.map((slug) => (_jsx(TextLink, { href: hrefForCharacter(slug), children: slug.replace(/-/g, " ") }, slug))) }))] })), specRows.length > 0 && (_jsxs(Stack, { gap: "sm", className: "min-w-0", children: [_jsx(Heading, { level: 2, size: "lg", children: "Specifications" }), _jsx(SpecTable, { rows: specRows })] })), products.length > 0 && (_jsxs(Stack, { gap: "sm", className: "min-w-0", children: [_jsx(Heading, { level: 2, size: "lg", children: "Found in" }), _jsx(Stack, { gap: "xs", className: "min-w-0", children: products.map((p) => (_jsxs(Row, { gap: "md", align: "center", className: "min-w-0", children: [_jsx(Span, { children: p.name }), p.productType && (_jsx(Text, { size: "xs", color: "muted", children: p.productType }))] }, p.id))) })] })), _jsxs(Stack, { gap: "sm", className: "min-w-0", children: [_jsx(Heading, { level: 2, size: "lg", children: listings.length > 0 ? "Currently listed" : "Listings" }), listings.length === 0 ? (_jsx(Text, { color: "muted", size: "sm", children: "Nothing matching this model is listed right now." })) : (_jsx(Stack, { gap: "xs", className: "min-w-0", children: listings.map((l) => (_jsxs(Row, { gap: "md", align: "center", className: "min-w-0", children: [_jsx(TextLink, { href: l.href, children: l.title }), l.condition && (_jsx(Text, { size: "xs", color: "muted", children: l.condition }))] }, l.id))) })), _jsx(Text, { size: "sm", children: _jsx(TextLink, { href: allListingsHref, children: listingsTruncated ? "See all listings" : "Search listings for this model" }) })] }), _jsx(Div, { children: _jsxs(Text, { size: "xs", color: "faint", children: ["Facts on this page are drawn from a community wiki under CC\u00A0BY-SA.", " ", _jsx(Anchor, { href: bey.sourceUrl, tone: "muted", underline: "always", children: "Source" }), bey.wboThreadUrl ? (_jsxs(_Fragment, { children: [" ยท ", _jsx(Anchor, { href: bey.wboThreadUrl, tone: "muted", underline: "always", children: "Community discussion" })] })) : null, " ยท ", _jsx(TextLink, { href: String(ROUTES.PUBLIC.CREDITS), children: "Credits" })] }) })] }) }) }));
98
+ }