@mohasinac/appkit 4.56.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.
- package/dist/.build-stamp.json +4 -4
- package/dist/_internal/server/features/beys/data.d.ts +44 -0
- package/dist/_internal/server/features/beys/data.js +107 -0
- package/dist/_internal/server/features/beys/og.d.ts +42 -0
- package/dist/_internal/server/features/beys/og.js +89 -0
- package/dist/_internal/server/features/checkout/actions.d.ts +31 -0
- package/dist/_internal/server/features/checkout/actions.js +117 -5
- package/dist/_internal/server/features/orders/actions.d.ts +30 -0
- package/dist/_internal/server/features/orders/actions.js +103 -0
- package/dist/_internal/server/features/orders/adapters.js +7 -0
- package/dist/_internal/server/features/parts/data.js +3 -2
- package/dist/_internal/shared/actions/action-registry.d.ts +1 -1
- package/dist/_internal/shared/actions/action-registry.js +139 -0
- package/dist/_internal/shared/shipping/quote.js +5 -6
- package/dist/client.d.ts +23 -0
- package/dist/client.js +3 -0
- package/dist/constants/api-endpoints.d.ts +12 -0
- package/dist/constants/api-endpoints.js +10 -0
- package/dist/constants/field-names.d.ts +46 -0
- package/dist/constants/field-names.js +46 -0
- package/dist/features/about/components/PublicProfileView.js +2 -1
- package/dist/features/account/components/UserOrdersView.js +25 -3
- package/dist/features/admin/components/AdminBeysView.d.ts +27 -0
- package/dist/features/admin/components/AdminBeysView.js +178 -0
- package/dist/features/admin/components/AdminPartsView.d.ts +27 -0
- package/dist/features/admin/components/AdminPartsView.js +160 -0
- package/dist/features/admin/components/OwnerQuickActions.d.ts +19 -2
- package/dist/features/admin/components/OwnerQuickActions.js +83 -8
- package/dist/features/admin/components/RecordOwnerQuickActions.d.ts +31 -0
- package/dist/features/admin/components/RecordOwnerQuickActions.js +128 -0
- package/dist/features/admin/components/ReferenceRecordEditor.d.ts +45 -0
- package/dist/features/admin/components/ReferenceRecordEditor.js +153 -0
- package/dist/features/admin/schemas/firestore.d.ts +1 -1
- package/dist/features/auctions/components/MarketplaceAuctionCard.js +15 -2
- package/dist/features/auth/permissions/constants.d.ts +1 -1
- package/dist/features/auth/permissions/constants.js +12 -1
- package/dist/features/beys/components/BeyDetailPageView.d.ts +50 -0
- package/dist/features/beys/components/BeyDetailPageView.js +98 -0
- package/dist/features/beys/schemas/firestore.d.ts +417 -0
- package/dist/features/beys/schemas/firestore.js +65 -0
- package/dist/features/blog/components/BlogFeaturedCard.js +15 -2
- package/dist/features/blog/components/BlogPostView.js +2 -1
- package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
- package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
- package/dist/features/categories/components/BrandDetailPageView.js +4 -2
- package/dist/features/categories/components/BundleDetailView.js +2 -2
- package/dist/features/categories/components/CategoryDetailPageView.js +36 -3
- package/dist/features/categories/components/CategoryGrid.js +24 -2
- package/dist/features/categories/components/FranchiseDetailPageView.js +2 -1
- package/dist/features/events/components/EventCard.js +15 -2
- package/dist/features/faq/components/FAQPageContent.js +17 -4
- package/dist/features/faq/constants/help-guides.d.ts +36 -0
- package/dist/features/faq/constants/help-guides.js +64 -0
- package/dist/features/grouped/components/GroupedListingDetailView.js +2 -3
- package/dist/features/homepage/components/CustomCardsSection.js +67 -50
- package/dist/features/homepage/components/HeroCarousel.js +3 -1
- package/dist/features/homepage/components/ShopByCategorySection.js +23 -10
- package/dist/features/homepage/lib/section-renderer.js +2 -1
- package/dist/features/homepage/schemas/index.d.ts +10 -10
- package/dist/features/orders/components/EmiInstallmentsPanel.d.ts +19 -0
- package/dist/features/orders/components/EmiInstallmentsPanel.js +57 -0
- package/dist/features/orders/components/index.d.ts +1 -0
- package/dist/features/orders/components/index.js +1 -0
- package/dist/features/orders/schemas/index.d.ts +2 -2
- package/dist/features/orders/types/index.d.ts +15 -0
- package/dist/features/parts/components/PartDetailPageView.js +5 -3
- package/dist/features/parts/components/PartsSearchBox.js +79 -4
- package/dist/features/parts/schemas/admin-edit.d.ts +81 -0
- package/dist/features/parts/schemas/admin-edit.js +89 -0
- package/dist/features/parts/schemas/firestore.d.ts +2 -1
- package/dist/features/points/repository/points.repository.js +5 -5
- package/dist/features/points/schemas/firestore.d.ts +16 -0
- package/dist/features/points/schemas/firestore.js +17 -0
- package/dist/features/pre-orders/components/MarketplacePreorderCard.js +15 -2
- package/dist/features/products/components/MarketplaceBundleCard.js +15 -2
- package/dist/features/products/components/MarketplacePrizeDrawCard.js +15 -2
- package/dist/features/products/components/ProductGrid.js +18 -2
- package/dist/features/products/schemas/firestore.d.ts +18 -0
- package/dist/features/reviews/components/ReviewDetailPageView.js +3 -2
- package/dist/features/seller/components/CategoryInlineSelect.d.ts +10 -1
- package/dist/features/seller/components/CategoryInlineSelect.js +53 -5
- package/dist/features/seller/components/SellerProductShell.d.ts +39 -0
- package/dist/features/seller/components/SellerProductShell.js +31 -3
- package/dist/features/stores/components/StoreHeader.js +3 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.js +13 -0
- package/dist/next/routing/route-map.d.ts +17 -1
- package/dist/next/routing/route-map.js +39 -0
- package/dist/schemas/registry.d.ts +8 -8
- package/dist/seed/_helpers/category-models/chunk0.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk0.js +2648 -0
- package/dist/seed/_helpers/category-models/chunk1.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk1.js +2318 -0
- package/dist/seed/_helpers/category-models/chunk2.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk2.js +2560 -0
- package/dist/seed/_helpers/category-models/chunk3.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk3.js +2196 -0
- package/dist/seed/_helpers/category-models/chunk4.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk4.js +1732 -0
- package/dist/seed/_helpers/category-models.d.ts +9 -2
- package/dist/seed/_helpers/category-models.js +19 -1368
- package/dist/seed/_helpers/category-parts-glossary-map.js +38 -38
- package/dist/seed/_helpers/category-parts.js +39 -39
- package/dist/seed/bey-products-seed-data.d.ts +18 -0
- package/dist/seed/bey-products-seed-data.js +18011 -0
- package/dist/seed/beys/chunk0.d.ts +17 -0
- package/dist/seed/beys/chunk0.js +36733 -0
- package/dist/seed/beys/chunk1.d.ts +17 -0
- package/dist/seed/beys/chunk1.js +35728 -0
- package/dist/seed/beys/chunk2.d.ts +17 -0
- package/dist/seed/beys/chunk2.js +35857 -0
- package/dist/seed/beys/chunk3.d.ts +17 -0
- package/dist/seed/beys/chunk3.js +34476 -0
- package/dist/seed/beys-seed-data.d.ts +18 -0
- package/dist/seed/beys-seed-data.js +11 -0
- package/dist/seed/homepage-sections-seed-data.js +16 -0
- package/dist/seed/index.d.ts +2 -0
- package/dist/seed/index.js +3 -0
- package/dist/seed/media-assets-seed-data.js +4 -4
- package/dist/seed/products-catalogue/chunk0.d.ts +12 -0
- package/dist/seed/products-catalogue/chunk0.js +7262 -0
- package/dist/seed/products-catalogue/chunk1.d.ts +12 -0
- package/dist/seed/products-catalogue/chunk1.js +7290 -0
- package/dist/seed/products-catalogue/chunk10.d.ts +12 -0
- package/dist/seed/products-catalogue/chunk10.js +7290 -0
- package/dist/seed/products-catalogue/chunk11.d.ts +12 -0
- package/dist/seed/products-catalogue/chunk11.js +7005 -0
- package/dist/seed/products-catalogue/chunk12.d.ts +12 -0
- package/dist/seed/products-catalogue/chunk12.js +7002 -0
- package/dist/seed/products-catalogue/chunk13.d.ts +12 -0
- package/dist/seed/products-catalogue/chunk13.js +7002 -0
- package/dist/seed/products-catalogue/chunk14.d.ts +12 -0
- package/dist/seed/products-catalogue/chunk14.js +7002 -0
- package/dist/seed/products-catalogue/chunk15.d.ts +12 -0
- package/dist/seed/products-catalogue/chunk15.js +7002 -0
- package/dist/seed/products-catalogue/chunk16.d.ts +12 -0
- package/dist/seed/products-catalogue/chunk16.js +7002 -0
- package/dist/seed/products-catalogue/chunk17.d.ts +12 -0
- package/dist/seed/products-catalogue/chunk17.js +7002 -0
- package/dist/seed/products-catalogue/chunk18.d.ts +12 -0
- package/dist/seed/products-catalogue/chunk18.js +4398 -0
- package/dist/seed/products-catalogue/chunk2.d.ts +12 -0
- package/dist/seed/products-catalogue/chunk2.js +7268 -0
- package/dist/seed/products-catalogue/chunk3.d.ts +12 -0
- package/dist/seed/products-catalogue/chunk3.js +7264 -0
- package/dist/seed/products-catalogue/chunk4.d.ts +12 -0
- package/dist/seed/products-catalogue/chunk4.js +7268 -0
- package/dist/seed/products-catalogue/chunk5.d.ts +12 -0
- package/dist/seed/products-catalogue/chunk5.js +7258 -0
- package/dist/seed/products-catalogue/chunk6.d.ts +12 -0
- package/dist/seed/products-catalogue/chunk6.js +7268 -0
- package/dist/seed/products-catalogue/chunk7.d.ts +12 -0
- package/dist/seed/products-catalogue/chunk7.js +7311 -0
- package/dist/seed/products-catalogue/chunk8.d.ts +12 -0
- package/dist/seed/products-catalogue/chunk8.js +7304 -0
- package/dist/seed/products-catalogue/chunk9.d.ts +12 -0
- package/dist/seed/products-catalogue/chunk9.js +7309 -0
- package/dist/seed/products-catalogue-seed-data.d.ts +2 -22
- package/dist/seed/products-catalogue-seed-data.js +58 -86382
- package/dist/seed/seed-purpose.js +9 -0
- package/dist/server.d.ts +5 -1
- package/dist/server.js +19 -0
- package/dist/styles.css +468 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/PosterCard.d.ts +99 -0
- package/dist/ui/components/PosterCard.js +109 -0
- package/dist/ui/components/PosterCard.style.css +466 -0
- package/dist/ui/components/index.style.css +1 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +2 -0
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +108 -1
|
@@ -51,6 +51,27 @@ const ORDER_LANE_TABS = [
|
|
|
51
51
|
{ id: "auction", label: "Auction wins" },
|
|
52
52
|
{ id: "offer", label: "Offer wins" },
|
|
53
53
|
];
|
|
54
|
+
/**
|
|
55
|
+
* Payment-method tabs — a THIRD axis, independent of the lane tabs above and
|
|
56
|
+
* the lifecycle scope beside them.
|
|
57
|
+
*
|
|
58
|
+
* 🛑 EMI is not a lane and not a status, which is why it could not join
|
|
59
|
+
* either existing control: `orderType` records how the order was WON
|
|
60
|
+
* (standard / auction / offer) and the scope records where it IS. How it is
|
|
61
|
+
* being PAID is orthogonal to both — an auction win can be on an EMI plan.
|
|
62
|
+
*
|
|
63
|
+
* Every id is exactly a stored `OrderDocument.paymentMethod` value, and
|
|
64
|
+
* `paymentMethod` is `canFilter: true` in the orders SIEVE_FIELDS — checked
|
|
65
|
+
* before wiring this, because an unallowlisted field does not raise: the
|
|
66
|
+
* clause is silently dropped and the chip returns the ENTIRE order list while
|
|
67
|
+
* looking like a filter (Root Cause #62).
|
|
68
|
+
*/
|
|
69
|
+
const PAYMENT_METHOD_TABS = [
|
|
70
|
+
{ id: "", label: "Any" },
|
|
71
|
+
{ id: "emi", label: "EMI plans" },
|
|
72
|
+
{ id: "cod", label: "Cash on delivery" },
|
|
73
|
+
{ id: "online", label: "Paid online" },
|
|
74
|
+
];
|
|
54
75
|
const STATUS_OPTIONS = [
|
|
55
76
|
{ value: "pending", label: "Pending" },
|
|
56
77
|
{ value: "processing", label: "Processing" },
|
|
@@ -85,7 +106,7 @@ export function UserOrdersView({ onOrderClick }) {
|
|
|
85
106
|
* (CLAUDE.md, Root Cause #35).
|
|
86
107
|
*/
|
|
87
108
|
const searchParams = useSearchParams();
|
|
88
|
-
const hasNarrowingFilter = ["q", "search", "status", "orderType", "filters"].some((k) => Boolean(searchParams.get(k)));
|
|
109
|
+
const hasNarrowingFilter = ["q", "search", "status", "orderType", "paymentMethod", "filters"].some((k) => Boolean(searchParams.get(k)));
|
|
89
110
|
const emptyMessage = hasNarrowingFilter
|
|
90
111
|
? "No orders match your search or filters."
|
|
91
112
|
: "You haven't placed any orders yet.";
|
|
@@ -104,7 +125,7 @@ export function UserOrdersView({ onOrderClick }) {
|
|
|
104
125
|
fields: ["id", "productTitle"],
|
|
105
126
|
},
|
|
106
127
|
emptyLabel: emptyMessage,
|
|
107
|
-
filterKeys: ["status", "orderType"],
|
|
128
|
+
filterKeys: ["status", "orderType", "paymentMethod"],
|
|
108
129
|
defaultSort: sortBy("createdAt", "DESC"),
|
|
109
130
|
queryKey: ["user", "orders", "listing"],
|
|
110
131
|
endpoint: ACCOUNT_ENDPOINTS.ORDERS,
|
|
@@ -115,6 +136,7 @@ export function UserOrdersView({ onOrderClick }) {
|
|
|
115
136
|
buildFilters: (state) => [
|
|
116
137
|
state.status ? sieveFilter("status", SIEVE_OP.EQ, state.status) : null,
|
|
117
138
|
state.orderType ? sieveFilter("orderType", SIEVE_OP.EQ, state.orderType) : null,
|
|
139
|
+
state.paymentMethod ? sieveFilter("paymentMethod", SIEVE_OP.EQ, state.paymentMethod) : null,
|
|
118
140
|
]
|
|
119
141
|
.filter(Boolean)
|
|
120
142
|
.join(",") || undefined,
|
|
@@ -123,7 +145,7 @@ export function UserOrdersView({ onOrderClick }) {
|
|
|
123
145
|
// in flight" without those two questions fighting over one control.
|
|
124
146
|
buildExtraParams: () => orderScope.extraParams,
|
|
125
147
|
renderAboveContent: orderScope.renderAboveContent,
|
|
126
|
-
renderFilterPanel: ({ pendingFilters, setPendingFilters }) => (_jsxs(Stack, { gap: "md", children: [_jsx(FilterChipGroup, { label: "Type", tabs: ORDER_LANE_TABS, value: pendingFilters.orderType ?? "", onChange: (id) => setPendingFilters((prev) => ({ ...prev, orderType: id })) }), _jsx(Div, { children: _jsx(PaginatedSelect, { value: pendingFilters.status || null, onChange: (v) => setPendingFilters((p) => ({ ...p, status: v ?? "" })), options: STATUS_OPTIONS, placeholder: "All statuses", ariaLabel: "Filter by order status" }) })] })),
|
|
148
|
+
renderFilterPanel: ({ pendingFilters, setPendingFilters }) => (_jsxs(Stack, { gap: "md", children: [_jsx(FilterChipGroup, { label: "Type", tabs: ORDER_LANE_TABS, value: pendingFilters.orderType ?? "", onChange: (id) => setPendingFilters((prev) => ({ ...prev, orderType: id })) }), _jsx(FilterChipGroup, { label: "Payment", tabs: PAYMENT_METHOD_TABS, value: pendingFilters.paymentMethod ?? "", onChange: (id) => setPendingFilters((prev) => ({ ...prev, paymentMethod: id })) }), _jsx(Div, { children: _jsx(PaginatedSelect, { value: pendingFilters.status || null, onChange: (v) => setPendingFilters((p) => ({ ...p, status: v ?? "" })), options: STATUS_OPTIONS, placeholder: "All statuses", ariaLabel: "Filter by order status" }) })] })),
|
|
127
149
|
renderCards: (rows, _view, _selection, isLoading) => {
|
|
128
150
|
if (isLoading) {
|
|
129
151
|
return (_jsx(Stack, { gap: "md", children: Array.from({ length: 3 }).map((_, i) => (_jsx(Div, { className: "h-24 animate-pulse border border-[var(--appkit-color-border)]", rounded: "xl" }, i))) }));
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AdminBeysView — browse and correct the generated Beyblade database.
|
|
3
|
+
*
|
|
4
|
+
* ── 🛑 WHY THIS IS A CORRECTION SURFACE, NOT A CRUD SCREEN ────────────────
|
|
5
|
+
*
|
|
6
|
+
* `beys` is 1,988 rows built by committed generators from a crawled corpus. So
|
|
7
|
+
* there is no "Add Beyblade" button: a row authored here would have no source,
|
|
8
|
+
* would vanish on the next regeneration, and would make the claim "this dataset
|
|
9
|
+
* rebuilds from `appkit/src/seed/` alone" false. Adding one is a change to the
|
|
10
|
+
* 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:beys
|
|
20
|
+
* @tag layer:feature
|
|
21
|
+
* @tag access:client
|
|
22
|
+
* @tag consumers:app/[locale]/admin/beys/page.tsx
|
|
23
|
+
*/
|
|
24
|
+
import React from "react";
|
|
25
|
+
import type { ListingLayoutProps } from "../../../ui";
|
|
26
|
+
export type AdminBeysViewProps = ListingLayoutProps;
|
|
27
|
+
export declare function AdminBeysView({ children, ...props }: AdminBeysViewProps): React.JSX.Element;
|
|
@@ -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" | "BUNDLE" | "GROUP" | "SUBLISTING" | "EVENT" | "BLOG" | "REVIEW";
|
|
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;
|
|
@@ -54,14 +54,63 @@ import { IconButton } from "../../../ui/components/IconButton.js";
|
|
|
54
54
|
import { Row, useToast } from "../../../ui/index.js";
|
|
55
55
|
import { normalizeError } from "../../../errors/normalize.js";
|
|
56
56
|
import { toUserMessage } from "../../../errors/error-display-map.js";
|
|
57
|
-
|
|
57
|
+
const RESOURCE_LEAVES = {
|
|
58
|
+
PRODUCT: { bucket: "PRODUCT", edit: "edit", remove: "delete", duplicate: "duplicate" },
|
|
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" },
|
|
69
|
+
BRAND: { bucket: "BRAND", edit: "edit", remove: "delete" },
|
|
70
|
+
BUNDLE: { bucket: "BUNDLE", edit: "edit", remove: "delete" },
|
|
71
|
+
GROUP: { bucket: "GROUP", edit: "edit", remove: "delete" },
|
|
72
|
+
SUBLISTING: { bucket: "SUBLISTING", edit: "edit", remove: "delete" },
|
|
73
|
+
EVENT: { bucket: "EVENT", edit: "edit", remove: "delete", duplicate: "duplicate" },
|
|
74
|
+
REVIEW: { bucket: "REVIEW", edit: "edit", remove: "delete" },
|
|
75
|
+
/** 🛑 `edit-post` / `delete-post`, not `edit` / `delete`. See the note above. */
|
|
76
|
+
BLOG: { bucket: "BLOG", edit: "edit-post", remove: "delete-post", duplicate: "duplicate-post" },
|
|
77
|
+
/**
|
|
78
|
+
* A franchise row IS a category (`categoryType: "franchise"`), so it reuses
|
|
79
|
+
* the category leaves rather than duplicating copy that would drift.
|
|
80
|
+
*/
|
|
81
|
+
FRANCHISE: { bucket: "CATEGORY", edit: "edit", remove: "delete" },
|
|
82
|
+
/** Carousel slides have no bucket of their own; the leaves live under ADMIN. */
|
|
83
|
+
CAROUSEL: { bucket: "ADMIN", edit: "edit-carousel", remove: "delete-carousel" },
|
|
84
|
+
/*
|
|
85
|
+
* ── The three PENCIL-ONLY resources ─────────────────────────────────────
|
|
86
|
+
*
|
|
87
|
+
* `remove` is omitted on purpose for all three. Each is a delete whose blast
|
|
88
|
+
* radius is far larger than the page the bin would sit on, and a public
|
|
89
|
+
* surface is the worst possible place to offer it:
|
|
90
|
+
*
|
|
91
|
+
* STORE — deleting a store orphans its products, orders and payouts. The
|
|
92
|
+
* dashboard offers `suspend-store` / `verify-store`, which are the
|
|
93
|
+
* operations an admin actually wants from a store page.
|
|
94
|
+
* USER — deleting a user cascades across orders, reviews, bids and
|
|
95
|
+
* addresses. `ADMIN.ban-user` is the reversible tool, and it has
|
|
96
|
+
* its own flow with a reason.
|
|
97
|
+
* SECTION — homepage section `order` is POSITIONAL, so removing one silently
|
|
98
|
+
* renumbers its neighbours. There is also no per-section public
|
|
99
|
+
* page; the pencil goes to the list that owns them.
|
|
100
|
+
*/
|
|
101
|
+
STORE: { bucket: "ADMIN", edit: "manage-store" },
|
|
102
|
+
USER: { bucket: "ADMIN", edit: "manage-user" },
|
|
103
|
+
SECTION: { bucket: "ADMIN", edit: "edit-section" },
|
|
104
|
+
};
|
|
105
|
+
export function OwnerQuickActions({ resource, editHref, onDelete, onClone, ownerId, storeId, recordLabel, placement = "card", onMedia = false, className = "", }) {
|
|
58
106
|
const session = useOptionalSession();
|
|
59
107
|
const isOwnerOrAdmin = useIsOwner(ownerId);
|
|
60
108
|
const isAdmin = useIsAdmin();
|
|
61
109
|
const { showToast } = useToast();
|
|
62
110
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
63
111
|
const [deleting, setDeleting] = useState(false);
|
|
64
|
-
const
|
|
112
|
+
const leaves = RESOURCE_LEAVES[resource];
|
|
113
|
+
const bucket = ACTIONS[leaves.bucket];
|
|
65
114
|
const handleConfirmedDelete = useCallback(async () => {
|
|
66
115
|
if (!onDelete)
|
|
67
116
|
return;
|
|
@@ -113,10 +162,36 @@ export function OwnerQuickActions({ resource, editHref, onDelete, onClone, owner
|
|
|
113
162
|
*/
|
|
114
163
|
if (!isAdmin && !isOwnerOrAdmin && !ownsStore)
|
|
115
164
|
return null;
|
|
116
|
-
const editDef = bucket.edit;
|
|
117
|
-
const deleteDef = bucket.
|
|
165
|
+
const editDef = bucket[leaves.edit];
|
|
166
|
+
const deleteDef = leaves.remove ? bucket[leaves.remove] : undefined;
|
|
118
167
|
const confirmation = deleteDef?.confirmation;
|
|
119
|
-
const showClone = Boolean(onClone) && isAdmin;
|
|
168
|
+
const showClone = Boolean(onClone) && isAdmin && Boolean(leaves.duplicate);
|
|
169
|
+
/*
|
|
170
|
+
* 🛑 `leaves.remove` gates the bin, not just `onDelete`. A resource whose
|
|
171
|
+
* mapping omits a delete leaf (store, user, homepage section) must render no
|
|
172
|
+
* bin even if a caller passes `onDelete` — the safety decision belongs to the
|
|
173
|
+
* resource, not to each of its call sites, or the next call site re-opens it.
|
|
174
|
+
*/
|
|
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";
|
|
120
195
|
return (_jsxs(_Fragment, { children: [_jsxs(Row, { gap: "xs", align: "center", className: className,
|
|
121
196
|
/*
|
|
122
197
|
* Stops the click reaching an ancestor `<Link>` or the card's own
|
|
@@ -125,7 +200,7 @@ export function OwnerQuickActions({ resource, editHref, onDelete, onClone, owner
|
|
|
125
200
|
*/
|
|
126
201
|
onClick: (e) => {
|
|
127
202
|
e.stopPropagation();
|
|
128
|
-
}, children: [_jsx(IconButton, { "aria-label": editDef?.ariaLabel ?? editDef?.label ?? "Edit", size: "touch", variant:
|
|
203
|
+
}, children: [_jsx(IconButton, { "aria-label": editDef?.ariaLabel ?? editDef?.label ?? "Edit", size: "touch", variant: neutralVariant, icon: _jsx(Icon, { name: "edit" }), onClick: () => {
|
|
129
204
|
/*
|
|
130
205
|
* `window.location`, not `next/link`. This component is rendered
|
|
131
206
|
* inside cards whose whole body is already an anchor, and nesting
|
|
@@ -135,8 +210,8 @@ export function OwnerQuickActions({ resource, editHref, onDelete, onClone, owner
|
|
|
135
210
|
*/
|
|
136
211
|
if (typeof window !== "undefined")
|
|
137
212
|
window.location.assign(editHref);
|
|
138
|
-
} }), showClone && (_jsx(IconButton, { "aria-label": (bucket.duplicate?.ariaLabel ?? bucket.duplicate?.label) ||
|
|
139
|
-
"Duplicate", size: "touch", variant:
|
|
213
|
+
} }), showClone && (_jsx(IconButton, { "aria-label": (leaves.duplicate ? bucket[leaves.duplicate]?.ariaLabel ?? bucket[leaves.duplicate]?.label : undefined) ||
|
|
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
|
|
140
215
|
? `${confirmation.body}\n\nDeleting: ${recordLabel}`
|
|
141
216
|
: confirmation?.body, confirmText: confirmation?.confirmLabel ?? deleteDef?.label ?? "Delete", variant: "danger" }))] }));
|
|
142
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;
|