@mohasinac/appkit 4.60.1 → 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/features/auctions/actions.d.ts +1 -1
- package/dist/_internal/server/features/auctions/actions.js +12 -1
- package/dist/_internal/server/features/blog/actions.d.ts +1 -1
- package/dist/_internal/server/features/blog/actions.js +12 -1
- package/dist/_internal/server/features/brands/actions.d.ts +1 -1
- package/dist/_internal/server/features/brands/actions.js +12 -1
- package/dist/_internal/server/features/cart/actions.d.ts +1 -1
- package/dist/_internal/server/features/cart/actions.js +12 -1
- package/dist/_internal/server/features/checkout/actions.js +0 -1
- package/dist/_internal/server/features/events/actions.d.ts +1 -1
- package/dist/_internal/server/features/events/actions.js +12 -1
- package/dist/_internal/server/features/gateway-events/redact.js +14 -2
- package/dist/_internal/server/features/history/actions.d.ts +1 -1
- package/dist/_internal/server/features/history/actions.js +12 -1
- package/dist/_internal/server/features/lottery/actions.d.ts +1 -1
- package/dist/_internal/server/features/lottery/actions.js +12 -1
- package/dist/_internal/server/features/orders/actions.d.ts +1 -1
- package/dist/_internal/server/features/orders/actions.js +12 -1
- package/dist/_internal/server/features/orders/cancellation-refund.js +0 -1
- package/dist/_internal/server/features/orders/service.js +31 -1
- package/dist/_internal/server/features/payouts/actions.d.ts +1 -1
- package/dist/_internal/server/features/payouts/actions.js +12 -1
- package/dist/_internal/server/features/points/award.js +9 -1
- package/dist/_internal/server/features/pre-orders/actions.d.ts +1 -1
- package/dist/_internal/server/features/pre-orders/actions.js +12 -1
- package/dist/_internal/server/features/prize-draws/actions.d.ts +1 -1
- package/dist/_internal/server/features/prize-draws/actions.js +12 -1
- package/dist/_internal/server/features/products/actions.d.ts +1 -1
- package/dist/_internal/server/features/products/actions.js +12 -1
- package/dist/_internal/server/features/promotions/actions.d.ts +1 -1
- package/dist/_internal/server/features/promotions/actions.js +12 -1
- package/dist/_internal/server/features/protection-claims/actions.d.ts +2 -2
- package/dist/_internal/server/features/protection-claims/actions.js +21 -12
- package/dist/_internal/server/features/raffle/actions.d.ts +1 -1
- package/dist/_internal/server/features/raffle/actions.js +12 -1
- package/dist/_internal/server/features/refunds/actions.d.ts +1 -3
- package/dist/_internal/server/features/refunds/actions.js +14 -2
- package/dist/_internal/server/features/reviews/actions.d.ts +1 -1
- package/dist/_internal/server/features/reviews/actions.js +12 -1
- package/dist/_internal/server/features/wishlist/actions.d.ts +1 -1
- package/dist/_internal/server/features/wishlist/actions.js +12 -1
- package/dist/_internal/server/jobs/core/obligationSweep.js +0 -1
- package/dist/_internal/server/jobs/core/paymentReconcile.js +93 -76
- package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
- package/dist/_internal/server/jobs/core/testDataCleanup.js +22 -1
- package/dist/_internal/server/jobs/handlers/onShipmentAllocationSync.js +16 -4
- package/dist/_internal/shared/actions/action-registry.js +149 -9
- package/dist/_internal/shared/features/promotions/schema.d.ts +66 -0
- package/dist/_internal/shared/features/promotions/schema.js +7 -0
- package/dist/_internal/shared/media/limits.d.ts +10 -0
- package/dist/_internal/shared/media/limits.js +10 -0
- package/dist/_internal/shared/obligations/deadlines.js +11 -1
- package/dist/_internal/shared/tester/window.d.ts +25 -0
- package/dist/_internal/shared/tester/window.js +66 -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/core/unit-of-work.js +15 -1
- package/dist/errors/error-mapping.js +26 -1
- package/dist/errors/firestore-codes.d.ts +37 -0
- package/dist/errors/firestore-codes.js +52 -0
- package/dist/errors/index.d.ts +0 -1
- package/dist/errors/index.js +12 -2
- 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/AdminCouponEditorView.js +8 -0
- package/dist/features/admin/components/AdminFulfillmentView.js +3 -2
- package/dist/features/admin/components/AdminGatewayEventsView.js +2 -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/DataListingView.js +2 -0
- package/dist/features/admin/components/ReferenceRecordEditor.js +1 -1
- package/dist/features/admin/components/analytics/AdminStatCard.js +4 -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/repository/site-settings.repository.d.ts +2 -1
- package/dist/features/admin/repository/site-settings.repository.js +7 -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/auctions/components/MarketplaceAuctionCard.js +1 -1
- package/dist/features/auth/components/RegisterForm.js +2 -1
- package/dist/features/catalogue/actions/approve-catalogue-listing.d.ts +1 -1
- package/dist/features/catalogue/actions/approve-catalogue-listing.js +12 -1
- package/dist/features/catalogue/actions/list-from-catalogue.d.ts +1 -1
- package/dist/features/catalogue/actions/list-from-catalogue.js +12 -1
- package/dist/features/catalogue/actions/submit-for-approval.d.ts +1 -1
- package/dist/features/catalogue/actions/submit-for-approval.js +12 -1
- package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -3
- package/dist/features/categories/repository/price-observation.repository.d.ts +19 -0
- package/dist/features/categories/repository/price-observation.repository.js +21 -2
- package/dist/features/events/actions/event-actions.js +4 -1
- package/dist/features/events/components/AdminEventEditorView.js +79 -50
- package/dist/features/events/components/EventsListPageView.js +2 -1
- package/dist/features/events/schemas/firestore.d.ts +17 -0
- package/dist/features/events/schemas/firestore.js +1 -0
- package/dist/features/events/utils/sort-pinned.d.ts +33 -0
- package/dist/features/events/utils/sort-pinned.js +46 -0
- package/dist/features/gateway-events/repository/gateway-events.repository.js +3 -40
- package/dist/features/gateway-events/schemas/index.d.ts +2 -2
- package/dist/features/inventory/components/AdminStorageBoxesView.js +55 -39
- package/dist/features/inventory/components/ReceiveStationView.js +61 -17
- package/dist/features/inventory/repository/storage-boxes.repository.d.ts +3 -1
- package/dist/features/inventory/repository/storage-boxes.repository.js +36 -3
- package/dist/features/layout/hooks/useFormBottomActions.js +20 -1
- package/dist/features/obligations/repository/obligations.repository.js +10 -1
- package/dist/features/orders/components/EmiInstallmentsPanel.js +25 -7
- package/dist/features/orders/components/RefundRequestView.js +4 -4
- package/dist/features/orders/schemas/firestore.d.ts +19 -0
- package/dist/features/orders/schemas/firestore.js +19 -0
- package/dist/features/orders/schemas/index.d.ts +6 -6
- package/dist/features/payment-cases/repository/payment-cases.repository.d.ts +1 -0
- package/dist/features/payment-cases/repository/payment-cases.repository.js +3 -14
- package/dist/features/pre-orders/components/MarketplacePreorderCard.js +1 -1
- package/dist/features/products/components/GroupMemberPicker.js +4 -1
- package/dist/features/products/components/MarketplaceBundleCard.js +1 -1
- package/dist/features/products/components/MarketplacePrizeDrawCard.js +1 -1
- package/dist/features/products/components/PrizeDrawEntryActions.js +2 -1
- package/dist/features/products/components/ProductDetailActions.js +2 -1
- package/dist/features/products/components/ProductForm.js +2 -2
- package/dist/features/products/components/ProductGrid.js +1 -1
- package/dist/features/promotions/repository/claimed-coupons.repository.js +31 -1
- package/dist/features/promotions/schemas/firestore.d.ts +22 -0
- package/dist/features/promotions/schemas/firestore.js +22 -0
- package/dist/features/promotions/schemas/index.d.ts +479 -6
- package/dist/features/promotions/schemas/index.js +153 -0
- package/dist/features/protection-claims/repository/protection-claims.repository.js +22 -15
- 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/seller/schemas/coupon-form.d.ts +5 -0
- package/dist/features/seller/schemas/coupon-form.js +18 -0
- package/dist/features/shell/SectionForm.js +35 -9
- 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/sticker-trace/server.d.ts +2 -0
- package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +3 -2
- package/dist/http/api-handler.js +4 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.js +6 -4
- package/dist/next/routing/route-map.d.ts +4 -2
- package/dist/next/routing/route-map.js +19 -1
- package/dist/schemas/registry.d.ts +15 -10
- package/dist/schemas/sieve.d.ts +3 -3
- package/dist/server.d.ts +1 -2
- package/dist/server.js +6 -4
- package/dist/styles.css +51 -0
- 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 +0 -0
- 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
- package/scripts/seed-cli.mjs +1822 -1812
- package/dist/features/tester/utils/phases.d.ts +0 -48
- package/dist/features/tester/utils/phases.js +0 -139
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Firestore / gRPC error-code predicates, in one place.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this file exists
|
|
5
|
+
*
|
|
6
|
+
* `isAlreadyExists` had been written THREE times — in the gateway-events,
|
|
7
|
+
* payment-cases and protection-claims repositories, two of them byte-identical
|
|
8
|
+
* — and each copy duck-typed the caught error the same way:
|
|
9
|
+
*
|
|
10
|
+
* const code = (err as { code?: unknown }).code;
|
|
11
|
+
*
|
|
12
|
+
* That is past the Rule of Three, and `audit-unknown-leakage` was reporting all
|
|
13
|
+
* six of those field reads: casting to `{ code?: unknown }` narrows the OBJECT
|
|
14
|
+
* and leaves the FIELD unknown, so every comparison below it is unchecked.
|
|
15
|
+
* Naming the shape once gives the comparisons a real type and leaves one
|
|
16
|
+
* implementation to fix when the SDK changes what it throws.
|
|
17
|
+
*
|
|
18
|
+
* ## 🛑 Why three checks and not one
|
|
19
|
+
*
|
|
20
|
+
* The Admin SDK has surfaced this condition as a numeric gRPC code, as a string
|
|
21
|
+
* code, and as a bare message, depending on version and transport. All three
|
|
22
|
+
* are tested, and the message test is in ADDITION to the codes rather than
|
|
23
|
+
* instead of them — a stringly-typed guard alone is what breaks silently on an
|
|
24
|
+
* SDK bump, and the failure mode is a create that reports success while having
|
|
25
|
+
* collided.
|
|
26
|
+
*/
|
|
27
|
+
/** gRPC status 6. The numeric form, which is what the Admin SDK usually sets. */
|
|
28
|
+
export const GRPC_ALREADY_EXISTS = 6;
|
|
29
|
+
/** Narrow an unknown thrown value to the fields these predicates read. */
|
|
30
|
+
function asFirestoreError(err) {
|
|
31
|
+
if (typeof err !== "object" || err === null)
|
|
32
|
+
return null;
|
|
33
|
+
return err;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Did Firestore refuse this write because the document already exists?
|
|
37
|
+
*
|
|
38
|
+
* The one question every `createWithId`-style insert needs answered, and the
|
|
39
|
+
* reason it matters is that "already there" is usually SUCCESS for an
|
|
40
|
+
* idempotent writer — a claim, a case or an inbox row that a retry re-sends.
|
|
41
|
+
* Treating it as a failure turns a safe retry into a 500.
|
|
42
|
+
*/
|
|
43
|
+
export function isAlreadyExistsError(err) {
|
|
44
|
+
const e = asFirestoreError(err);
|
|
45
|
+
if (!e)
|
|
46
|
+
return false;
|
|
47
|
+
if (e.code === GRPC_ALREADY_EXISTS)
|
|
48
|
+
return true;
|
|
49
|
+
if (e.code === "already-exists" || e.code === "ALREADY_EXISTS")
|
|
50
|
+
return true;
|
|
51
|
+
return typeof e.message === "string" && e.message.includes("ALREADY_EXISTS");
|
|
52
|
+
}
|
package/dist/errors/index.d.ts
CHANGED
|
@@ -10,7 +10,6 @@ export { NotFoundError } from "./not-found-error";
|
|
|
10
10
|
export { ConflictError } from "./conflict-error";
|
|
11
11
|
export { DatabaseError } from "./database-error";
|
|
12
12
|
export { PaymentProviderUnreachableError } from "./payment-provider-unreachable";
|
|
13
|
-
export { handleApiError, logError, isAppError } from "./error-handler";
|
|
14
13
|
export { mapToHttpError, HTTP_ERROR_CODES, type MappedError, type HttpErrorCode, } from "./error-mapping";
|
|
15
14
|
export { ERROR_DISPLAY_MAP, getErrorDisplay, toUserMessage, GENERIC_USER_MESSAGE, type ErrorDisplayEntry, } from "./error-display-map";
|
|
16
15
|
export { safeRead, installDegradedReadReporter, type DegradedReadReport, } from "./safe-read";
|
package/dist/errors/index.js
CHANGED
|
@@ -13,8 +13,18 @@ export { NotFoundError } from "./not-found-error.js";
|
|
|
13
13
|
export { ConflictError } from "./conflict-error.js";
|
|
14
14
|
export { DatabaseError } from "./database-error.js";
|
|
15
15
|
export { PaymentProviderUnreachableError } from "./payment-provider-unreachable.js";
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
/*
|
|
17
|
+
* 🛑 `error-handler` is deliberately NOT re-exported here. Import it from
|
|
18
|
+
* "./error-handler" directly.
|
|
19
|
+
*
|
|
20
|
+
* It does `import { NextResponse } from "next/server.js"`, and 83 files
|
|
21
|
+
* import THIS barrel — including repositories reachable from the Firebase
|
|
22
|
+
* Functions entry. So the barrel dragged Next's server runtime into the tsup
|
|
23
|
+
* bundle, which died on two unresolved `react-server-dom-webpack/*` requires
|
|
24
|
+
* and two `@vercel/og` .wasm files, naming none of the files responsible.
|
|
25
|
+
* The old line carried a comment saying "only import in server/API-route
|
|
26
|
+
* contexts" — a convention nothing could enforce. Root Cause #18.
|
|
27
|
+
*/
|
|
18
28
|
// Code→HTTP mapper (server) — and the i18n / inline-field map (client-safe)
|
|
19
29
|
export { mapToHttpError, HTTP_ERROR_CODES, } from "./error-mapping.js";
|
|
20
30
|
export { ERROR_DISPLAY_MAP, getErrorDisplay, toUserMessage, GENERIC_USER_MESSAGE, } from "./error-display-map.js";
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useCallback, useEffect, useState, useTransition } from "react";
|
|
4
|
-
import { Alert, Badge, Button, Div, Heading, LoginRequiredModal, Row, Spinner, Stack, Text } from "../../../ui/index.js";
|
|
4
|
+
import { Alert, Badge, Button, Div, Heading, LoginRequiredModal, Row, Spinner, Stack, Text, TextLink } from "../../../ui/index.js";
|
|
5
|
+
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
5
6
|
import { isAuthError } from "../../../utils/auth-error.js";
|
|
6
7
|
import { ACTIONS } from "../../../_internal/shared/actions/action-registry.js";
|
|
7
8
|
import { ACCOUNT_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
8
9
|
import { normalizeError } from "../../../errors/normalize.js";
|
|
10
|
+
import { toUserMessage } from "../../../errors/error-display-map.js";
|
|
9
11
|
const CLS_COUNTER_AMOUNT = "text-[length:var(--appkit-text-sm)] font-semibold text-info dark:text-info";
|
|
10
12
|
function formatRupees(amount) {
|
|
11
13
|
if (amount === undefined || amount === null)
|
|
@@ -60,7 +62,7 @@ function BuyerOfferCard({ offer, onAcceptCounter, onWithdraw, onCheckout, onUpda
|
|
|
60
62
|
}
|
|
61
63
|
});
|
|
62
64
|
}
|
|
63
|
-
return (_jsxs(Stack, { surface: "card", padding: "sm", gap: "3", children: [_jsxs(Row, { align: "start", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Div, { className: "min-w-0", children: [_jsx(
|
|
65
|
+
return (_jsxs(Stack, { surface: "card", padding: "sm", gap: "3", children: [_jsxs(Row, { align: "start", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Div, { className: "min-w-0", children: [_jsx(TextLink, { href: String(ROUTES.PUBLIC.PRODUCT_DETAIL(offer.productSlug ?? offer.productId)), size: "sm", weight: "semibold", truncate: true, children: offer.productTitle ?? "Unknown Product" }), _jsx(Text, { className: "mt-0.5", color: "muted", size: "xs", children: relativeTime(offer.createdAt) })] }), _jsx(Badge, { variant: statusVariant(offer.status), size: "xs", className: "shrink-0 capitalize", children: offer.status })] }), _jsxs(Row, { wrap: true, gap: "md", children: [_jsxs(Div, { children: [_jsx(Text, { className: "tracking-wide", color: "faint", size: "xs", transform: "uppercase", children: "Listed" }), _jsx(Text, { size: "sm", weight: "medium", color: "muted", children: formatRupees(offer.listedPrice) })] }), _jsxs(Div, { children: [_jsx(Text, { className: "tracking-wide", color: "faint", size: "xs", transform: "uppercase", children: "Your Offer" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: formatRupees(offer.offerAmount) })] }), offer.counterAmount != null && (_jsxs(Div, { children: [_jsx(Text, { className: "tracking-wide", color: "faint", size: "xs", transform: "uppercase", children: "Seller Counter" }), _jsx(Text, { className: CLS_COUNTER_AMOUNT, children: formatRupees(offer.counterAmount) })] })), offer.lockedPrice != null && (_jsxs(Div, { children: [_jsx(Text, { className: "tracking-wide", color: "faint", size: "xs", transform: "uppercase", children: "Agreed Price" }), _jsx(Text, { className: "text-success", size: "sm", weight: "semibold", children: formatRupees(offer.lockedPrice) })] }))] }), offer.sellerNote && (_jsxs(Text, { className: "italic", color: "muted", size: "xs", children: ["Seller note: \"", offer.sellerNote, "\""] })), offer.status === "countered" && (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { className: "text-info", size: "xs", weight: "medium", children: ["Seller countered at ", formatRupees(offer.counterAmount), ". Accept or withdraw your offer."] }), _jsxs(Row, { wrap: true, gap: "sm", children: [_jsx(Button, { size: "sm", variant: "primary", onClick: () => act(() => onAcceptCounter(offer.id), { status: "accepted", lockedPrice: offer.counterAmount }), disabled: isPending, children: isPending ? "Accepting…" : `Accept ${formatRupees(offer.counterAmount)}` }), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => setConfirming("withdraw"), disabled: isPending, className: "text-error border border-error/20", children: "Withdraw" })] })] })), offer.status === "accepted" && onCheckout && (_jsx(Button, { size: "sm", variant: "primary", onClick: () => act(() => onCheckout(offer.id), { status: "paid" }), disabled: isPending, children: isPending ? "Processing…" : "Checkout at Agreed Price" })), offer.status === "pending" && (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => setConfirming("withdraw"), disabled: isPending, className: "text-error border border-error/20 text-[length:var(--appkit-text-xs)]", children: "Withdraw Offer" })), confirming === "withdraw" && (_jsxs(Row, { gap: "sm", children: [_jsx(Button, { size: "sm", variant: "ghost", onClick: () => act(() => onWithdraw(offer.id), { status: "withdrawn" }), disabled: isPending, className: "text-error border border-error/20", children: isPending ? "Withdrawing…" : "Confirm Withdraw" }), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => setConfirming(null), disabled: isPending, children: "Cancel" })] })), error && _jsx(Alert, { variant: "error", children: _jsx(Text, { size: "xs", children: error }) })] }));
|
|
64
66
|
}
|
|
65
67
|
export function UserOffersPanel({ fetchEndpoint = ACCOUNT_ENDPOINTS.OFFERS, onAcceptCounter, onWithdraw, onCheckout, className = "", }) {
|
|
66
68
|
const [offers, setOffers] = useState([]);
|
|
@@ -92,7 +94,7 @@ export function UserOffersPanel({ fetchEndpoint = ACCOUNT_ENDPOINTS.OFFERS, onAc
|
|
|
92
94
|
}
|
|
93
95
|
catch (err) {
|
|
94
96
|
void normalizeError(err);
|
|
95
|
-
setFetchError(err
|
|
97
|
+
setFetchError(toUserMessage(err?.code, undefined, { fallback: "Failed to load offers." }));
|
|
96
98
|
}
|
|
97
99
|
finally {
|
|
98
100
|
setLoading(false);
|
|
@@ -27,10 +27,12 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
27
27
|
import React from "react";
|
|
28
28
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
29
29
|
import { useApiMutation } from "@mohasinac/appkit/client";
|
|
30
|
-
import { Alert, Badge, Row, Span, Stack, Text, Toggle, useToast } from "../../../ui/index.js";
|
|
30
|
+
import { Alert, Badge, Row, Span, Stack, Text, TextLink, Toggle, useToast } from "../../../ui/index.js";
|
|
31
31
|
import { apiClient } from "../../../http/index.js";
|
|
32
32
|
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
33
33
|
import { DataListingView } from "./DataListingView.js";
|
|
34
|
+
import { QuickEditMenu } from "./QuickEditMenu.js";
|
|
35
|
+
import { actionIndexOverrideSchema, splitKeywords, joinKeywords, ACTION_INDEX_OVERRIDE_FIELDS, } from "../schemas/action-index-form.js";
|
|
34
36
|
const ENDPOINT = ADMIN_ENDPOINTS.ACTION_INDEX;
|
|
35
37
|
export function AdminActionIndexView({ labels = {} }) {
|
|
36
38
|
const queryClient = useQueryClient();
|
|
@@ -41,7 +43,12 @@ export function AdminActionIndexView({ labels = {} }) {
|
|
|
41
43
|
staleTime: 60000,
|
|
42
44
|
});
|
|
43
45
|
const payload = query.data?.data;
|
|
44
|
-
|
|
46
|
+
/*
|
|
47
|
+
* Memoised because the `?? {}` fallback is a fresh object every render,
|
|
48
|
+
* which makes the `rows` memo below rebuild on every render — it reads
|
|
49
|
+
* `overrides` for each entry's effective `enabled`.
|
|
50
|
+
*/
|
|
51
|
+
const overrides = React.useMemo(() => payload?.control?.entries ?? {}, [payload?.control?.entries]);
|
|
45
52
|
const toggleMutation = useApiMutation({
|
|
46
53
|
errorMessage: "Could not update that entry.",
|
|
47
54
|
mutationFn: async (vars) => apiClient.patch(ENDPOINT, {
|
|
@@ -53,6 +60,44 @@ export function AdminActionIndexView({ labels = {} }) {
|
|
|
53
60
|
void queryClient.invalidateQueries({ queryKey: ["admin", "action-index"] });
|
|
54
61
|
},
|
|
55
62
|
});
|
|
63
|
+
/*
|
|
64
|
+
* Spread the EXISTING override first, exactly as the toggle does. The
|
|
65
|
+
* endpoint's `setOverride` replaces the whole override object, so sending
|
|
66
|
+
* only the edited fields would silently clear this entry's `enabled` —
|
|
67
|
+
* re-showing something an admin had deliberately hidden.
|
|
68
|
+
*/
|
|
69
|
+
const overrideMutation = useApiMutation({
|
|
70
|
+
errorMessage: "Could not save that entry.",
|
|
71
|
+
mutationFn: async (vars) => {
|
|
72
|
+
const parsed = actionIndexOverrideSchema.parse(vars.values);
|
|
73
|
+
return apiClient.patch(ENDPOINT, {
|
|
74
|
+
entryId: vars.entryId,
|
|
75
|
+
override: {
|
|
76
|
+
...overrides[vars.entryId],
|
|
77
|
+
label: parsed.label,
|
|
78
|
+
description: parsed.description,
|
|
79
|
+
keywords: splitKeywords(parsed.keywords),
|
|
80
|
+
weight: parsed.weight,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
onSuccess: () => {
|
|
85
|
+
showToast("Entry saved.", "success");
|
|
86
|
+
void queryClient.invalidateQueries({ queryKey: ["admin", "action-index"] });
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
/** Named so the row-action array stays a flat list of declarations. */
|
|
90
|
+
const saveOverride = React.useCallback(async (entryId, values) => {
|
|
91
|
+
await overrideMutation.mutateAsync({ entryId, values });
|
|
92
|
+
}, [overrideMutation]);
|
|
93
|
+
const clearMutation = useApiMutation({
|
|
94
|
+
errorMessage: "Could not reset that entry.",
|
|
95
|
+
mutationFn: async (entryId) => apiClient.patch(ENDPOINT, { entryId, clear: true }),
|
|
96
|
+
onSuccess: () => {
|
|
97
|
+
showToast("Entry reset to its code default.", "success");
|
|
98
|
+
void queryClient.invalidateQueries({ queryKey: ["admin", "action-index"] });
|
|
99
|
+
},
|
|
100
|
+
});
|
|
56
101
|
/*
|
|
57
102
|
* Ordered by weight, then label — the same ranking `matchesNavQuery` gives
|
|
58
103
|
* the search, so the admin reading this list sees entries in the order a
|
|
@@ -69,6 +114,10 @@ export function AdminActionIndexView({ labels = {} }) {
|
|
|
69
114
|
href: `${entry.href}${entry.deepLink ?? ""}`,
|
|
70
115
|
permission: entry.requiredPermission ?? "—",
|
|
71
116
|
enabled: overrides[entry.id]?.enabled !== false,
|
|
117
|
+
// Seeds the edit drawer. `entry.*` is already override-merged by
|
|
118
|
+
// `mergeActionIndex`, so these are the effective values an admin sees.
|
|
119
|
+
keywords: joinKeywords(entry.keywords),
|
|
120
|
+
weight: entry.weight ?? 0,
|
|
72
121
|
})), [payload?.entries, overrides]);
|
|
73
122
|
const sync = payload?.sync;
|
|
74
123
|
return (_jsxs(Stack, { gap: "md", children: [sync && sync.orphanedOverrides.length > 0 && (_jsxs(Alert, { variant: "warning", title: "Overrides with no entry", children: [sync.orphanedOverrides.length, " saved override", sync.orphanedOverrides.length === 1 ? "" : "s", " point at entries the code no longer has (", sync.orphanedOverrides.slice(0, 4).join(", "), sync.orphanedOverrides.length > 4 ? "…" : "", "). They are ignored \u2014 clear them when convenient."] })), _jsx(DataListingView, { config: {
|
|
@@ -109,9 +158,19 @@ export function AdminActionIndexView({ labels = {} }) {
|
|
|
109
158
|
},
|
|
110
159
|
{ key: "portal", header: "Portal" },
|
|
111
160
|
{
|
|
161
|
+
/*
|
|
162
|
+
* A LINK, not mono text.
|
|
163
|
+
*
|
|
164
|
+
* This row IS a destination — "where is it" is half of what
|
|
165
|
+
* this screen exists to answer, and the answer was printed as
|
|
166
|
+
* inert text beside a toggle. The link is also what makes the
|
|
167
|
+
* row reachable at all: every other column is metadata about
|
|
168
|
+
* the entry, so without it there is no way to go look at the
|
|
169
|
+
* thing being enabled or disabled.
|
|
170
|
+
*/
|
|
112
171
|
key: "href",
|
|
113
172
|
header: "Goes to",
|
|
114
|
-
render: (row) => (_jsx(
|
|
173
|
+
render: (row) => (_jsx(TextLink, { href: row.href, size: "xs", onClick: (e) => e.stopPropagation(), children: _jsx(Span, { size: "xs", family: "mono", children: row.href }) })),
|
|
115
174
|
},
|
|
116
175
|
{
|
|
117
176
|
key: "permission",
|
|
@@ -121,8 +180,44 @@ export function AdminActionIndexView({ labels = {} }) {
|
|
|
121
180
|
{
|
|
122
181
|
key: "enabled",
|
|
123
182
|
header: "Shown",
|
|
124
|
-
render: (row) => (_jsx(Row, { align: "center", gap: "sm", children: _jsx(Toggle, { checked: row.enabled, size: "sm",
|
|
183
|
+
render: (row) => (_jsx(Row, { align: "center", gap: "sm", children: _jsx(Toggle, { checked: row.enabled, size: "sm", ariaLabel: `Show ${row.primary} in navigation and search`, onChange: (next) => toggleMutation.mutate({ entryId: row.id, enabled: next }) }) })),
|
|
125
184
|
},
|
|
126
185
|
],
|
|
127
|
-
|
|
186
|
+
/*
|
|
187
|
+
* 🛑 This exists because the footer below already PROMISED it:
|
|
188
|
+
* "Labels, descriptions and keywords can be edited here." They
|
|
189
|
+
* could not. `PATCH /api/admin/action-index` has always accepted
|
|
190
|
+
* `label` / `description` / `keywords` / `weight` in an override
|
|
191
|
+
* (see `ActionIndexOverride`), and the only control on this screen
|
|
192
|
+
* was the Shown toggle — so the sentence described a capability the
|
|
193
|
+
* server had and the UI had never grown. Root Cause #51's shape: a
|
|
194
|
+
* label describing something other than what the surface does.
|
|
195
|
+
*
|
|
196
|
+
* `href`, `kind` and every permission field stay out, per the
|
|
197
|
+
* component docstring: repointing a built-in route is routing, and
|
|
198
|
+
* relaxing a permission is access control.
|
|
199
|
+
*/
|
|
200
|
+
renderRowActions: (row) => (_jsx(QuickEditMenu, { actions: [
|
|
201
|
+
{
|
|
202
|
+
label: "Edit entry",
|
|
203
|
+
formTitle: `Edit "${row.primary}"`,
|
|
204
|
+
schema: actionIndexOverrideSchema,
|
|
205
|
+
fields: ACTION_INDEX_OVERRIDE_FIELDS,
|
|
206
|
+
defaultValues: {
|
|
207
|
+
label: row.primary,
|
|
208
|
+
description: row.secondary,
|
|
209
|
+
keywords: row.keywords,
|
|
210
|
+
weight: row.weight,
|
|
211
|
+
},
|
|
212
|
+
onSubmit: (values) => saveOverride(row.id, values),
|
|
213
|
+
submitLabel: "Save entry",
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
label: "Reset to code default",
|
|
217
|
+
separator: true,
|
|
218
|
+
destructive: true,
|
|
219
|
+
onClick: () => clearMutation.mutate(row.id),
|
|
220
|
+
},
|
|
221
|
+
] })),
|
|
222
|
+
} }), _jsx(Text, { size: "xs", color: "muted", children: "Hiding an entry removes it from the sidebar, the header search and the command palette at once. Use the row menu to edit an entry\u2019s label, description, keywords and search weight, or to reset it to its code default; a route and its required permission belong to the code." })] }));
|
|
128
223
|
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useQuery } from "@tanstack/react-query";
|
|
4
4
|
import { useQueryClient } from "@tanstack/react-query";
|
|
5
|
-
import { Badge, Button, Div, Heading, Row, Span, Stack, Text, useToast } from "../../../ui/index.js";
|
|
5
|
+
import { Badge, Button, Div, Heading, Row, Span, Stack, Text, TextLink, useToast } from "../../../ui/index.js";
|
|
6
|
+
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
6
7
|
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
7
8
|
import { apiClient } from "../../../http/index.js";
|
|
8
9
|
import { useApiMutation } from "@mohasinac/appkit/client";
|
|
@@ -31,6 +32,24 @@ export function AdminAddressClustersView(_props) {
|
|
|
31
32
|
void queryClient.invalidateQueries({ queryKey: ["admin", "address-clusters"] });
|
|
32
33
|
},
|
|
33
34
|
});
|
|
35
|
+
/*
|
|
36
|
+
* 🛑 "Flag Suspicious" was ONE-WAY: there was no unflag, so a wrong call —
|
|
37
|
+
* or a cluster that turned out to be a family sharing an address — left a
|
|
38
|
+
* permanent mark with no way back from this screen.
|
|
39
|
+
*
|
|
40
|
+
* `clear_ban` has existed on the route the whole time and calls
|
|
41
|
+
* `clearBanById`; only the control was missing.
|
|
42
|
+
*/
|
|
43
|
+
const clearFlagMutation = useApiMutation({
|
|
44
|
+
errorMessage: "Failed to clear that flag.",
|
|
45
|
+
mutationFn: async (id) => {
|
|
46
|
+
await apiClient.patch(ADMIN_ENDPOINTS.ADDRESS_BY_ID(id), { action: "clear_ban" });
|
|
47
|
+
},
|
|
48
|
+
onSuccess: () => {
|
|
49
|
+
showToast("Flag cleared.", "success");
|
|
50
|
+
void queryClient.invalidateQueries({ queryKey: ["admin", "address-clusters"] });
|
|
51
|
+
},
|
|
52
|
+
});
|
|
34
53
|
const clusters = data?.clusters ?? [];
|
|
35
|
-
return (_jsxs(Stack, { gap: "lg", padding: "page", children: [_jsxs(Stack, { gap: "none", children: [_jsx(Heading, { level: 2, size: "lg", weight: "semibold", color: "primary", children: "Address Clusters" }), _jsx(Text, { size: "sm", color: "muted", className: "mt-1", children: "Users sharing the same physical address. Flagging is informational only \u2014 users are not blocked." })] }), isLoading && (_jsx(Row, { justify: "center", padding: "y-4xl", children: _jsx(Div, { className: "h-6 w-6 animate-spin border-2 border-[var(--appkit-color-primary)] border-t-transparent", rounded: "full" }) })), error && (_jsx(Div, { surface: "danger-surface", color: "error", padding: "inline", rounded: "xl", textSize: "sm", children: "Failed to load clusters." })), !isLoading && clusters.length === 0 && (_jsx(Div, { surface: "muted", padding: "y-4xl", rounded: "xl", className: "text-center", children: _jsx(Text, { color: "muted", size: "sm", children: "No shared addresses found." }) })), clusters.map((cluster) => (_jsxs(Stack, { surface: "card", padding: "md", gap: "md", rounded: "xl", border: "default", children: [_jsxs(Row, { justify: "between", align: "start", gap: "sm", children: [_jsxs(Stack, { gap: "none", children: [_jsx(Text, { size: "xs", color: "muted", weight: "medium", children: "Hash" }), _jsx(Span, { size: "xs", color: "muted", className: "font-mono truncate max-w-xs", children: cluster.addressHash })] }), _jsxs(Badge, { variant: "secondary", size: "sm", children: [cluster.addresses.length, " accounts"] })] }), _jsx(Stack, { gap: "xs", children: cluster.addresses.map((addr) => (_jsxs(Row, { justify: "between", align: "center", gap: "sm", surface: "muted", padding: "inlineSm", rounded: "lg", children: [_jsxs(Stack, { gap: "none", className: "min-w-0", children: [_jsxs(
|
|
54
|
+
return (_jsxs(Stack, { gap: "lg", padding: "page", children: [_jsxs(Stack, { gap: "none", children: [_jsx(Heading, { level: 2, size: "lg", weight: "semibold", color: "primary", children: "Address Clusters" }), _jsx(Text, { size: "sm", color: "muted", className: "mt-1", children: "Users sharing the same physical address. Flagging is informational only \u2014 users are not blocked." })] }), isLoading && (_jsx(Row, { justify: "center", padding: "y-4xl", children: _jsx(Div, { className: "h-6 w-6 animate-spin border-2 border-[var(--appkit-color-primary)] border-t-transparent", rounded: "full" }) })), error && (_jsx(Div, { surface: "danger-surface", color: "error", padding: "inline", rounded: "xl", textSize: "sm", children: "Failed to load clusters." })), !isLoading && clusters.length === 0 && (_jsx(Div, { surface: "muted", padding: "y-4xl", rounded: "xl", className: "text-center", children: _jsx(Text, { color: "muted", size: "sm", children: "No shared addresses found." }) })), clusters.map((cluster) => (_jsxs(Stack, { surface: "card", padding: "md", gap: "md", rounded: "xl", border: "default", children: [_jsxs(Row, { justify: "between", align: "start", gap: "sm", children: [_jsxs(Stack, { gap: "none", children: [_jsx(Text, { size: "xs", color: "muted", weight: "medium", children: "Hash" }), _jsx(Span, { size: "xs", color: "muted", className: "font-mono truncate max-w-xs", children: cluster.addressHash })] }), _jsxs(Badge, { variant: "secondary", size: "sm", children: [cluster.addresses.length, " accounts"] })] }), _jsx(Stack, { gap: "xs", children: cluster.addresses.map((addr) => (_jsxs(Row, { justify: "between", align: "center", gap: "sm", surface: "muted", padding: "inlineSm", rounded: "lg", children: [_jsxs(Stack, { gap: "none", className: "min-w-0", children: [_jsxs(TextLink, { href: addr.ownerType === "user" ? String(ROUTES.ADMIN.USER_DETAIL(addr.ownerId)) : String(ROUTES.ADMIN.STORE_DETAIL(addr.ownerId)), size: "sm", weight: "medium", truncate: true, children: [addr.ownerType === "user" ? "User" : "Store", ": ", addr.ownerId] }), addr.city && (_jsx(Text, { size: "xs", color: "muted", children: [addr.city, addr.state, addr.postalCode].filter(Boolean).join(", ") }))] }), _jsxs(Row, { gap: "xs", align: "center", children: [addr.banStatus && (_jsx(Badge, { variant: BAN_BADGE[addr.banStatus] ?? "secondary", size: "sm", children: addr.banStatus.replace(/_/g, " ") })), addr.banStatus === "suspicious" ? (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => clearFlagMutation.mutate(addr.id), disabled: clearFlagMutation.isPending, children: "Clear flag" })) : (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => flagMutation.mutate(addr.id), disabled: flagMutation.isPending, children: "Flag Suspicious" }))] })] }, addr.id))) })] }, cluster.addressHash)))] }));
|
|
36
55
|
}
|
|
@@ -201,13 +201,13 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
|
|
|
201
201
|
required: true,
|
|
202
202
|
quick: true,
|
|
203
203
|
fields: ["title", "slug", "excerpt", "content"],
|
|
204
|
-
render: ({ values, onChange }) => (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Content" }), _jsx(Input, { label: "Title", value: values.title, onChange: (e) => {
|
|
204
|
+
render: ({ values, onChange, errors }) => (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Content" }), _jsx(Input, { label: "Title", error: errors.title, value: values.title, onChange: (e) => {
|
|
205
205
|
const v = e.target.value;
|
|
206
206
|
onChange({ title: v, ...(!slugManual && { slug: toSlug(v) }) });
|
|
207
|
-
}, placeholder: "e.g. How to Grade Pok\u00E9mon Cards" }), _jsx(Input, { label: "Slug", value: values.slug, onChange: (e) => {
|
|
207
|
+
}, placeholder: "e.g. How to Grade Pok\u00E9mon Cards" }), _jsx(Input, { label: "Slug", error: errors.slug, value: values.slug, onChange: (e) => {
|
|
208
208
|
setSlugManual(true);
|
|
209
209
|
onChange({ slug: e.target.value });
|
|
210
|
-
}, placeholder: "blog-how-to-grade-pokemon-cards", helperText: "Auto-generated from title. Must start with 'blog-'." }), _jsx(Input, { label: "Excerpt", value: values.excerpt, onChange: (e) => onChange({ excerpt: e.target.value }), placeholder: "Short summary shown in listings and cards" }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Content" }), _jsx(RichTextEditor, { ariaLabel: "Content", value: values.content, onChange: (v) => onChange({ content: v }), placeholder: "Write your article here...", minHeightClassName: "min-h-[320px]" })] })] })),
|
|
210
|
+
}, placeholder: "blog-how-to-grade-pokemon-cards", helperText: "Auto-generated from title. Must start with 'blog-'." }), _jsx(Input, { label: "Excerpt", error: errors.excerpt, value: values.excerpt, onChange: (e) => onChange({ excerpt: e.target.value }), placeholder: "Short summary shown in listings and cards" }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Content" }), _jsx(RichTextEditor, { ariaLabel: "Content", value: values.content, onChange: (v) => onChange({ content: v }), placeholder: "Write your article here...", minHeightClassName: "min-h-[320px]" })] })] })),
|
|
211
211
|
},
|
|
212
212
|
{
|
|
213
213
|
id: "media",
|
|
@@ -216,20 +216,20 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
|
|
|
216
216
|
// its children, which would abort an in-flight upload.
|
|
217
217
|
keepMounted: true,
|
|
218
218
|
fields: ["coverImage", "youtubeId"],
|
|
219
|
-
render: ({ values, onChange }) => (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Media" }), _jsx(ImageUpload, { label: "Cover Image", currentImage: values.coverImage, onUpload: (file) => upload(file, "blog", true, {
|
|
219
|
+
render: ({ values, onChange, errors }) => (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Media" }), _jsx(ImageUpload, { label: "Cover Image", currentImage: values.coverImage, onUpload: (file) => upload(file, "blog", true, {
|
|
220
220
|
type: "blog-cover",
|
|
221
221
|
title: values.title || values.slug,
|
|
222
222
|
category: values.category,
|
|
223
|
-
}), onChange: (url) => onChange({ coverImage: url }) }), _jsx(Input, { label: "YouTube Video ID (optional)", value: values.youtubeId, onChange: (e) => onChange({ youtubeId: e.target.value }), placeholder: "e.g. dQw4w9WgXcQ", helperText: "The 11-character video ID from the YouTube URL." })] })),
|
|
223
|
+
}), onChange: (url) => onChange({ coverImage: url }) }), _jsx(Input, { label: "YouTube Video ID (optional)", error: errors.youtubeId, value: values.youtubeId, onChange: (e) => onChange({ youtubeId: e.target.value }), placeholder: "e.g. dQw4w9WgXcQ", helperText: "The 11-character video ID from the YouTube URL." })] })),
|
|
224
224
|
},
|
|
225
225
|
{
|
|
226
226
|
id: "seo",
|
|
227
227
|
label: "SEO & Tags",
|
|
228
228
|
quick: true,
|
|
229
229
|
fields: ["category", "tags", "metaTitle", "metaDescription"],
|
|
230
|
-
render: ({ values, onChange }) => {
|
|
230
|
+
render: ({ values, onChange, errors }) => {
|
|
231
231
|
const readTime = Math.max(1, Math.round(values.content.replace(/<[^>]*>/g, "").split(/\s+/).filter(Boolean).length / 200));
|
|
232
|
-
return (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "SEO & Tags" }), _jsxs(Div, { layout: "grid", gap: "4", className: "grid-cols-2", children: [_jsx(Select, { label: "Category", options: CATEGORY_OPTIONS, value: values.category, onValueChange: (v) => onChange({ category: v }) }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-1.5", size: "sm", weight: "medium", children: "Estimated Read Time" }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: ["~", readTime, " min (auto-calculated from content)"] })] })] }), _jsx(TagInput, { label: "Tags", value: values.tags, onChange: (t) => onChange({ tags: t }), placeholder: "e.g. pokemon, grading, tcg" }), _jsx(Input, { label: "Meta Title (optional)", value: values.metaTitle, onChange: (e) => onChange({ metaTitle: e.target.value }), placeholder: "Defaults to post title" }), _jsx(Input, { label: "Meta Description (optional)", value: values.metaDescription, onChange: (e) => onChange({ metaDescription: e.target.value }), placeholder: "SEO description \u2014 max 160 chars", maxLength: 160 })] }));
|
|
232
|
+
return (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "SEO & Tags" }), _jsxs(Div, { layout: "grid", gap: "4", className: "grid-cols-2", children: [_jsx(Select, { label: "Category", error: errors.category, options: CATEGORY_OPTIONS, value: values.category, onValueChange: (v) => onChange({ category: v }) }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-1.5", size: "sm", weight: "medium", children: "Estimated Read Time" }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: ["~", readTime, " min (auto-calculated from content)"] })] })] }), _jsx(TagInput, { label: "Tags", error: errors.tags, value: values.tags, onChange: (t) => onChange({ tags: t }), placeholder: "e.g. pokemon, grading, tcg" }), _jsx(Input, { label: "Meta Title (optional)", error: errors.metaTitle, value: values.metaTitle, onChange: (e) => onChange({ metaTitle: e.target.value }), placeholder: "Defaults to post title" }), _jsx(Input, { label: "Meta Description (optional)", error: errors.metaDescription, value: values.metaDescription, onChange: (e) => onChange({ metaDescription: e.target.value }), placeholder: "SEO description \u2014 max 160 chars", maxLength: 160 })] }));
|
|
233
233
|
},
|
|
234
234
|
},
|
|
235
235
|
{
|
|
@@ -237,7 +237,7 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
|
|
|
237
237
|
label: "Publish",
|
|
238
238
|
quick: true,
|
|
239
239
|
fields: ["status", "publishedAt", "authorName", "isFeatured"],
|
|
240
|
-
render: ({ values, onChange }) => (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Publish Settings" }), _jsxs(Div, { layout: "grid", gap: "4", className: "grid-cols-2", children: [_jsx(Select, { label: "Status", options: STATUS_OPTIONS, value: values.status, onValueChange: (v) => onChange({ status: v }) }), _jsx(Input, { label: "Publish date (optional)", value: values.publishedAt, onChange: (e) => onChange({ publishedAt: e.target.value }), type: "date", helperText: "Auto-set to now when publishing." })] }), _jsx(Input, { label: "Author Name", value: values.authorName, onChange: (e) => onChange({ authorName: e.target.value }), placeholder: "Author display name" }), _jsx(Toggle, { label: "Featured post", checked: values.isFeatured, onChange: (checked) => onChange({ isFeatured: checked }) }), isEdit && (_jsx(Div, { className: "border-t border-[var(--appkit-color-border)]", padding: "t-md", children: _jsx(Button, { type: "button", variant: "danger", size: "sm", onClick: () => setShowDeleteConfirm(true), isLoading: deleteMutation.isPending, children: "Delete post" }) }))] })),
|
|
240
|
+
render: ({ values, onChange, errors }) => (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Publish Settings" }), _jsxs(Div, { layout: "grid", gap: "4", className: "grid-cols-2", children: [_jsx(Select, { label: "Status", error: errors.status, options: STATUS_OPTIONS, value: values.status, onValueChange: (v) => onChange({ status: v }) }), _jsx(Input, { label: "Publish date (optional)", error: errors.publishedAt, value: values.publishedAt, onChange: (e) => onChange({ publishedAt: e.target.value }), type: "date", helperText: "Auto-set to now when publishing." })] }), _jsx(Input, { label: "Author Name", error: errors.authorName, value: values.authorName, onChange: (e) => onChange({ authorName: e.target.value }), placeholder: "Author display name" }), _jsx(Toggle, { label: "Featured post", checked: values.isFeatured, onChange: (checked) => onChange({ isFeatured: checked }) }), isEdit && (_jsx(Div, { className: "border-t border-[var(--appkit-color-border)]", padding: "t-md", children: _jsx(Button, { type: "button", variant: "danger", size: "sm", onClick: () => setShowDeleteConfirm(true), isLoading: deleteMutation.isPending, children: "Delete post" }) }))] })),
|
|
241
241
|
},
|
|
242
242
|
];
|
|
243
243
|
const deleteModal = (_jsx(ConfirmDeleteModal, { isOpen: showDeleteConfirm, onConfirm: () => {
|
|
@@ -224,7 +224,7 @@ export function AdminCarouselEditorView({ slideId, onSaved, onDeleted, onCancel,
|
|
|
224
224
|
label: "Slide info",
|
|
225
225
|
required: true,
|
|
226
226
|
fields: ["title", "active", "order", "settings"],
|
|
227
|
-
render: () => (_jsxs(Stack, { gap: "md", children: [_jsx(FieldInput, { name: "title", label: "Title", value: title, onChange: (v) => setTitle(v), required: true, placeholder: "e.g. Hot Wheels RLC Exclusives" }), _jsx(Toggle, { label: "Active (visible on homepage)", checked: active, onChange: setActive }), _jsx(Input, { label: "Display order", type: "number", value: order, onChange: (e) => setOrder(e.target.value), min: 0, placeholder: "1" }), _jsx(Select, { label: "Slide height", value: height, onChange: (e) => setHeight(e.target.value), options: HEIGHT_OPTIONS }), _jsx(Input, { label: "Autoplay delay (ms)", type: "number", value: autoplayDelayMs, onChange: (e) => setAutoplayDelayMs(e.target.value), min: 1000, max: 15000, placeholder: "4000", helperText: "How long this slide shows before auto-advancing. Default: 4000 ms." })] })),
|
|
227
|
+
render: () => (_jsxs(Stack, { gap: "md", children: [_jsx(FieldInput, { name: "title", label: "Title", value: title, onChange: (v) => setTitle(v), required: true, placeholder: "e.g. Hot Wheels RLC Exclusives" }), _jsx(Toggle, { label: "Active (visible on homepage)", checked: active, onChange: setActive }), _jsx(Input, { label: "Display order", error: form.shellCtx.errors.order, type: "number", value: order, onChange: (e) => setOrder(e.target.value), min: 0, placeholder: "1" }), _jsx(Select, { label: "Slide height", value: height, onChange: (e) => setHeight(e.target.value), options: HEIGHT_OPTIONS }), _jsx(Input, { label: "Autoplay delay (ms)", type: "number", value: autoplayDelayMs, onChange: (e) => setAutoplayDelayMs(e.target.value), min: 1000, max: 15000, placeholder: "4000", helperText: "How long this slide shows before auto-advancing. Default: 4000 ms." })] })),
|
|
228
228
|
},
|
|
229
229
|
{
|
|
230
230
|
id: "background",
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useApiMutation } from "@mohasinac/appkit/client";
|
|
4
4
|
import React, { useState, useCallback } from "react";
|
|
5
|
+
import { useRouter } from "next/navigation.js";
|
|
5
6
|
import { useQueryClient } from "@tanstack/react-query";
|
|
6
|
-
import { Badge, BulkActionBar, Button, ConfirmDeleteModal, Div, ListingFilterDrawer, ListingLayout, ListingToolbar, Pagination, Row, Span, Stack, Text, StickyToolbar } from "../../../ui/index.js";
|
|
7
|
+
import { Badge, BulkActionBar, Button, ConfirmDeleteModal, Div, ListingFilterDrawer, ListingLayout, ListingToolbar, Pagination, Row, Span, Stack, Text, TextLink, StickyToolbar, Toggle, useToast } from "../../../ui/index.js";
|
|
8
|
+
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
7
9
|
import { useBottomActions } from "../../layout/index.js";
|
|
8
10
|
import { MediaImage } from "../../media/MediaImage.js";
|
|
9
11
|
import { ADMIN_ENDPOINTS, HOMEPAGE_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
@@ -14,6 +16,8 @@ import { useAdminListing } from "../hooks/useAdminListing.js";
|
|
|
14
16
|
import { DataTable } from "./DataTable.js";
|
|
15
17
|
import { RowActionMenu } from "../../../ui/components/RowActionMenu.js";
|
|
16
18
|
import { apiClient } from "../../../http/index.js";
|
|
19
|
+
import { normalizeError } from "../../../errors/normalize.js";
|
|
20
|
+
import { toUserMessage } from "../../../errors/error-display-map.js";
|
|
17
21
|
const PAGE_SIZE = 50;
|
|
18
22
|
const FILTER_KEYS = ["active"];
|
|
19
23
|
const DEFAULT_SORT = "order";
|
|
@@ -33,6 +37,8 @@ export function AdminCarouselView({ children, onBulkDelete, ...props }) {
|
|
|
33
37
|
const [deleteTargetId, setDeleteTargetId] = useState(null);
|
|
34
38
|
const [localRows, setLocalRows] = useState([]);
|
|
35
39
|
const queryClient = useQueryClient();
|
|
40
|
+
const router = useRouter();
|
|
41
|
+
const { showToast } = useToast();
|
|
36
42
|
const { view, setView, table, searchInput, setSearchInput, commitSearch, filterOpen, setFilterOpen, openFilters, applyFilters, clearFilters, pendingFilters, setPendingFilters, activeFilterCount, hasActiveState, resetAll, rows: fetchedRows, isLoading, errorMessage, currentPage, totalPages, selection, } = useAdminListing({
|
|
37
43
|
filterKeys: FILTER_KEYS,
|
|
38
44
|
defaultSort: DEFAULT_SORT,
|
|
@@ -111,8 +117,58 @@ export function AdminCarouselView({ children, onBulkDelete, ...props }) {
|
|
|
111
117
|
setDraggingId(null);
|
|
112
118
|
reorderMutation.mutate(next.map((r) => r.id));
|
|
113
119
|
};
|
|
120
|
+
const setActiveMutation = useApiMutation({
|
|
121
|
+
errorMessage: "Could not change that slide's status.",
|
|
122
|
+
mutationFn: ({ id, active }) =>
|
|
123
|
+
// PUT, not PATCH: this route has no PATCH, and every field on
|
|
124
|
+
// `updateSlideSchema` is optional, so a lone `{ active }` is a valid
|
|
125
|
+
// partial update.
|
|
126
|
+
apiClient.put(ADMIN_ENDPOINTS.CAROUSEL_BY_ID(id), { active }),
|
|
127
|
+
onSuccess: () => {
|
|
128
|
+
queryClient.invalidateQueries({ queryKey: ["admin", "carousel", "listing"] });
|
|
129
|
+
// The public hero carousel reads under its own key.
|
|
130
|
+
queryClient.invalidateQueries({ queryKey: ["carousel"] });
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
/*
|
|
134
|
+
* 🛑 The bulk delete used to be gated entirely on an `onBulkDelete` PROP —
|
|
135
|
+
* and the page renders `<AdminCarouselView />` with no props at all. So
|
|
136
|
+
* `bulkActions` was always `[]`, the BulkActionBar never rendered, and
|
|
137
|
+
* DataTable was handed no selection props either, which meant no checkbox
|
|
138
|
+
* column existed to select with in the first place.
|
|
139
|
+
* `ACTIONS.ADMIN["delete-carousel"]` and its confirmation dialog were
|
|
140
|
+
* unreachable from this page.
|
|
141
|
+
*
|
|
142
|
+
* The prop is kept so an embedder can still override the behaviour, but it
|
|
143
|
+
* no longer decides whether the action EXISTS — this view owns a delete
|
|
144
|
+
* mutation already and can perfectly well call it N times.
|
|
145
|
+
*/
|
|
146
|
+
const handleBulkDelete = useCallback(async () => {
|
|
147
|
+
const ids = selection.selectedIds;
|
|
148
|
+
try {
|
|
149
|
+
if (onBulkDelete) {
|
|
150
|
+
await onBulkDelete(ids);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
await Promise.all(ids.map((id) => apiClient.delete(ADMIN_ENDPOINTS.CAROUSEL_BY_ID(id))));
|
|
154
|
+
await queryClient.invalidateQueries({ queryKey: ["admin", "carousel", "listing"] });
|
|
155
|
+
}
|
|
156
|
+
selection.clearSelection();
|
|
157
|
+
showToast(`${ids.length} slide${ids.length === 1 ? "" : "s"} deleted.`, "success");
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
const normalized = normalizeError(err);
|
|
161
|
+
/*
|
|
162
|
+
* The selection is deliberately NOT cleared on failure. `Promise.all`
|
|
163
|
+
* rejects on the first error, so some of these ids may be gone and
|
|
164
|
+
* some may not — clearing would hide which rows are still there and
|
|
165
|
+
* leave the admin no way to retry the remainder.
|
|
166
|
+
*/
|
|
167
|
+
showToast(toUserMessage(normalized.code, undefined, { fallback: "Could not delete the selected slides." }), "error");
|
|
168
|
+
}
|
|
169
|
+
}, [onBulkDelete, selection, queryClient, showToast]);
|
|
114
170
|
const bulkActions = [
|
|
115
|
-
|
|
171
|
+
buildBulkAction(ACTIONS.ADMIN["delete-carousel"], handleBulkDelete),
|
|
116
172
|
];
|
|
117
173
|
useBottomActions(selection.selectedCount > 0 ? { bulk: { selectedCount: selection.selectedCount, onClearSelection: selection.clearSelection, actions: bulkActions } } : {});
|
|
118
174
|
if (hasChildren) {
|
|
@@ -138,6 +194,24 @@ export function AdminCarouselView({ children, onBulkDelete, ...props }) {
|
|
|
138
194
|
header: "Details",
|
|
139
195
|
render: (row) => row.secondary,
|
|
140
196
|
},
|
|
197
|
+
{
|
|
198
|
+
/*
|
|
199
|
+
* 🛑 Both preventDefault AND stopPropagation.
|
|
200
|
+
*
|
|
201
|
+
* `rowHrefTemplate` makes the whole row an anchor, so stopping
|
|
202
|
+
* propagation alone leaves the anchor's DEFAULT action intact — the
|
|
203
|
+
* toggle would flip and then navigate to the edit page, which reads as
|
|
204
|
+
* "clicking the switch opens the editor". A normal column cell is also
|
|
205
|
+
* outside the two `<Td>`s DataTable stops propagation on; only the
|
|
206
|
+
* selection and row-action cells get that for free.
|
|
207
|
+
*/
|
|
208
|
+
key: "active",
|
|
209
|
+
header: "Active",
|
|
210
|
+
className: "w-24",
|
|
211
|
+
render: (row) => (_jsx(Row, { align: "center", gap: "sm", role: "presentation", onClick: (e) => { e.preventDefault(); e.stopPropagation(); }, onKeyDown: (e) => e.stopPropagation(), children: _jsx(Toggle, { size: "sm", checked: row.status === "Active", ariaLabel: row.status === "Active"
|
|
212
|
+
? `${ACTIONS.ADMIN["deactivate-carousel"].ariaLabel}: ${row.primary}`
|
|
213
|
+
: `${ACTIONS.ADMIN["activate-carousel"].ariaLabel}: ${row.primary}`, onChange: (next) => setActiveMutation.mutate({ id: row.id, active: next }) }) })),
|
|
214
|
+
},
|
|
141
215
|
{
|
|
142
216
|
key: "status",
|
|
143
217
|
header: "Status",
|
|
@@ -152,7 +226,10 @@ export function AdminCarouselView({ children, onBulkDelete, ...props }) {
|
|
|
152
226
|
key: "actions",
|
|
153
227
|
header: "",
|
|
154
228
|
render: (row) => (_jsx(RowActionMenu, { actions: [
|
|
155
|
-
|
|
229
|
+
// router.push, not window.location.href — a full page load to
|
|
230
|
+
// reach a page the row already links to is strictly worse, and
|
|
231
|
+
// this is the same navigation DataTable's row anchor performs.
|
|
232
|
+
{ label: ACTIONS.ADMIN["edit-carousel"].label, onClick: () => router.push(String(ROUTES.ADMIN.CAROUSEL_EDIT(row.id))) },
|
|
156
233
|
{
|
|
157
234
|
label: ACTIONS.ADMIN["delete-carousel"].label,
|
|
158
235
|
destructive: true,
|
|
@@ -163,5 +240,20 @@ export function AdminCarouselView({ children, onBulkDelete, ...props }) {
|
|
|
163
240
|
];
|
|
164
241
|
return (_jsxs(Div, { className: "min-h-screen", children: [_jsx(ListingToolbar, { filterCount: activeFilterCount, onFiltersClick: openFilters, searchValue: searchInput,
|
|
165
242
|
// Search intentionally absent: this endpoint does not read `q`, so the box accepted typing and changed nothing. Restore it when the collection gains searchTxt — audit-listing-search-capability tracks it.
|
|
166
|
-
onSearchChange: setSearchInput, onSearchCommit: commitSearch, showTableView: true, view: view, onViewChange: (v) => setView(v), onResetAll: resetAll, hasActiveState: hasActiveState
|
|
243
|
+
onSearchChange: setSearchInput, onSearchCommit: commitSearch, showTableView: true, view: view, onViewChange: (v) => setView(v), onResetAll: resetAll, hasActiveState: hasActiveState,
|
|
244
|
+
/*
|
|
245
|
+
* 🛑 The empty state named a button that did not exist: "No carousel
|
|
246
|
+
* slides — use 'New slide' to add one", with no New slide control
|
|
247
|
+
* anywhere on the page. `/admin/carousel/new` has always been a real
|
|
248
|
+
* route and nothing linked to it.
|
|
249
|
+
*/
|
|
250
|
+
extra: _jsx(Button, { gap: "sm", size: "sm", asChild: true, children: _jsx(TextLink, { variant: "inherit", href: String(ROUTES.ADMIN.CAROUSEL_NEW), children: "New slide" }) }) }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: currentPage, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), selection.selectedCount > 0 && bulkActions.length > 0 && (_jsx(BulkActionBar, { selectedCount: selection.selectedCount, actions: bulkActions, onClearSelection: selection.clearSelection })), _jsxs(Div, { paddingX: "x-sm-md", padding: "y-md", children: [errorMessage && (_jsx(Div, { textSize: "sm", className: "mb-4 border border-error/20", color: "error", surface: "danger-surface", padding: "inline", rounded: "xl", children: errorMessage })), _jsx(DataTable, { rows: displayRows, columns: columns, isLoading: isLoading, emptyLabel: "No carousel slides yet", emptyAction: _jsx(Button, { gap: "sm", size: "sm", asChild: true, children: _jsx(TextLink, { variant: "inherit", href: String(ROUTES.ADMIN.CAROUSEL_NEW), children: "New slide" }) }), rowHrefTemplate: "/admin/carousel/{id}/edit",
|
|
251
|
+
/*
|
|
252
|
+
* Selection was destructured and a bulk-action array built from it,
|
|
253
|
+
* and these three props were never passed — so no checkbox column
|
|
254
|
+
* rendered and nothing could be selected to act on.
|
|
255
|
+
*/
|
|
256
|
+
selectedIds: selection.selectedIdSet, onToggleSelect: (id) => selection.toggle(id), onToggleSelectAll: () => selection.toggleAll() })] }), _jsx(ListingFilterDrawer, { open: filterOpen, onClose: () => setFilterOpen(false), onApply: applyFilters, onClear: clearFilters, activeCount: activeFilterCount, children: _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { className: "tracking-widest", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Status" }), _jsx(Row, { wrap: true, gap: "sm", children: [{ label: "All", value: "" }, { label: "Active", value: "true" }, { label: "Inactive", value: "false" }].map((opt) => (_jsx(Button, { variant: "ghost", type: "button", onClick: () => setPendingFilters((p) => ({ ...p, active: opt.value })), className: `rounded-full px-[var(--appkit-space-3)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium border transition-colors min-h-0 h-auto ${(pendingFilters.active || "") === opt.value ? "bg-primary text-white border-primary" : "border-[var(--appkit-color-border)] text-[var(--appkit-color-text-muted)] hover:bg-surface-hover"}`, children: opt.label }, opt.label))) })] }) }), deleteTargetId && (_jsx(ConfirmDeleteModal, { isOpen: true,
|
|
257
|
+
/* Rule #7: confirmation copy lives on the ActionDef, not here. */
|
|
258
|
+
title: ACTIONS.ADMIN["delete-carousel"].confirmation?.title, message: ACTIONS.ADMIN["delete-carousel"].confirmation?.body, confirmText: ACTIONS.ADMIN["delete-carousel"].confirmation?.confirmLabel, onConfirm: () => { deleteMutation.mutate(deleteTargetId); setDeleteTargetId(null); }, onClose: () => setDeleteTargetId(null), isDeleting: deleteMutation.isPending }))] }));
|
|
167
259
|
}
|
|
@@ -56,6 +56,9 @@ export function AdminCouponEditorView({ couponId, onSaved, onDeleted, embedded,
|
|
|
56
56
|
minPurchase: "",
|
|
57
57
|
buyQuantity: "1",
|
|
58
58
|
getQuantity: "1",
|
|
59
|
+
// Blank, not a number — the ceiling is optional and a default here would
|
|
60
|
+
// silently cap a new BOGO coupon the admin never meant to cap.
|
|
61
|
+
maxItemPrice: "",
|
|
59
62
|
totalLimit: "",
|
|
60
63
|
perUserLimit: "",
|
|
61
64
|
currentUsage: 0,
|
|
@@ -101,6 +104,7 @@ export function AdminCouponEditorView({ couponId, onSaved, onDeleted, embedded,
|
|
|
101
104
|
minPurchase: c.discount?.minPurchase !== undefined ? String(c.discount.minPurchase) : "",
|
|
102
105
|
buyQuantity: c.bxgy?.buyQuantity !== undefined ? String(c.bxgy.buyQuantity) : "1",
|
|
103
106
|
getQuantity: c.bxgy?.getQuantity !== undefined ? String(c.bxgy.getQuantity) : "1",
|
|
107
|
+
maxItemPrice: c.bxgy?.maxItemPrice !== undefined ? String(c.bxgy.maxItemPrice) : "",
|
|
104
108
|
totalLimit: c.usage?.totalLimit !== undefined ? String(c.usage.totalLimit) : "",
|
|
105
109
|
perUserLimit: c.usage?.perUserLimit !== undefined ? String(c.usage.perUserLimit) : "",
|
|
106
110
|
currentUsage: c.usage?.currentUsage ?? 0,
|
|
@@ -147,6 +151,10 @@ export function AdminCouponEditorView({ couponId, onSaved, onDeleted, embedded,
|
|
|
147
151
|
bxgy: {
|
|
148
152
|
buyQuantity: num(draft.buyQuantity) ?? 1,
|
|
149
153
|
getQuantity: num(draft.getQuantity) ?? 1,
|
|
154
|
+
// `num("")` is undefined, and an undefined ceiling is "no limit" —
|
|
155
|
+
// which is what a blank box should mean. Do not `?? 0` it: zero
|
|
156
|
+
// would disqualify every unit and silently zero the coupon.
|
|
157
|
+
maxItemPrice: num(draft.maxItemPrice),
|
|
150
158
|
},
|
|
151
159
|
}),
|
|
152
160
|
usage: {
|