@mohasinac/appkit 4.61.0 → 4.62.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 +3 -3
- package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +19 -3
- package/dist/_internal/client/features/maintenance/views/AnalysisRunnerView.js +1 -1
- package/dist/_internal/client/features/maintenance/views/CloudLogsListView.js +1 -1
- package/dist/_internal/server/jobs/core/paymentReconcile.js +87 -97
- package/dist/_internal/shared/actions/action-registry.js +149 -9
- package/dist/_internal/shared/media/limits.d.ts +10 -0
- package/dist/_internal/shared/media/limits.js +10 -0
- package/dist/_internal/shared/types/action-result.js +15 -1
- package/dist/client/api/surface-error.js +16 -2
- package/dist/client/api/useApiMutation.js +17 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/constants/api-endpoints.d.ts +3 -0
- package/dist/constants/api-endpoints.js +1 -0
- package/dist/constants/field-names.d.ts +17 -0
- package/dist/constants/field-names.js +23 -0
- package/dist/core/server-action.js +19 -3
- package/dist/errors/error-mapping.js +26 -1
- package/dist/features/account/components/UserOffersPanel.js +5 -3
- package/dist/features/admin/components/AdminActionIndexView.js +100 -5
- package/dist/features/admin/components/AdminAddressClustersView.js +21 -2
- package/dist/features/admin/components/AdminBlogEditorView.js +8 -8
- package/dist/features/admin/components/AdminCarouselEditorView.js +1 -1
- package/dist/features/admin/components/AdminCarouselView.js +96 -4
- package/dist/features/admin/components/AdminFulfillmentView.js +3 -2
- package/dist/features/admin/components/AdminMediaView.js +36 -3
- package/dist/features/admin/components/AdminNavigationView.js +4 -1
- package/dist/features/admin/components/AdminOffersView.js +2 -1
- package/dist/features/admin/components/AdminPaymentClustersView.js +19 -2
- package/dist/features/admin/components/AdminProductEditorView.js +2 -2
- package/dist/features/admin/components/AdminSectionsView.d.ts +10 -0
- package/dist/features/admin/components/AdminSectionsView.js +486 -224
- package/dist/features/admin/components/ReferenceRecordEditor.js +1 -1
- package/dist/features/admin/hooks/useOptimisticRowFlag.d.ts +33 -0
- package/dist/features/admin/hooks/useOptimisticRowFlag.js +87 -0
- package/dist/features/admin/repository/notification.repository.d.ts +15 -0
- package/dist/features/admin/repository/notification.repository.js +32 -0
- package/dist/features/admin/schemas/action-index-form.d.ts +34 -0
- package/dist/features/admin/schemas/action-index-form.js +115 -0
- package/dist/features/auth/components/RegisterForm.js +2 -1
- package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -3
- package/dist/features/events/components/AdminEventEditorView.js +8 -6
- package/dist/features/layout/hooks/useFormBottomActions.js +20 -1
- package/dist/features/orders/components/EmiInstallmentsPanel.js +25 -7
- package/dist/features/orders/components/RefundRequestView.js +4 -4
- package/dist/features/orders/schemas/index.d.ts +6 -6
- package/dist/features/products/components/GroupMemberPicker.js +4 -1
- package/dist/features/products/components/PrizeDrawEntryActions.js +2 -1
- package/dist/features/products/components/ProductDetailActions.js +2 -1
- package/dist/features/seller/components/FulfillmentView.js +3 -2
- package/dist/features/seller/components/SellerAddressesView.js +32 -4
- package/dist/features/seller/components/SellerAnalyticsAlertsView.js +62 -3
- package/dist/features/seller/components/SellerBundlesView.js +53 -14
- package/dist/features/seller/components/SellerGroupedListingsView.js +23 -3
- package/dist/features/seller/components/SellerListingTypeView.js +56 -10
- package/dist/features/seller/components/SellerPayoutMethodsView.js +33 -1
- package/dist/features/seller/components/SellerPayoutSettingsView.js +48 -9
- package/dist/features/seller/components/SellerProductsView.js +5 -1
- package/dist/features/seller/components/SellerShippingConfigsView.js +33 -1
- package/dist/features/seller/components/SellerShippingView.js +2 -1
- package/dist/features/seller/components/SellerStoreCategoriesView.js +33 -1
- package/dist/features/seller/components/SellerSupportView.js +69 -2
- package/dist/features/shell/SectionForm.js +20 -16
- package/dist/features/shell/build-sections.js +14 -1
- package/dist/features/shipments/components/AdminShipmentLotItemsView.js +54 -1
- package/dist/features/shipments/components/AdminShipmentProjectionsView.js +3 -2
- package/dist/features/sticker-trace/components/StickerCommitPanel.js +2 -1
- package/dist/features/sticker-trace/components/StickerPrintPanel.js +2 -1
- package/dist/features/sticker-trace/components/StickerRetryPanel.js +24 -4
- package/dist/features/sticker-trace/components/StickerTraceStudio.js +2 -1
- package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +3 -2
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/next/routing/route-map.d.ts +4 -0
- package/dist/next/routing/route-map.js +9 -0
- package/dist/schemas/registry.d.ts +4 -4
- package/dist/schemas/sieve.d.ts +3 -3
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/dist/ui/components/Checkbox.js +10 -2
- package/dist/ui/components/DateInput.js +8 -1
- package/dist/ui/components/Input.js +22 -2
- package/dist/ui/components/PaginatedSelect.d.ts +8 -19
- package/dist/ui/components/PaginatedSelect.js +43 -9
- package/dist/ui/components/Radio.js +8 -1
- package/dist/ui/components/Select.js +10 -2
- package/dist/ui/components/TagInput.d.ts +8 -1
- package/dist/ui/components/TagInput.js +4 -3
- package/dist/ui/components/Textarea.js +10 -2
- package/dist/ui/forms/FieldCheckbox.js +1 -1
- package/dist/ui/forms/FormErrorList.d.ts +0 -16
- package/dist/ui/forms/FormErrorList.js +34 -1
- package/dist/ui/forms/apply-zod-issues.d.ts +19 -1
- package/dist/ui/forms/apply-zod-issues.js +23 -1
- package/package.json +1 -1
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { normalizeError } from "../../../errors/normalize.js";
|
|
4
|
-
import { useApiMutation } from "@mohasinac/appkit/client";
|
|
4
|
+
import { useApiMutation, sortBy, sieveFilter, SIEVE_OP } from "@mohasinac/appkit/client";
|
|
5
5
|
import React from "react";
|
|
6
6
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
7
7
|
import { ProductInlineSelect } from "../../seller/components/ProductInlineSelect.js";
|
|
8
8
|
import { CategoryInlineSelect } from "../../seller/components/CategoryInlineSelect.js";
|
|
9
|
-
import { Button, Checkbox, Div,
|
|
9
|
+
import { Badge, Button, Checkbox, ConfirmDeleteModal, Div, FilterChipGroup, Input, PaginatedSelect, Row, Select, SideDrawer, Span, Stack, Text, Textarea, Toggle, useToast } from "../../../ui/index.js";
|
|
10
10
|
import { ICON_KEY_OPTIONS } from "./sections/icon-options.js";
|
|
11
11
|
import { FormErrorSummary, applyZodIssues } from "../../../ui/forms/index.js";
|
|
12
12
|
import { homepageSectionFormSchema, } from "../schemas/homepage-section-form.js";
|
|
@@ -16,10 +16,16 @@ import { apiClient } from "../../../http/index.js";
|
|
|
16
16
|
import { ADMIN_ENDPOINTS } from "../../../constants/index.js";
|
|
17
17
|
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
18
18
|
import { useAdminSectionsListing } from "../hooks/useAdminSectionsListing.js";
|
|
19
|
-
import {
|
|
19
|
+
import { toRecordArray, toRelativeDate } from "../hooks/useAdminListingData.js";
|
|
20
|
+
import { useOptimisticRowFlag } from "../hooks/useOptimisticRowFlag.js";
|
|
21
|
+
import { DataListingView } from "./DataListingView.js";
|
|
22
|
+
import { QuickEditMenu } from "./QuickEditMenu.js";
|
|
23
|
+
import { ALL_TAB } from "../constants/filter-tabs.js";
|
|
24
|
+
import { ACTIONS } from "../../../_internal/shared/actions/action-registry.js";
|
|
20
25
|
import { templatesForType, templateById, profileById, } from "./sections/sectionTemplates.js";
|
|
21
26
|
import { SECTION_TYPE_OPTIONS, SUPPORTED_TYPED_BUILDERS, RESOURCE_SORT_OPTIONS, FAQ_CATEGORY_OPTIONS, DEFAULT_PRODUCTS_BUILDER, DEFAULT_AUCTIONS_BUILDER, DEFAULT_STATS_BUILDER, DEFAULT_PRE_ORDERS_BUILDER, DEFAULT_STORES_BUILDER, DEFAULT_EVENTS_BUILDER, DEFAULT_SOCIAL_FEED_BUILDER, DEFAULT_WELCOME_BUILDER, DEFAULT_TRUST_INDICATORS_BUILDER, DEFAULT_CATEGORIES_BUILDER, DEFAULT_BRANDS_BUILDER, DEFAULT_BANNER_BUILDER, DEFAULT_FEATURES_BUILDER, DEFAULT_REVIEWS_BUILDER, DEFAULT_WHATSAPP_BUILDER, DEFAULT_FAQ_BUILDER, DEFAULT_BLOG_BUILDER, DEFAULT_NEWSLETTER_BUILDER, DEFAULT_CAROUSEL_BUILDER, DEFAULT_CUSTOM_CARDS_BUILDER, DEFAULT_GOOGLE_REVIEWS_BUILDER, DEFAULT_FEATURED_BUNDLES_BUILDER, DEFAULT_PRIZE_DRAWS_BUILDER, DEFAULT_EVENT_RAFFLES_BUILDER, DEFAULT_COLLECTION_CARDS_BUILDER, } from "./sections/adminSectionsTypes.js";
|
|
22
27
|
import { toStringValue, buildProductsConfig, buildAuctionsConfig, buildStatsConfig, buildPreOrdersConfig, buildStoresConfig, buildEventsConfig, buildSocialFeedConfig, buildWelcomeConfig, buildTrustIndicatorsConfig, buildCategoriesConfig, buildBrandsConfig, buildBannerConfig, buildFeaturesConfig, buildReviewsConfig, buildWhatsAppConfig, buildFAQConfig, buildBlogConfig, buildNewsletterConfig, buildCarouselConfig, buildCustomCardsConfig, buildGoogleReviewsConfig, buildFeaturedBundlesConfig, buildPrizeDrawsConfig, buildEventRafflesConfig, buildCollectionCardsConfig, parseProductsBuilder, parseAuctionsBuilder, parseStatsBuilder, parsePreOrdersBuilder, parseStoresBuilder, parseEventsBuilder, parseSocialFeedBuilder, parseWelcomeBuilder, parseTrustIndicatorsBuilder, parseCategoriesBuilder, parseBrandsBuilder, parseBannerBuilder, parseFeaturesBuilder, parseReviewsBuilder, parseWhatsAppBuilder, parseFAQBuilder, parseBlogBuilder, parseNewsletterBuilder, parseCarouselBuilder, parseCustomCardsBuilder, parseGoogleReviewsBuilder, parseFeaturedBundlesBuilder, parsePrizeDrawsBuilder, parseEventRafflesBuilder, parseCollectionCardsBuilder } from "./sections/adminSectionsBuildParse.js";
|
|
28
|
+
import { toUserMessage } from "../../../errors/error-display-map.js";
|
|
23
29
|
const __P = {
|
|
24
30
|
p3: "p-[var(--appkit-space-3)]",
|
|
25
31
|
p4: "p-[var(--appkit-space-4)]",
|
|
@@ -35,12 +41,28 @@ const LBL_SCROLL_INTERVAL = "Scroll interval (ms)";
|
|
|
35
41
|
const LBL_VIEW_MORE = "View all link label";
|
|
36
42
|
const PH_VIEW_MORE = "View all →";
|
|
37
43
|
const HELP_VIEW_MORE = "Leave blank to use the default label.";
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
/**
|
|
45
|
+
* The homepage-section editor: the envelope, 25 typed builders and the raw
|
|
46
|
+
* JSON fallback.
|
|
47
|
+
*
|
|
48
|
+
* 🛑 THIS IS A SEPARATE COMPONENT FOR A MECHANICAL REASON, NOT FOR TIDINESS.
|
|
49
|
+
* `usePanelUrlSync` lives inside `useAdminListing` inside `DataListingView`,
|
|
50
|
+
* so the id of the row whose pencil was clicked is only readable inside the
|
|
51
|
+
* `renderEditor(ctx)` callback. The hydrate effect below must run in a
|
|
52
|
+
* component body, so the editor has to BE a component.
|
|
53
|
+
*
|
|
54
|
+
* 🛑 The caller MUST pass `key={ctx.editId ?? "create"}`. `isCreateOpen ||
|
|
55
|
+
* isEditOpen` is a single boolean, so clicking row B's pencil while row A's
|
|
56
|
+
* drawer is open does NOT unmount this component — `editId` simply changes.
|
|
57
|
+
* Without the key, `hydratedRef` below sees "already hydrated" and the admin
|
|
58
|
+
* edits B's record with A's values, then saves it. Silent cross-record
|
|
59
|
+
* corruption, no error anywhere. The remount the key forces is also what
|
|
60
|
+
* resets the 24 non-matching builders and `selectedTemplateId`, which the old
|
|
61
|
+
* single-component version had to do by hand and did not.
|
|
62
|
+
*/
|
|
63
|
+
function SectionEditorPanel({ sectionId, section, siblingSections, isSectionsLoading, onDone, }) {
|
|
64
|
+
const isCreate = !sectionId;
|
|
40
65
|
const queryClient = useQueryClient();
|
|
41
|
-
const [isModalOpen, setIsModalOpen] = React.useState(false);
|
|
42
|
-
const [mode, setMode] = React.useState("create");
|
|
43
|
-
const [selectedSectionId, setSelectedSectionId] = React.useState("");
|
|
44
66
|
const [sectionType, setSectionType] = React.useState("products");
|
|
45
67
|
/*
|
|
46
68
|
* D1 — which template the admin picked, or "" for the type's defaults.
|
|
@@ -51,7 +73,19 @@ export function AdminSectionsView({ children }) {
|
|
|
51
73
|
const [selectedTemplateId, setSelectedTemplateId] = React.useState("");
|
|
52
74
|
const [enabled, setEnabled] = React.useState(true);
|
|
53
75
|
const [order, setOrder] = React.useState("");
|
|
54
|
-
|
|
76
|
+
/*
|
|
77
|
+
* The RAW textarea contents — meaningful only for a section type with no
|
|
78
|
+
* typed builder. For a typed builder `configJson` below is DERIVED from
|
|
79
|
+
* `typedConfig`, because the textarea is `readOnly` in that case and was
|
|
80
|
+
* therefore never an input: it is a preview.
|
|
81
|
+
*
|
|
82
|
+
* 🛑 This used to be plain state written by an effect on `typedConfig`,
|
|
83
|
+
* which ran AFTER the hydrate effect and overwrote the stored document with
|
|
84
|
+
* a builder-generated config on every open. Deriving removes the race by
|
|
85
|
+
* construction rather than by ordering, and removes a second render per
|
|
86
|
+
* builder keystroke.
|
|
87
|
+
*/
|
|
88
|
+
const [rawConfigJson, setRawConfigJson] = React.useState("{}");
|
|
55
89
|
const [productsBuilder, setProductsBuilder] = React.useState(DEFAULT_PRODUCTS_BUILDER);
|
|
56
90
|
const [auctionsBuilder, setAuctionsBuilder] = React.useState(DEFAULT_AUCTIONS_BUILDER);
|
|
57
91
|
const [statsBuilder, setStatsBuilder] = React.useState(DEFAULT_STATS_BUILDER);
|
|
@@ -77,16 +111,9 @@ export function AdminSectionsView({ children }) {
|
|
|
77
111
|
const [prizeDrawsBuilder, setPrizeDrawsBuilder] = React.useState(DEFAULT_PRIZE_DRAWS_BUILDER);
|
|
78
112
|
const [eventRafflesBuilder, setEventRafflesBuilder] = React.useState(DEFAULT_EVENT_RAFFLES_BUILDER);
|
|
79
113
|
const [collectionCardsBuilder, setCollectionCardsBuilder] = React.useState(DEFAULT_COLLECTION_CARDS_BUILDER);
|
|
80
|
-
const [reorderDraft, setReorderDraft] = React.useState([]);
|
|
81
|
-
const [reorderServerSnapshot, setReorderServerSnapshot] = React.useState([]);
|
|
82
|
-
const [reorderUndoStack, setReorderUndoStack] = React.useState([]);
|
|
83
|
-
const [dragIndex, setDragIndex] = React.useState(null);
|
|
84
114
|
const toast = useToast();
|
|
85
115
|
const isTypedBuilder = SUPPORTED_TYPED_BUILDERS.includes(sectionType);
|
|
86
|
-
const
|
|
87
|
-
page: 1,
|
|
88
|
-
pageSize: 50,
|
|
89
|
-
});
|
|
116
|
+
const duplicateOfType = React.useMemo(() => (isCreate ? siblingSections.find((s) => s.type === sectionType) : undefined), [isCreate, siblingSections, sectionType]);
|
|
90
117
|
const categoriesQuery = useQuery({
|
|
91
118
|
queryKey: ["admin", "sections", "categories", "options"],
|
|
92
119
|
queryFn: () => apiClient.get(`${ADMIN_ENDPOINTS.CATEGORIES}?page=1&pageSize=100&sorts=name`),
|
|
@@ -165,25 +192,15 @@ export function AdminSectionsView({ children }) {
|
|
|
165
192
|
eventRafflesBuilder,
|
|
166
193
|
collectionCardsBuilder,
|
|
167
194
|
]);
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
.map((section) => ({
|
|
178
|
-
id: section.id,
|
|
179
|
-
type: section.type,
|
|
180
|
-
order: section.order,
|
|
181
|
-
}));
|
|
182
|
-
const normalized = normalizeOrder(sorted);
|
|
183
|
-
setReorderDraft(normalized);
|
|
184
|
-
setReorderServerSnapshot(normalized);
|
|
185
|
-
setReorderUndoStack([]);
|
|
186
|
-
}, [sections]);
|
|
195
|
+
/**
|
|
196
|
+
* What the Generated-config preview shows and what the save sends.
|
|
197
|
+
*
|
|
198
|
+
* Derived for a typed builder, raw state otherwise. `typedConfig` is null
|
|
199
|
+
* for the three types with no builder (`how-it-works`, `testimonials`,
|
|
200
|
+
* `promo-grid`), which is the same set `isTypedBuilder` is false for — so
|
|
201
|
+
* the two branches agree and neither can strand the other.
|
|
202
|
+
*/
|
|
203
|
+
const configJson = React.useMemo(() => (isTypedBuilder && typedConfig ? JSON.stringify(typedConfig, null, 2) : rawConfigJson), [isTypedBuilder, typedConfig, rawConfigJson]);
|
|
187
204
|
const saveSection = useApiMutation({
|
|
188
205
|
errorMessage: "Failed to save section.",
|
|
189
206
|
mutationFn: async () => {
|
|
@@ -195,7 +212,7 @@ export function AdminSectionsView({ children }) {
|
|
|
195
212
|
void normalizeError(_err);
|
|
196
213
|
throw new Error("Config must be valid JSON.");
|
|
197
214
|
}
|
|
198
|
-
if (
|
|
215
|
+
if (isCreate) {
|
|
199
216
|
await apiClient.post(ADMIN_ENDPOINTS.SECTIONS, {
|
|
200
217
|
type: sectionType,
|
|
201
218
|
enabled,
|
|
@@ -204,9 +221,17 @@ export function AdminSectionsView({ children }) {
|
|
|
204
221
|
});
|
|
205
222
|
return;
|
|
206
223
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
224
|
+
/*
|
|
225
|
+
* `config` is sent unconditionally and that is deliberate. The
|
|
226
|
+
* repository MERGES it (`{...existing.config, ...data.config}`), which
|
|
227
|
+
* costs one extra read — the price of a guaranteed-correct write. A
|
|
228
|
+
* "diff and skip" would save that read and silently drop an admin's
|
|
229
|
+
* edit whenever the comparison was wrong, which is the worse failure.
|
|
230
|
+
*
|
|
231
|
+
* The reads worth saving are the per-ROW ones, and those already omit
|
|
232
|
+
* `config`: the enable/disable toggle and the reorder save both send a
|
|
233
|
+
* single scalar and short-circuit the merge.
|
|
234
|
+
*/
|
|
210
235
|
const payload = {
|
|
211
236
|
enabled,
|
|
212
237
|
config: parsedConfig,
|
|
@@ -214,44 +239,39 @@ export function AdminSectionsView({ children }) {
|
|
|
214
239
|
if (order.trim()) {
|
|
215
240
|
payload.order = Number(order);
|
|
216
241
|
}
|
|
217
|
-
await apiClient.patch(`${ADMIN_ENDPOINTS.SECTIONS}/${
|
|
218
|
-
},
|
|
219
|
-
onSuccess: async () => {
|
|
220
|
-
await queryClient.invalidateQueries({ queryKey: ["admin", "sections", "listing"] });
|
|
221
|
-
// Public-facing useHomepageSections() reads under a separate key.
|
|
222
|
-
await queryClient.invalidateQueries({ queryKey: ["homepage-sections"] });
|
|
223
|
-
toast.showToast(mode === "create" ? "Section created." : "Section updated.", "success");
|
|
224
|
-
setIsModalOpen(false);
|
|
225
|
-
},
|
|
226
|
-
});
|
|
227
|
-
const reorderSections = useApiMutation({
|
|
228
|
-
errorMessage: "Failed to reorder sections.",
|
|
229
|
-
mutationFn: async () => {
|
|
230
|
-
await Promise.all(reorderDraft.map((item) => apiClient.patch(`${ADMIN_ENDPOINTS.SECTIONS}/${item.id}`, {
|
|
231
|
-
order: item.order,
|
|
232
|
-
})));
|
|
242
|
+
await apiClient.patch(`${ADMIN_ENDPOINTS.SECTIONS}/${sectionId}`, payload);
|
|
233
243
|
},
|
|
234
244
|
onSuccess: async () => {
|
|
235
245
|
await queryClient.invalidateQueries({ queryKey: ["admin", "sections", "listing"] });
|
|
236
246
|
// Public-facing useHomepageSections() reads under a separate key.
|
|
237
247
|
await queryClient.invalidateQueries({ queryKey: ["homepage-sections"] });
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
toast.showToast("Section order updated.", "success");
|
|
248
|
+
toast.showToast(isCreate ? "Section created." : "Section updated.", "success");
|
|
249
|
+
onDone();
|
|
241
250
|
},
|
|
242
251
|
});
|
|
252
|
+
/**
|
|
253
|
+
* Hydrate the builders from the stored document — ONCE per mounted record.
|
|
254
|
+
*
|
|
255
|
+
* 🛑 `hydratedRef` is not an optimisation. The old deps included the whole
|
|
256
|
+
* `sections` array, which is a fresh identity after every refetch — and with
|
|
257
|
+
* `refetchOnWindowFocus` plus a 30s `staleTime`, alt-tabbing away mid-edit
|
|
258
|
+
* and coming back re-ran this and silently reverted every builder change the
|
|
259
|
+
* admin had made. No error, no prompt, just their work gone.
|
|
260
|
+
*
|
|
261
|
+
* The ref is only correct because the caller remounts on `editId` (see the
|
|
262
|
+
* 🛑 on this component). Ref and key are a pair; neither works alone.
|
|
263
|
+
*/
|
|
264
|
+
const hydratedRef = React.useRef(false);
|
|
243
265
|
React.useEffect(() => {
|
|
244
|
-
if (
|
|
245
|
-
return;
|
|
246
|
-
const selected = sections.find((section) => section.id === selectedSectionId);
|
|
247
|
-
if (!selected)
|
|
266
|
+
if (!section || hydratedRef.current)
|
|
248
267
|
return;
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
268
|
+
hydratedRef.current = true;
|
|
269
|
+
setSectionType(section.type);
|
|
270
|
+
setEnabled(Boolean(section.enabled));
|
|
271
|
+
setOrder(String(section.order ?? ""));
|
|
272
|
+
const selectedConfig = (section.config ?? {});
|
|
273
|
+
setRawConfigJson(JSON.stringify(selectedConfig, null, 2));
|
|
274
|
+
switch (section.type) {
|
|
255
275
|
case "products":
|
|
256
276
|
setProductsBuilder(parseProductsBuilder(selectedConfig));
|
|
257
277
|
break;
|
|
@@ -328,7 +348,7 @@ export function AdminSectionsView({ children }) {
|
|
|
328
348
|
setCollectionCardsBuilder(parseCollectionCardsBuilder(selectedConfig));
|
|
329
349
|
break;
|
|
330
350
|
}
|
|
331
|
-
}, [
|
|
351
|
+
}, [section]);
|
|
332
352
|
/**
|
|
333
353
|
* D1 — set the builder for one section type, from a TEMPLATE or from the
|
|
334
354
|
* type's default.
|
|
@@ -434,11 +454,11 @@ export function AdminSectionsView({ children }) {
|
|
|
434
454
|
* template rather than its own 25-arm switch.
|
|
435
455
|
*/
|
|
436
456
|
React.useEffect(() => {
|
|
437
|
-
if (!
|
|
457
|
+
if (!isCreate)
|
|
438
458
|
return;
|
|
439
459
|
applyBuilderFor(sectionType);
|
|
440
460
|
setSelectedTemplateId("");
|
|
441
|
-
}, [
|
|
461
|
+
}, [isCreate, sectionType, applyBuilderFor]);
|
|
442
462
|
function toggleCategorySelection(ids, categoryId, checked) {
|
|
443
463
|
if (checked) {
|
|
444
464
|
if (ids.includes(categoryId)) {
|
|
@@ -448,90 +468,6 @@ export function AdminSectionsView({ children }) {
|
|
|
448
468
|
}
|
|
449
469
|
return ids.filter((id) => id !== categoryId);
|
|
450
470
|
}
|
|
451
|
-
function normalizeOrder(items) {
|
|
452
|
-
return items.map((item, index) => ({ ...item, order: index + 1 }));
|
|
453
|
-
}
|
|
454
|
-
function cloneReorderItems(items) {
|
|
455
|
-
return items.map((item) => ({ ...item }));
|
|
456
|
-
}
|
|
457
|
-
function isSameReorder(a, b) {
|
|
458
|
-
if (a.length !== b.length) {
|
|
459
|
-
return false;
|
|
460
|
-
}
|
|
461
|
-
return a.every((item, index) => {
|
|
462
|
-
const other = b[index];
|
|
463
|
-
return Boolean(other) && item.id === other.id && item.order === other.order;
|
|
464
|
-
});
|
|
465
|
-
}
|
|
466
|
-
function pushUndoSnapshot(snapshot) {
|
|
467
|
-
setReorderUndoStack((history) => [cloneReorderItems(snapshot), ...history].slice(0, 30));
|
|
468
|
-
}
|
|
469
|
-
function applyReorderChange(transform) {
|
|
470
|
-
setReorderDraft((prev) => {
|
|
471
|
-
const next = transform(prev);
|
|
472
|
-
if (isSameReorder(prev, next)) {
|
|
473
|
-
return prev;
|
|
474
|
-
}
|
|
475
|
-
pushUndoSnapshot(prev);
|
|
476
|
-
return next;
|
|
477
|
-
});
|
|
478
|
-
}
|
|
479
|
-
function moveReorderItem(index, direction) {
|
|
480
|
-
const targetIndex = index + direction;
|
|
481
|
-
if (targetIndex < 0 || targetIndex >= reorderDraft.length) {
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
applyReorderChange((prev) => {
|
|
485
|
-
const next = [...prev];
|
|
486
|
-
const [item] = next.splice(index, 1);
|
|
487
|
-
next.splice(targetIndex, 0, item);
|
|
488
|
-
return normalizeOrder(next);
|
|
489
|
-
});
|
|
490
|
-
}
|
|
491
|
-
function updateReorderItemOrder(index, nextOrder) {
|
|
492
|
-
applyReorderChange((prev) => {
|
|
493
|
-
const clamped = Math.min(Math.max(1, nextOrder), prev.length);
|
|
494
|
-
const currentIndex = index;
|
|
495
|
-
const sorted = [...prev];
|
|
496
|
-
const [item] = sorted.splice(currentIndex, 1);
|
|
497
|
-
sorted.splice(clamped - 1, 0, item);
|
|
498
|
-
return normalizeOrder(sorted);
|
|
499
|
-
});
|
|
500
|
-
}
|
|
501
|
-
function moveItemToIndex(fromIndex, toIndex) {
|
|
502
|
-
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0) {
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
applyReorderChange((prev) => {
|
|
506
|
-
if (fromIndex >= prev.length || toIndex >= prev.length) {
|
|
507
|
-
return prev;
|
|
508
|
-
}
|
|
509
|
-
const next = [...prev];
|
|
510
|
-
const [item] = next.splice(fromIndex, 1);
|
|
511
|
-
next.splice(toIndex, 0, item);
|
|
512
|
-
return normalizeOrder(next);
|
|
513
|
-
});
|
|
514
|
-
}
|
|
515
|
-
function handleReorderDrop(targetIndex) {
|
|
516
|
-
if (dragIndex !== null) {
|
|
517
|
-
moveItemToIndex(dragIndex, targetIndex);
|
|
518
|
-
}
|
|
519
|
-
setDragIndex(null);
|
|
520
|
-
}
|
|
521
|
-
function reindexDraft() {
|
|
522
|
-
applyReorderChange((prev) => normalizeOrder(prev));
|
|
523
|
-
}
|
|
524
|
-
function resetToServerOrder() {
|
|
525
|
-
applyReorderChange(() => cloneReorderItems(reorderServerSnapshot));
|
|
526
|
-
}
|
|
527
|
-
function undoReorderChange() {
|
|
528
|
-
if (reorderUndoStack.length === 0) {
|
|
529
|
-
return;
|
|
530
|
-
}
|
|
531
|
-
const [latest, ...rest] = reorderUndoStack;
|
|
532
|
-
setReorderUndoStack(rest);
|
|
533
|
-
setReorderDraft(cloneReorderItems(latest));
|
|
534
|
-
}
|
|
535
471
|
function renderProductsCategorySelectors() {
|
|
536
472
|
return (_jsxs(Stack, { gap: "sm", children: [_jsx(Text, { className: "tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Category selectors" }), categoriesQuery.isLoading ? (_jsx(Text, { size: "sm", color: "muted", children: "Loading categories..." })) : categoryOptions.length === 0 ? (_jsx(Text, { size: "sm", color: "muted", children: "No categories available." })) : (_jsx(Div, { layout: "grid", gap: "2", className: "grid-cols-1 sm:grid-cols-2", children: categoryOptions.map((category) => (_jsx(Checkbox, { checked: productsBuilder.selectedCategoryIds.includes(category.id), label: category.name, onChange: (event) => setProductsBuilder((prev) => ({
|
|
537
473
|
...prev,
|
|
@@ -968,22 +904,6 @@ export function AdminSectionsView({ children }) {
|
|
|
968
904
|
default: return null;
|
|
969
905
|
}
|
|
970
906
|
}
|
|
971
|
-
// React.useMemo must run unconditionally on every render — it was
|
|
972
|
-
// previously declared after the `hasChildren` early return below, which
|
|
973
|
-
// skips the hook call in passthrough mode, violating the Rules of Hooks.
|
|
974
|
-
const sectionOrderMap = React.useMemo(() => {
|
|
975
|
-
return new Map(sections.map((section) => [section.id, section.order]));
|
|
976
|
-
}, [sections]);
|
|
977
|
-
// Map sections data to listing rows
|
|
978
|
-
const rows = sections.map((section) => ({
|
|
979
|
-
id: section.id,
|
|
980
|
-
primary: section.type.charAt(0).toUpperCase() + section.type.slice(1).replace(/-/g, " "),
|
|
981
|
-
secondary: `Order: ${section.order} • ${section.enabled ? "Enabled" : "Disabled"}`,
|
|
982
|
-
status: section.enabled ? "Active" : "Inactive",
|
|
983
|
-
updatedAt: new Date(section.updatedAt).toLocaleDateString(),
|
|
984
|
-
}));
|
|
985
|
-
const hasReorderChanges = reorderDraft.some((item) => sectionOrderMap.get(item.id) !== item.order);
|
|
986
|
-
const canUndoReorderChanges = reorderUndoStack.length > 0;
|
|
987
907
|
/**
|
|
988
908
|
* The modal's fields, as three hand-written sections.
|
|
989
909
|
*
|
|
@@ -1004,21 +924,12 @@ export function AdminSectionsView({ children }) {
|
|
|
1004
924
|
label: "Section",
|
|
1005
925
|
required: true,
|
|
1006
926
|
fields: ["sectionType", "order", "enabled"],
|
|
1007
|
-
render: () => (_jsxs(Stack, { gap: "md", children: [_jsx(Select, { label: "
|
|
1008
|
-
{ label: "Create new section", value: "create" },
|
|
1009
|
-
{ label: "Edit existing section", value: "edit" },
|
|
1010
|
-
] }), mode === "edit" ? (_jsx(Select, { label: "Section", value: selectedSectionId, onValueChange: setSelectedSectionId, placeholder: "Select section", options: sections.map((section) => ({
|
|
1011
|
-
label: `${section.type} (#${section.order})`,
|
|
1012
|
-
value: section.id,
|
|
1013
|
-
})) })) : null, _jsx(Select, { label: "Section type", value: sectionType, onValueChange: (value) => {
|
|
1014
|
-
setSectionType(value);
|
|
1015
|
-
if (mode === "create") {
|
|
1016
|
-
setSelectedSectionId("");
|
|
1017
|
-
}
|
|
1018
|
-
}, options: SECTION_TYPE_OPTIONS.map((type) => ({
|
|
927
|
+
render: () => (_jsxs(Stack, { gap: "md", children: [_jsx(Select, { label: "Section type", value: sectionType, onValueChange: (value) => setSectionType(value), options: SECTION_TYPE_OPTIONS.map((type) => ({
|
|
1019
928
|
label: type,
|
|
1020
929
|
value: type,
|
|
1021
|
-
})), disabled:
|
|
930
|
+
})), disabled: !isCreate, helperText: isCreate
|
|
931
|
+
? undefined
|
|
932
|
+
: "A section's type is fixed — its config has a different shape per type." }), isCreate && duplicateOfType && (_jsxs(Text, { size: "xs", color: "warning", children: ["A ", sectionType, " section already exists on this surface (#", duplicateOfType.order, "). Every enabled section is rendered, so a second one shows this type twice."] })), isCreate && templatesForType(sectionType).length > 0 && (_jsxs(_Fragment, { children: [_jsx(Select, { label: "Start from a template", value: selectedTemplateId, onValueChange: (value) => {
|
|
1022
933
|
setSelectedTemplateId(value);
|
|
1023
934
|
// "" means the type's own defaults — `applyBuilderFor` takes
|
|
1024
935
|
// no template and behaves exactly as the reset effect does.
|
|
@@ -1032,7 +943,9 @@ export function AdminSectionsView({ children }) {
|
|
|
1032
943
|
return null;
|
|
1033
944
|
const p = profileById(t.sourceProfileId);
|
|
1034
945
|
return (_jsxs(Div, { surface: "muted", padding: "sm", rounded: "md", children: [t.notes && _jsx(Text, { size: "xs", color: "muted", children: t.notes }), p && (_jsxs(Text, { size: "xs", color: "faint", children: [p.label, " \u00B7 ", p.platform, " \u00B7 ", p.measured.products.toLocaleString(), " products \u00B7", " ", Math.round(p.measured.soldOutShare * 100), "% sold out", p.measured.uniqueInternalLinks ? ` · ${p.measured.uniqueInternalLinks} internal links` : ""] }))] }));
|
|
1035
|
-
})()] })), _jsx(Input, { label: "Order", type: "number", value: order, onChange: (event) => setOrder(event.target.value), placeholder: "Leave empty to auto-place"
|
|
946
|
+
})()] })), _jsx(Input, { label: "Order", type: "number", value: order, onChange: (event) => setOrder(event.target.value), placeholder: isCreate ? "Leave empty to auto-place" : "Leave empty to keep the current order", helperText: isCreate
|
|
947
|
+
? undefined
|
|
948
|
+
: "Reorder sections in the toolbar is the easier way to move several at once." }), _jsx(Select, { label: "Enabled", value: enabled ? "true" : "false", onValueChange: (value) => setEnabled(value === "true"), options: [
|
|
1036
949
|
{ label: "Enabled", value: "true" },
|
|
1037
950
|
{ label: "Disabled", value: "false" },
|
|
1038
951
|
] })] })),
|
|
@@ -1047,13 +960,21 @@ export function AdminSectionsView({ children }) {
|
|
|
1047
960
|
id: "config",
|
|
1048
961
|
label: isTypedBuilder ? "Generated config" : "Section config (JSON)",
|
|
1049
962
|
fields: ["configJson"],
|
|
1050
|
-
render: () => (_jsx(Textarea, { label: isTypedBuilder ? "Generated config (JSON preview)" : "Section config (JSON)", value: configJson, onChange: (event) =>
|
|
1051
|
-
? "
|
|
963
|
+
render: () => (_jsx(Textarea, { label: isTypedBuilder ? "Generated config (JSON preview)" : "Section config (JSON)", value: configJson, onChange: (event) => setRawConfigJson(event.target.value), rows: 10, readOnly: isTypedBuilder, helperText: isTypedBuilder
|
|
964
|
+
? "Generated from the typed controls above. It shows only the keys those controls know about — any other key already stored on this section is preserved on save and simply isn't shown here."
|
|
1052
965
|
: "Provide section config JSON for this section type." })),
|
|
1053
966
|
},
|
|
967
|
+
/*
|
|
968
|
+
* `selectedTemplateId` and `applyBuilderFor` are read by the template
|
|
969
|
+
* picker and were both missing from this list. The notes panel under the
|
|
970
|
+
* picker refreshed anyway — but only by ACCIDENT, because picking a
|
|
971
|
+
* template also mutates builder state, which changes `configJson`, which
|
|
972
|
+
* is in the list. Naming them makes the panel's correctness depend on the
|
|
973
|
+
* thing it actually reads.
|
|
974
|
+
*/
|
|
1054
975
|
], [
|
|
1055
|
-
|
|
1056
|
-
isTypedBuilder, configJson, renderTypedBuilder,
|
|
976
|
+
isCreate, sectionType, order, enabled, duplicateOfType, selectedTemplateId,
|
|
977
|
+
applyBuilderFor, isTypedBuilder, configJson, renderTypedBuilder,
|
|
1057
978
|
]);
|
|
1058
979
|
/** What the nav and the error summary key on. */
|
|
1059
980
|
const draft = React.useMemo(() => ({ sectionType, order, enabled, configJson }), [sectionType, order, enabled, configJson]);
|
|
@@ -1064,44 +985,385 @@ export function AdminSectionsView({ children }) {
|
|
|
1064
985
|
fieldToSectionIndex: nav.fieldToSectionIndex,
|
|
1065
986
|
});
|
|
1066
987
|
/*
|
|
1067
|
-
*
|
|
988
|
+
* A deep link (`?panel=edit&id=…`) can name a section that no longer
|
|
989
|
+
* exists — the delete confirm may have been accepted in another tab while
|
|
990
|
+
* this URL sat in history. Say so, rather than rendering a form bound to
|
|
991
|
+
* nothing and letting Save create a section the admin meant to edit.
|
|
992
|
+
*/
|
|
993
|
+
if (sectionId && !section && !isSectionsLoading) {
|
|
994
|
+
return (_jsxs(Stack, { gap: "md", padding: "md", children: [_jsx(Text, { size: "sm", color: "muted", children: "This section no longer exists \u2014 it may have been deleted." }), _jsx(Row, { children: _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: onDone, children: "Close" }) })] }));
|
|
995
|
+
}
|
|
996
|
+
return (_jsxs(FormShellContext.Provider, { value: form.shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: modalSections, values: draft,
|
|
997
|
+
/*
|
|
998
|
+
* Each control writes through its own setter — `setSectionType`,
|
|
999
|
+
* `setOrder`, `setEnabled` — and several do more than store a value.
|
|
1000
|
+
* So this routes nothing generically.
|
|
1001
|
+
*/
|
|
1002
|
+
onChange: () => undefined, onSubmit: () => {
|
|
1003
|
+
form.clearErrors();
|
|
1004
|
+
const parsed = homepageSectionFormSchema.safeParse(draft);
|
|
1005
|
+
if (!parsed.success) {
|
|
1006
|
+
applyZodIssues(parsed.error.issues, form.setFieldError);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
saveSection.mutate();
|
|
1010
|
+
}, onValidationChange: () => form.validate(draft), schema: homepageSectionFormSchema, openIds: nav.openIds, onOpenChange: nav.setOpenIds, submitLabel: saveSection.isPending
|
|
1011
|
+
? "Saving..."
|
|
1012
|
+
: isCreate
|
|
1013
|
+
? "Create section"
|
|
1014
|
+
: "Update section", cancelLabel: "Cancel", onCancel: onDone, isLoading: saveSection.isPending })] }));
|
|
1015
|
+
}
|
|
1016
|
+
/*
|
|
1017
|
+
* 🛑 Not registered in `filter-tabs.ts`. `audit-filter-tab-enums` cross-checks
|
|
1018
|
+
* chip ids against a backing STRING enum, and `enabled` is a boolean — the
|
|
1019
|
+
* same out-of-scope category as ADMIN_CART_OWNERSHIP_TABS.
|
|
1020
|
+
*/
|
|
1021
|
+
const SECTION_ENABLED_TABS = [
|
|
1022
|
+
ALL_TAB,
|
|
1023
|
+
{ id: "true", label: "Enabled" },
|
|
1024
|
+
{ id: "false", label: "Disabled" },
|
|
1025
|
+
];
|
|
1026
|
+
/** Reorder renumbers 1..N GLOBALLY — see the full-set query in the component. */
|
|
1027
|
+
function normalizeOrder(items) {
|
|
1028
|
+
return items.map((item, index) => ({ ...item, order: index + 1 }));
|
|
1029
|
+
}
|
|
1030
|
+
function cloneReorderItems(items) {
|
|
1031
|
+
return items.map((item) => ({ ...item }));
|
|
1032
|
+
}
|
|
1033
|
+
function isSameReorder(a, b) {
|
|
1034
|
+
if (a.length !== b.length)
|
|
1035
|
+
return false;
|
|
1036
|
+
return a.every((item, index) => {
|
|
1037
|
+
const other = b[index];
|
|
1038
|
+
return Boolean(other) && item.id === other.id && item.order === other.order;
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* The homepage/surface sections listing.
|
|
1043
|
+
*
|
|
1044
|
+
* Was a bare `<DataTable rows>` with no `columns`, no `renderRowActions`, no
|
|
1045
|
+
* `onRowClick` and no `rowHrefTemplate` — so `isInteractive` was false and
|
|
1046
|
+
* every row was inert, including the blank avatar circles that come from
|
|
1047
|
+
* `buildDefaultColumns(undefined)`. The only way to edit a section was a
|
|
1048
|
+
* toolbar button, a "Mode" dropdown, and a second dropdown to re-find the row
|
|
1049
|
+
* you had just clicked past.
|
|
1050
|
+
*/
|
|
1051
|
+
export function AdminSectionsView({ children }) {
|
|
1052
|
+
const hasChildren = React.Children.count(children) > 0;
|
|
1053
|
+
const queryClient = useQueryClient();
|
|
1054
|
+
const toast = useToast();
|
|
1055
|
+
const [reorderOpen, setReorderOpen] = React.useState(false);
|
|
1056
|
+
const [reorderDraft, setReorderDraft] = React.useState([]);
|
|
1057
|
+
const [reorderServerSnapshot, setReorderServerSnapshot] = React.useState([]);
|
|
1058
|
+
const [reorderUndoStack, setReorderUndoStack] = React.useState([]);
|
|
1059
|
+
const [dragIndex, setDragIndex] = React.useState(null);
|
|
1060
|
+
const [deleteTargetId, setDeleteTargetId] = React.useState(null);
|
|
1061
|
+
/*
|
|
1062
|
+
* 🛑 `config` is deliberately omitted from the PATCH:
|
|
1063
|
+
* `HomepageSectionsRepository.update` short-circuits its merge READ when
|
|
1064
|
+
* `data.config` is absent, so an enable/disable costs one write and no read.
|
|
1068
1065
|
*
|
|
1069
|
-
*
|
|
1070
|
-
*
|
|
1071
|
-
*
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1066
|
+
* The optimistic/rollback logic lives in the shared hook — see its header
|
|
1067
|
+
* for why a rollback must DELETE the override key rather than write back a
|
|
1068
|
+
* captured value, and why the key is cleared on success too.
|
|
1069
|
+
*/
|
|
1070
|
+
const enabledFlag = useOptimisticRowFlag({
|
|
1071
|
+
mutate: (id, next) => apiClient.patch(`${ADMIN_ENDPOINTS.SECTIONS}/${id}`, { enabled: next }),
|
|
1072
|
+
// The public homepage reads under a key of its own; both must be busted.
|
|
1073
|
+
invalidate: [["admin", "sections", "listing"], ["homepage-sections"]],
|
|
1074
|
+
successMessage: (next) => (next ? "Section enabled." : "Section disabled."),
|
|
1075
|
+
errorMessage: "Failed to update section.",
|
|
1076
|
+
});
|
|
1077
|
+
/*
|
|
1078
|
+
* The reorder panel needs EVERY section, not the current page.
|
|
1079
|
+
* `normalizeOrder` assigns `index + 1` globally, so feeding it the
|
|
1080
|
+
* DataListingView rows — which are paginated, filtered and sortable — would
|
|
1081
|
+
* renumber a partial set 1..N and PATCH wrong orders onto real rows.
|
|
1082
|
+
*/
|
|
1083
|
+
const { sections, total, isLoading } = useAdminSectionsListing({ page: 1, pageSize: 50 });
|
|
1084
|
+
const isReorderTruncated = total > sections.length;
|
|
1085
|
+
const sectionOrderMap = React.useMemo(() => new Map(sections.map((section) => [section.id, section.order])), [sections]);
|
|
1086
|
+
React.useEffect(() => {
|
|
1087
|
+
const sorted = [...sections]
|
|
1088
|
+
.sort((a, b) => a.order - b.order)
|
|
1089
|
+
.map((section) => ({ id: section.id, type: section.type, order: section.order }));
|
|
1090
|
+
setReorderDraft(normalizeOrder(sorted));
|
|
1091
|
+
/*
|
|
1092
|
+
* 🛑 The RAW server orders, not the normalized ones.
|
|
1093
|
+
*
|
|
1094
|
+
* Orders can legitimately have a gap — a delete leaves one — and
|
|
1095
|
+
* snapshotting the normalized list claims the server holds 1..N when it
|
|
1096
|
+
* does not. "Reset to server" would then reset to an order the server
|
|
1097
|
+
* never had, and `hasReorderChanges` (which compares against the real
|
|
1098
|
+
* `sectionOrderMap`) could not agree with it. Unreachable while every row
|
|
1099
|
+
* was 1..N; reachable the moment delete shipped.
|
|
1100
|
+
*/
|
|
1101
|
+
setReorderServerSnapshot(sorted);
|
|
1102
|
+
setReorderUndoStack([]);
|
|
1103
|
+
}, [sections]);
|
|
1104
|
+
const invalidateSections = React.useCallback(async () => {
|
|
1105
|
+
try {
|
|
1106
|
+
await queryClient.invalidateQueries({ queryKey: ["admin", "sections", "listing"] });
|
|
1107
|
+
// Public-facing useHomepageSections() reads under a separate key.
|
|
1108
|
+
await queryClient.invalidateQueries({ queryKey: ["homepage-sections"] });
|
|
1109
|
+
}
|
|
1110
|
+
catch (err) {
|
|
1111
|
+
/*
|
|
1112
|
+
* A refetch that fails after a SUCCESSFUL write is not a failed write,
|
|
1113
|
+
* so this must not raise — doing so would report "Failed to delete
|
|
1114
|
+
* section" for a section that is gone. The list is stale rather than
|
|
1115
|
+
* wrong, and says so.
|
|
1116
|
+
*/
|
|
1117
|
+
const normalized = normalizeError(err);
|
|
1118
|
+
toast.showToast(toUserMessage(normalized.code, undefined, { fallback: "Saved, but the list could not refresh. Reload to see it." }), "warning");
|
|
1119
|
+
}
|
|
1120
|
+
}, [queryClient, toast]);
|
|
1121
|
+
const deleteSection = useApiMutation({
|
|
1122
|
+
errorMessage: "Failed to delete section.",
|
|
1123
|
+
mutationFn: async (id) => {
|
|
1124
|
+
await apiClient.delete(`${ADMIN_ENDPOINTS.SECTIONS}/${id}`);
|
|
1125
|
+
},
|
|
1126
|
+
onSuccess: async () => {
|
|
1127
|
+
await invalidateSections();
|
|
1128
|
+
toast.showToast("Section deleted.", "success");
|
|
1129
|
+
},
|
|
1130
|
+
});
|
|
1131
|
+
const reorderSections = useApiMutation({
|
|
1132
|
+
errorMessage: "Failed to reorder sections.",
|
|
1133
|
+
mutationFn: async () => {
|
|
1134
|
+
await Promise.all(reorderDraft.map((item) => apiClient.patch(`${ADMIN_ENDPOINTS.SECTIONS}/${item.id}`, { order: item.order })));
|
|
1135
|
+
},
|
|
1136
|
+
onSuccess: async () => {
|
|
1137
|
+
await invalidateSections();
|
|
1138
|
+
setReorderServerSnapshot(cloneReorderItems(reorderDraft));
|
|
1139
|
+
setReorderUndoStack([]);
|
|
1140
|
+
toast.showToast("Section order updated.", "success");
|
|
1141
|
+
},
|
|
1142
|
+
});
|
|
1143
|
+
const pushUndoSnapshot = React.useCallback((snapshot) => {
|
|
1144
|
+
setReorderUndoStack((history) => [cloneReorderItems(snapshot), ...history].slice(0, 30));
|
|
1145
|
+
}, []);
|
|
1146
|
+
const applyReorderChange = React.useCallback((transform) => {
|
|
1147
|
+
setReorderDraft((prev) => {
|
|
1148
|
+
const next = transform(prev);
|
|
1149
|
+
if (isSameReorder(prev, next))
|
|
1150
|
+
return prev;
|
|
1151
|
+
pushUndoSnapshot(prev);
|
|
1152
|
+
return next;
|
|
1153
|
+
});
|
|
1154
|
+
}, [pushUndoSnapshot]);
|
|
1155
|
+
function moveReorderItem(index, direction) {
|
|
1156
|
+
const targetIndex = index + direction;
|
|
1157
|
+
if (targetIndex < 0 || targetIndex >= reorderDraft.length)
|
|
1158
|
+
return;
|
|
1159
|
+
applyReorderChange((prev) => {
|
|
1160
|
+
const next = [...prev];
|
|
1161
|
+
const [item] = next.splice(index, 1);
|
|
1162
|
+
next.splice(targetIndex, 0, item);
|
|
1163
|
+
return normalizeOrder(next);
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
function updateReorderItemOrder(index, nextOrder) {
|
|
1167
|
+
applyReorderChange((prev) => {
|
|
1168
|
+
const clamped = Math.min(Math.max(1, nextOrder), prev.length);
|
|
1169
|
+
const sorted = [...prev];
|
|
1170
|
+
const [item] = sorted.splice(index, 1);
|
|
1171
|
+
sorted.splice(clamped - 1, 0, item);
|
|
1172
|
+
return normalizeOrder(sorted);
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
function moveItemToIndex(fromIndex, toIndex) {
|
|
1176
|
+
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0)
|
|
1177
|
+
return;
|
|
1178
|
+
applyReorderChange((prev) => {
|
|
1179
|
+
if (fromIndex >= prev.length || toIndex >= prev.length)
|
|
1180
|
+
return prev;
|
|
1181
|
+
const next = [...prev];
|
|
1182
|
+
const [item] = next.splice(fromIndex, 1);
|
|
1183
|
+
next.splice(toIndex, 0, item);
|
|
1184
|
+
return normalizeOrder(next);
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
function handleReorderDrop(targetIndex) {
|
|
1188
|
+
if (dragIndex !== null)
|
|
1189
|
+
moveItemToIndex(dragIndex, targetIndex);
|
|
1190
|
+
setDragIndex(null);
|
|
1191
|
+
}
|
|
1192
|
+
function reindexDraft() {
|
|
1193
|
+
applyReorderChange((prev) => normalizeOrder(prev));
|
|
1194
|
+
}
|
|
1195
|
+
function resetToServerOrder() {
|
|
1196
|
+
applyReorderChange(() => cloneReorderItems(reorderServerSnapshot));
|
|
1197
|
+
}
|
|
1198
|
+
function undoReorderChange() {
|
|
1199
|
+
if (reorderUndoStack.length === 0)
|
|
1200
|
+
return;
|
|
1201
|
+
const [latest, ...rest] = reorderUndoStack;
|
|
1202
|
+
setReorderUndoStack(rest);
|
|
1203
|
+
setReorderDraft(cloneReorderItems(latest));
|
|
1204
|
+
}
|
|
1205
|
+
const hasReorderChanges = reorderDraft.some((item) => sectionOrderMap.get(item.id) !== item.order);
|
|
1206
|
+
const canUndoReorderChanges = reorderUndoStack.length > 0;
|
|
1207
|
+
const config = {
|
|
1208
|
+
portal: "admin",
|
|
1209
|
+
title: "Homepage Sections",
|
|
1210
|
+
subtitle: "The blocks that make up the homepage, in order.",
|
|
1211
|
+
// Search intentionally absent: this endpoint does not read `q`, and the
|
|
1212
|
+
// repository's sieveQuery has no text-search concept at all, so the box
|
|
1213
|
+
// would accept typing and change nothing — audit-listing-search-capability
|
|
1214
|
+
// tracks it.
|
|
1215
|
+
emptyLabel: "No sections yet",
|
|
1216
|
+
queryKey: ["admin", "sections", "listing"],
|
|
1217
|
+
endpoint: ADMIN_ENDPOINTS.SECTIONS,
|
|
1218
|
+
pageSize: 50,
|
|
1219
|
+
initialView: "table",
|
|
1220
|
+
filterKeys: ["enabled"],
|
|
1221
|
+
defaultSort: sortBy("order", "ASC"),
|
|
1222
|
+
/*
|
|
1223
|
+
* 🛑 TWO options, and the omissions are measured rather than lazy.
|
|
1224
|
+
*
|
|
1225
|
+
* The ONLY composite index on `homepageSections` is (enabled ASC, order
|
|
1226
|
+
* ASC). `canFilter`/`canSort` in SIEVE_FIELDS govern whether sievejs
|
|
1227
|
+
* BUILDS a clause; the index governs whether Firestore SERVES it:
|
|
1228
|
+
*
|
|
1229
|
+
* • "Type A–Z" — `type` is canSort:false and sieve runs with
|
|
1230
|
+
* throwExceptions:false, so the clause is dropped SILENTLY. The
|
|
1231
|
+
* dropdown would change and the list would not (Root Cause #62).
|
|
1232
|
+
* • "-updatedAt" / "-createdAt" — these ARE canSort:true, so they look
|
|
1233
|
+
* safe. They work alone and FAILED_PRECONDITION the moment the
|
|
1234
|
+
* Enabled filter is also active, because no (enabled, updatedAt)
|
|
1235
|
+
* index exists. A sort that breaks only in combination is worse than
|
|
1236
|
+
* one that is absent. Add the index first if either is wanted.
|
|
1237
|
+
*/
|
|
1238
|
+
sortOptions: [
|
|
1239
|
+
{ value: sortBy("order", "ASC"), label: "Display order" },
|
|
1240
|
+
{ value: sortBy("order"), label: "Display order (last first)" },
|
|
1241
|
+
],
|
|
1242
|
+
mapRows: (response) => toRecordArray(response.items).map((item, index) => {
|
|
1243
|
+
const id = toStringValue(item.id, `section-${index}`);
|
|
1244
|
+
const type = toStringValue(item.type, "unknown");
|
|
1245
|
+
const surface = toStringValue(item.surface, "homepage");
|
|
1246
|
+
const enabled = enabledFlag.resolve(id, Boolean(item.enabled));
|
|
1247
|
+
return {
|
|
1248
|
+
id,
|
|
1249
|
+
primary: type.charAt(0).toUpperCase() + type.slice(1).replace(/-/g, " "),
|
|
1250
|
+
/*
|
|
1251
|
+
* The ID, not "Order: N • Enabled" — both of those are their own
|
|
1252
|
+
* columns now, and the id is the only thing that distinguishes two
|
|
1253
|
+
* sections of the SAME type, a state the id generator's timestamp
|
|
1254
|
+
* suffix makes reachable. `surface` shows only when it is not the
|
|
1255
|
+
* default, so a homepage row carries no redundant word.
|
|
1256
|
+
*/
|
|
1257
|
+
secondary: surface === "homepage" ? id : `${id} · ${surface}`,
|
|
1258
|
+
status: enabled ? "Active" : "Inactive",
|
|
1259
|
+
// Not `new Date(...).toLocaleDateString()`: that yields "Invalid
|
|
1260
|
+
// Date" on a Firestore {toDate()} shape.
|
|
1261
|
+
updatedAt: toRelativeDate(item.updatedAt ?? item.createdAt),
|
|
1262
|
+
enabled,
|
|
1263
|
+
order: typeof item.order === "number" ? item.order : 0,
|
|
1264
|
+
};
|
|
1265
|
+
}),
|
|
1266
|
+
getTotal: (response, mappedRows) => typeof response.total === "number" ? response.total : mappedRows.length,
|
|
1267
|
+
/*
|
|
1268
|
+
* The explicit "true"/"false" test is load-bearing. Anything else yields
|
|
1269
|
+
* NO clause, so a hand-typed `?enabled=banana` shows every row — rather
|
|
1270
|
+
* than a clause that matches nothing and renders as a confident empty
|
|
1271
|
+
* list, which is the shape that reads as an answer.
|
|
1272
|
+
*/
|
|
1273
|
+
buildFilters: (state) => state.enabled === "true" || state.enabled === "false"
|
|
1274
|
+
? sieveFilter("enabled", SIEVE_OP.EQ, state.enabled === "true")
|
|
1275
|
+
: undefined,
|
|
1276
|
+
renderFilterPanel: ({ pendingFilters, setPendingFilters }) => (_jsx(FilterChipGroup, { label: "Status", tabs: SECTION_ENABLED_TABS, value: pendingFilters.enabled ?? "", onChange: (id) => setPendingFilters((prev) => ({ ...prev, enabled: id })) })),
|
|
1277
|
+
columns: [
|
|
1278
|
+
{
|
|
1279
|
+
key: "primary",
|
|
1280
|
+
header: "Section",
|
|
1281
|
+
render: (row) => (_jsxs(Stack, { gap: "none", className: "min-w-0", children: [_jsx(Text, { weight: "medium", color: "primary", className: "truncate", children: row.primary }), _jsx(Text, { size: "xs", color: "muted", family: "mono", className: "truncate", children: row.secondary })] })),
|
|
1282
|
+
},
|
|
1283
|
+
{
|
|
1284
|
+
key: "order",
|
|
1285
|
+
header: "Order",
|
|
1286
|
+
className: "w-20",
|
|
1287
|
+
render: (row) => _jsx(Span, { size: "sm", color: "muted", numeric: true, children: String(row.order) }),
|
|
1288
|
+
},
|
|
1289
|
+
{
|
|
1290
|
+
key: "status",
|
|
1291
|
+
header: "Status",
|
|
1292
|
+
className: "w-28",
|
|
1293
|
+
render: (row) => (_jsx(Badge, { variant: row.enabled ? "active" : "inactive", size: "xs", children: row.enabled ? "Enabled" : "Disabled" })),
|
|
1294
|
+
},
|
|
1295
|
+
{
|
|
1296
|
+
key: "updatedAt",
|
|
1297
|
+
header: "Updated",
|
|
1298
|
+
className: "w-32",
|
|
1299
|
+
render: (row) => _jsx(Span, { size: "sm", color: "muted", children: row.updatedAt }),
|
|
1300
|
+
},
|
|
1301
|
+
],
|
|
1302
|
+
/*
|
|
1303
|
+
* 🛑 The toggle lives HERE and not in a column, on purpose.
|
|
1304
|
+
*
|
|
1305
|
+
* `initialView` only applies to an admin with no saved view preference,
|
|
1306
|
+
* and DataListingView suppresses the table entirely below `md` — so a
|
|
1307
|
+
* column-only control is invisible to anyone on a phone and to anyone
|
|
1308
|
+
* whose stored preference is the card view. `renderRowActions` renders
|
|
1309
|
+
* inside an already-click-stopped container in all three renderers (table
|
|
1310
|
+
* cell, list card, grid card), so unlike a normal column cell it needs no
|
|
1311
|
+
* stopPropagation wrapper of its own.
|
|
1312
|
+
*
|
|
1313
|
+
* `ariaLabel` carries the record name because a page of 28 switches that
|
|
1314
|
+
* all announce "Toggle" names nothing.
|
|
1315
|
+
*/
|
|
1316
|
+
renderRowActions: (row) => (_jsxs(_Fragment, { children: [_jsx(Toggle, { size: "sm", checked: row.enabled, ariaLabel: row.enabled
|
|
1317
|
+
? `${ACTIONS.ADMIN["disable-section"].label} ${row.primary}`
|
|
1318
|
+
: `${ACTIONS.ADMIN["enable-section"].label} ${row.primary}`, onChange: (next) => {
|
|
1319
|
+
void enabledFlag.set(row.id, next);
|
|
1320
|
+
} }), _jsx(QuickEditMenu, { actions: [
|
|
1321
|
+
{
|
|
1322
|
+
label: ACTIONS.ADMIN["delete-section"].label,
|
|
1323
|
+
destructive: true,
|
|
1324
|
+
onClick: () => setDeleteTargetId(row.id),
|
|
1325
|
+
},
|
|
1326
|
+
] })] })),
|
|
1327
|
+
primaryAction: {
|
|
1328
|
+
label: "Add Section",
|
|
1329
|
+
onClick: ({ openCreatePanel }) => openCreatePanel(),
|
|
1330
|
+
},
|
|
1331
|
+
toolbarExtra: (_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setReorderOpen(true), children: "Reorder sections" })),
|
|
1332
|
+
/*
|
|
1333
|
+
* 🛑 `key` is REQUIRED — see the comment on SectionEditorPanel. Without it,
|
|
1334
|
+
* clicking row B's pencil while row A's drawer is open edits B's record
|
|
1335
|
+
* with A's values.
|
|
1336
|
+
*
|
|
1337
|
+
* No `onRowClick`: declaring one would OVERRIDE DataListingView's default,
|
|
1338
|
+
* which already opens this editor. `renderEditor` alone is what makes the
|
|
1339
|
+
* rows clickable and injects the per-row pencil.
|
|
1340
|
+
*/
|
|
1341
|
+
renderEditor: ({ editId, closePanel }) => (_jsx(SectionEditorPanel, { sectionId: editId ?? undefined,
|
|
1342
|
+
/*
|
|
1343
|
+
* From the parent's own full-set query, NOT from the mapped listing
|
|
1344
|
+
* rows: a `?panel=edit&id=…` deep link to a row that is filtered out
|
|
1345
|
+
* or on another page must still resolve, and the row shape has
|
|
1346
|
+
* already dropped `config`.
|
|
1347
|
+
*/
|
|
1348
|
+
section: editId ? sections.find((s) => s.id === editId) : undefined, siblingSections: sections, isSectionsLoading: isLoading, onDone: closePanel }, editId ?? "create")),
|
|
1349
|
+
resolveEditorTitle: ({ isCreate }) => isCreate ? "Add Homepage Section" : "Edit Homepage Section",
|
|
1350
|
+
};
|
|
1351
|
+
/*
|
|
1352
|
+
* Passthrough mode (detail view).
|
|
1076
1353
|
*
|
|
1077
|
-
*
|
|
1078
|
-
*
|
|
1079
|
-
*
|
|
1080
|
-
*
|
|
1081
|
-
*
|
|
1082
|
-
*
|
|
1354
|
+
* 🛑 Still below every hook. The list it guards shrank a lot when the editor
|
|
1355
|
+
* moved into its own component — `modalSections`, `draft`, `nav` and `form`
|
|
1356
|
+
* all left — but the reason the return sits here is unchanged, and the
|
|
1357
|
+
* remaining hooks are no less order-sensitive: a short-circuit above them
|
|
1358
|
+
* skips their calls in passthrough mode, so the hook ORDER differs between
|
|
1359
|
+
* the two render paths and React reads one hook's state out of another's
|
|
1360
|
+
* slot. That bug has now been fixed here twice.
|
|
1083
1361
|
*/
|
|
1084
1362
|
if (hasChildren) {
|
|
1085
1363
|
return _jsx(_Fragment, { children: children });
|
|
1086
1364
|
}
|
|
1087
|
-
return (_jsxs(_Fragment, { children: [
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
* store a value (choosing a type in create mode also clears the
|
|
1092
|
-
* selected section). So this routes nothing generically.
|
|
1093
|
-
*/
|
|
1094
|
-
onChange: () => undefined, onSubmit: () => {
|
|
1095
|
-
form.clearErrors();
|
|
1096
|
-
const parsed = homepageSectionFormSchema.safeParse(draft);
|
|
1097
|
-
if (!parsed.success) {
|
|
1098
|
-
applyZodIssues(parsed.error.issues, form.setFieldError);
|
|
1099
|
-
return;
|
|
1100
|
-
}
|
|
1101
|
-
saveSection.mutate();
|
|
1102
|
-
}, onValidationChange: () => form.validate(draft), schema: homepageSectionFormSchema, openIds: nav.openIds, onOpenChange: nav.setOpenIds, submitLabel: saveSection.isPending
|
|
1103
|
-
? "Saving..."
|
|
1104
|
-
: mode === "create"
|
|
1105
|
-
? "Create section"
|
|
1106
|
-
: "Update section", cancelLabel: "Cancel", onCancel: () => setIsModalOpen(false), isLoading: saveSection.isPending })] }) })] }));
|
|
1365
|
+
return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(SideDrawer, { isOpen: reorderOpen, onClose: () => setReorderOpen(false), title: "Reorder sections", footer: _jsxs(Row, { align: "center", justify: "end", gap: "sm", wrap: true, children: [_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: reindexDraft, disabled: reorderSections.isPending || reorderDraft.length === 0, children: "Reindex 1..N" }), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: undoReorderChange, disabled: reorderSections.isPending || !canUndoReorderChanges, children: "Undo unsaved" }), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: resetToServerOrder, disabled: reorderSections.isPending || !hasReorderChanges, children: "Reset to server" }), _jsx(Button, { type: "button", variant: "primary", size: "sm", onClick: () => reorderSections.mutate(), disabled: !hasReorderChanges || reorderSections.isPending || isReorderTruncated, children: reorderSections.isPending ? "Saving order..." : "Save order" })] }), children: _jsxs(Stack, { gap: "sm", children: [isReorderTruncated && (_jsxs(Div, { textSize: "sm", color: "error", surface: "danger-surface", padding: "inline", rounded: "xl", children: ["Showing ", sections.length, " of ", total, " sections. Saving a partial set would renumber the rest, so Save order is disabled."] })), reorderDraft.length === 0 ? (_jsx(Text, { size: "sm", color: "muted", children: "No sections to reorder." })) : (reorderDraft.map((item, index) => (_jsxs(Div, { layout: "grid", gap: "2", align: "center", className: "grid-cols-[auto_1fr_auto_auto_auto]", rounded: "md", padding: "xs", border: "default", draggable: true, onDragStart: () => setDragIndex(index), onDragOver: (event) => event.preventDefault(), onDrop: () => handleReorderDrop(index), onDragEnd: () => setDragIndex(null), children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "\u2261" }), _jsxs(Text, { size: "sm", color: "primary", children: [item.type, " #", item.order] }), _jsx(Input, { type: "number", min: 1, max: reorderDraft.length, value: String(item.order), "aria-label": `Order for ${item.type}`, onChange: (event) => updateReorderItemOrder(index, Number(event.target.value) || 1), className: "w-24" }), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => moveReorderItem(index, -1), disabled: index === 0, children: "Up" }), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => moveReorderItem(index, 1), disabled: index === reorderDraft.length - 1, children: "Down" })] }, `reorder-${item.id}`))))] }) }), deleteTargetId && (_jsx(ConfirmDeleteModal, { isOpen: true, title: ACTIONS.ADMIN["delete-section"].confirmation?.title, message: ACTIONS.ADMIN["delete-section"].confirmation?.body, confirmText: ACTIONS.ADMIN["delete-section"].confirmation?.confirmLabel, isDeleting: deleteSection.isPending, onConfirm: () => {
|
|
1366
|
+
deleteSection.mutate(deleteTargetId);
|
|
1367
|
+
setDeleteTargetId(null);
|
|
1368
|
+
}, onClose: () => setDeleteTargetId(null) }))] }));
|
|
1107
1369
|
}
|