@mohasinac/appkit 4.60.1 → 4.61.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/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 +84 -57
- 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/features/promotions/schema.d.ts +66 -0
- package/dist/_internal/shared/features/promotions/schema.js +7 -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/core/unit-of-work.js +15 -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/admin/components/AdminCouponEditorView.js +8 -0
- package/dist/features/admin/components/AdminGatewayEventsView.js +2 -2
- package/dist/features/admin/components/DataListingView.js +2 -0
- package/dist/features/admin/components/analytics/AdminStatCard.js +4 -1
- 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/auctions/components/MarketplaceAuctionCard.js +1 -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/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 +72 -45
- 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/obligations/repository/obligations.repository.js +10 -1
- package/dist/features/orders/schemas/firestore.d.ts +19 -0
- package/dist/features/orders/schemas/firestore.js +19 -0
- 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/MarketplaceBundleCard.js +1 -1
- package/dist/features/products/components/MarketplacePrizeDrawCard.js +1 -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/schemas/coupon-form.d.ts +5 -0
- package/dist/features/seller/schemas/coupon-form.js +18 -0
- package/dist/features/shell/SectionForm.js +31 -9
- package/dist/features/sticker-trace/server.d.ts +2 -0
- package/dist/http/api-handler.js +4 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/next/routing/route-map.d.ts +0 -2
- package/dist/next/routing/route-map.js +10 -1
- package/dist/schemas/registry.d.ts +11 -6
- package/dist/server.d.ts +0 -1
- package/dist/server.js +5 -3
- package/dist/styles.css +51 -0
- package/dist/ui/components/PaginatedSelect.js +0 -0
- 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";
|
|
@@ -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: {
|
|
@@ -52,7 +52,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
52
52
|
*/
|
|
53
53
|
import React, { useState } from "react";
|
|
54
54
|
import { sieveFilter, SIEVE_OP, sortBy, } from "@mohasinac/appkit/client";
|
|
55
|
-
import { FilterChipGroup, ListingLayout, Modal, RecordDetailModal, Row, RowActionMenu, Stack, Text, Button, } from "../../../ui/index.js";
|
|
55
|
+
import { FilterChipGroup, ListingLayout, Modal, RecordDetailModal, Row, RowActionMenu, Span, Stack, Text, Button, } from "../../../ui/index.js";
|
|
56
56
|
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
57
57
|
import { ADMIN_GATEWAY_EVENT_KIND_TABS, ADMIN_GATEWAY_EVENT_STATUS_TABS, } from "../constants/filter-tabs.js";
|
|
58
58
|
import { toRecordArray, toRelativeDate, toStringValue, } from "../hooks/useAdminListingData.js";
|
|
@@ -205,5 +205,5 @@ export function AdminGatewayEventsView({ children, ...props }) {
|
|
|
205
205
|
*/
|
|
206
206
|
metadata: detail && detail.payload && typeof detail.payload === "object"
|
|
207
207
|
? detail.payload
|
|
208
|
-
: undefined, footer: detail ? (_jsx(Button, { variant: "outline", size: "sm", onClick: () => setReopenId(toStringValue(detail.id, "")), children: "Reopen for redelivery" })) : undefined }), _jsx(Modal, { isOpen: reopenId !== null, onClose: () => setReopenId(null), title: "Reopen this event for redelivery", size: "sm", children: _jsxs(Stack, { gap: "md", children: [_jsxs(Text, { size: "sm", children: ["This clears the terminal flag and the attempt count so the inbox will ", _jsx("
|
|
208
|
+
: undefined, footer: detail ? (_jsx(Button, { variant: "outline", size: "sm", onClick: () => setReopenId(toStringValue(detail.id, "")), children: "Reopen for redelivery" })) : undefined }), _jsx(Modal, { isOpen: reopenId !== null, onClose: () => setReopenId(null), title: "Reopen this event for redelivery", size: "sm", children: _jsxs(Stack, { gap: "md", children: [_jsxs(Text, { size: "sm", children: ["This clears the terminal flag and the attempt count so the inbox will ", _jsx(Span, { weight: "semibold", children: "accept" }), " the next delivery of this event instead of refusing it as a duplicate."] }), _jsx(Row, { surface: "warning-surface", padding: "sm", rounded: "default", children: _jsxs(Text, { size: "sm", color: "warning", children: ["It does not reprocess anything by itself. Nothing outside the webhook route knows how to handle an event, so after this you still need to press ", _jsx(Span, { weight: "semibold", children: "Resend" }), " on this delivery in the gateway dashboard \u2014 or wait for the gateway\u2019s own retry."] }) }), _jsxs(Row, { justify: "end", gap: "sm", children: [_jsx(Button, { variant: "ghost", onClick: () => setReopenId(null), disabled: busy, children: "Cancel" }), _jsx(Button, { variant: "primary", isLoading: busy, disabled: busy, onClick: () => reopen(reopenId), children: "Reopen" })] })] }) })] }));
|
|
209
209
|
}
|
|
@@ -211,6 +211,8 @@ export function DataListingView({ config, }) {
|
|
|
211
211
|
* which is the same as having no label at all.
|
|
212
212
|
*/
|
|
213
213
|
const rowEditLabel = React.useCallback((row) => {
|
|
214
|
+
// `string | undefined`, not `unknown`: a row primary is a label or it
|
|
215
|
+
// is absent, and the `typeof` test below is a real narrowing either way.
|
|
214
216
|
const primary = row.primary;
|
|
215
217
|
const name = typeof primary === "string" && primary.trim() ? primary.trim() : row.id;
|
|
216
218
|
return `${ROW_ACTION_META[ROW_ACTION_ID.EDIT].label} ${name}`;
|
|
@@ -79,7 +79,10 @@ export function AdminStatCard({ label, value, sub, icon, gradient = "brand", cla
|
|
|
79
79
|
: `${up ? "up" : "down"} ${Math.abs(trendPercent).toFixed(1).replace(/\.0$/, "")}%`;
|
|
80
80
|
// The sparkline's colour follows the same good/bad rule, not its slope.
|
|
81
81
|
const sparkRising = good || (flat && higherIsBetter);
|
|
82
|
-
return (_jsxs(Div, { className: `relative border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] overflow-hidden ${className}`, rounded: "xl", shadow: "sm-hover-md", children: [_jsx(DynamicBgDiv, { background: resolvedGradient, className: "absolute top-0 left-0 right-0 h-[3px]", "aria-hidden": "true" }), _jsxs(Row, { paddingX: "x-5", paddingY: "b-md-lg", padding: "t-lg", align: "start", justify: "between", gap: "3", children: [_jsxs(Div, { className: "min-w-0 flex-1", children: [_jsx(Text, { className: "text-[11px] tracking-widest text-[var(--appkit-color-text-muted)]", weight: "semibold", transform: "uppercase", children: label }), _jsx(Heading, { level: 3, className: "mt-2 tabular-nums leading-none text-[var(--appkit-color-text)]", size: "2xl", weight: "bold", children: value }), sub && (_jsx(Text, { className: "mt-1.5 text-[var(--appkit-color-text-muted)]", size: "xs", children: sub }))] }), icon && (_jsx(DynamicBgDiv, { background: resolvedGradient, className: "flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg text-[length:var(--appkit-text-xl)]", children: icon }))] }), (showTrend || showSpark) && (_jsxs(Row, { paddingX: "x-5", padding: "b-md", align: "center", justify: "between", gap: "3", children: [showTrend ? (_jsxs(Text, { size: "xs", color: trendTone, weight: "semibold", children: [_jsxs("span",
|
|
82
|
+
return (_jsxs(Div, { className: `relative border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] overflow-hidden ${className}`, rounded: "xl", shadow: "sm-hover-md", children: [_jsx(DynamicBgDiv, { background: resolvedGradient, className: "absolute top-0 left-0 right-0 h-[3px]", "aria-hidden": "true" }), _jsxs(Row, { paddingX: "x-5", paddingY: "b-md-lg", padding: "t-lg", align: "start", justify: "between", gap: "3", children: [_jsxs(Div, { className: "min-w-0 flex-1", children: [_jsx(Text, { className: "text-[11px] tracking-widest text-[var(--appkit-color-text-muted)]", weight: "semibold", transform: "uppercase", children: label }), _jsx(Heading, { level: 3, className: "mt-2 tabular-nums leading-none text-[var(--appkit-color-text)]", size: "2xl", weight: "bold", children: value }), sub && (_jsx(Text, { className: "mt-1.5 text-[var(--appkit-color-text-muted)]", size: "xs", children: sub }))] }), icon && (_jsx(DynamicBgDiv, { background: resolvedGradient, className: "flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg text-[length:var(--appkit-text-xl)]", children: icon }))] }), (showTrend || showSpark) && (_jsxs(Row, { paddingX: "x-5", padding: "b-md", align: "center", justify: "between", gap: "3", children: [showTrend ? (_jsxs(Text, { size: "xs", color: trendTone, weight: "semibold", children: [_jsxs(Text, { as: "span", "aria-hidden": "true", children: [trendArrow, " "] }), trendText, trendLabel ? (_jsxs(Text, { as: "span", size: "xs", color: "muted", weight: "normal", children: [" ", trendLabel] })) : null] })) : (
|
|
83
|
+
/* A layout placeholder keeping the grid cell occupied — <Div> is
|
|
84
|
+
the primitive for that; a bare <span/> carries no semantics. */
|
|
85
|
+
_jsx(Div, {})), showSpark && (
|
|
83
86
|
/*
|
|
84
87
|
🛑 `aria-hidden`, with NO accessible label. A sparkline is a
|
|
85
88
|
decorative restatement of a trend already given in words
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { FirestoreDocument } from "../../../schemas/types";
|
|
1
2
|
/**
|
|
2
3
|
* Site Settings Repository
|
|
3
4
|
*
|
|
@@ -26,7 +27,7 @@ import { SiteSettingsDocument, SiteSettingsCredentialsMasked, SiteSettingsUpdate
|
|
|
26
27
|
* anything whose prototype is not `Object.prototype`, which also covers
|
|
27
28
|
* `FieldValue` sentinels and `Timestamp` itself.
|
|
28
29
|
*/
|
|
29
|
-
export declare function toDottedPaths(input:
|
|
30
|
+
export declare function toDottedPaths(input: FirestoreDocument, prefix?: string): FirestoreDocument;
|
|
30
31
|
/**
|
|
31
32
|
* Repository for site settings management
|
|
32
33
|
*
|
|
@@ -38,6 +38,13 @@ import { decryptSecret, encryptSecret, maskSecret } from "../../../security/inde
|
|
|
38
38
|
* anything whose prototype is not `Object.prototype`, which also covers
|
|
39
39
|
* `FieldValue` sentinels and `Timestamp` itself.
|
|
40
40
|
*/
|
|
41
|
+
/*
|
|
42
|
+
* `FirestoreDocument`, not `Record<string, unknown>` — its own docstring says
|
|
43
|
+
* "use as the document shape for Firestore-backed repositories and mapDoc
|
|
44
|
+
* helpers". `unknown` values made every branch below unchecked, which is what
|
|
45
|
+
* audit-unknown-leakage reports; `FirestoreValue` admits exactly what Firestore
|
|
46
|
+
* round-trips (JSON + Date + undefined) and nothing else.
|
|
47
|
+
*/
|
|
41
48
|
export function toDottedPaths(input, prefix = "") {
|
|
42
49
|
const out = {};
|
|
43
50
|
for (const [key, value] of Object.entries(input)) {
|
|
@@ -202,5 +202,5 @@ export function MarketplaceAuctionCard({ product, buyoutPrice, className = "", v
|
|
|
202
202
|
* Sibling of the card, never a child — see the prop's docstring. `z-20`
|
|
203
203
|
* clears this card's own overlays, which all sit at `z-10`.
|
|
204
204
|
*/
|
|
205
|
-
return (_jsxs(Div, {
|
|
205
|
+
return (_jsxs(Div, { layout: "flex-col", className: "h-full", children: [card, _jsx(Div, { layout: "flex", justify: "end", className: "px-[var(--appkit-space-3)] pb-[var(--appkit-space-3)]", children: ownerActions })] }));
|
|
206
206
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ActionResult } from "
|
|
1
|
+
import { type ActionResult } from "../../../_internal/shared/types/action-result";
|
|
2
2
|
/**
|
|
3
3
|
* Admin path — approves a buyer's "Request to sell", creating the product
|
|
4
4
|
* under the platform's own consignment store (not the buyer's, since a
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
"use server";
|
|
2
|
-
|
|
2
|
+
/*
|
|
3
|
+
* 🛑 From the DEFINING module, never "@mohasinac/appkit/server".
|
|
4
|
+
*
|
|
5
|
+
* appkit importing its own public server barrel is a self-reference that drags
|
|
6
|
+
* the whole server surface in behind it — `dist/server.js` reaches next/og,
|
|
7
|
+
* next/navigation, next/headers and driver.js. Twenty `actions.ts` files did
|
|
8
|
+
* this for two symbols, and because `obligationSweep` reaches one of them, the
|
|
9
|
+
* Firebase Functions tsup bundle (noExternal: appkit) had to resolve Next:
|
|
10
|
+
* it failed on two `react-server-dom-webpack/*` requires and two @vercel/og
|
|
11
|
+
* .wasm files, naming none of the responsible files. Root Cause #18.
|
|
12
|
+
*/
|
|
13
|
+
import { wrapAction } from "../../../_internal/shared/types/action-result.js";
|
|
3
14
|
import { catalogueRepository } from "../../../repositories/index.js";
|
|
4
15
|
import { requireRoleUser } from "../../../providers/auth-firebase/helpers.js";
|
|
5
16
|
import { isAdminUser } from "../../../features/auth/role-predicates.js";
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
"use server";
|
|
2
|
-
|
|
2
|
+
/*
|
|
3
|
+
* 🛑 From the DEFINING module, never "@mohasinac/appkit/server".
|
|
4
|
+
*
|
|
5
|
+
* appkit importing its own public server barrel is a self-reference that drags
|
|
6
|
+
* the whole server surface in behind it — `dist/server.js` reaches next/og,
|
|
7
|
+
* next/navigation, next/headers and driver.js. Twenty `actions.ts` files did
|
|
8
|
+
* this for two symbols, and because `obligationSweep` reaches one of them, the
|
|
9
|
+
* Firebase Functions tsup bundle (noExternal: appkit) had to resolve Next:
|
|
10
|
+
* it failed on two `react-server-dom-webpack/*` requires and two @vercel/og
|
|
11
|
+
* .wasm files, naming none of the responsible files. Root Cause #18.
|
|
12
|
+
*/
|
|
13
|
+
import { wrapAction } from "../../../_internal/shared/types/action-result.js";
|
|
3
14
|
import { catalogueRepository, storeRepository } from "../../../repositories/index.js";
|
|
4
15
|
import { requireRoleUser } from "../../../providers/auth-firebase/helpers.js";
|
|
5
16
|
import { isAdminUser } from "../../../features/auth/role-predicates.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ActionResult } from "
|
|
1
|
+
import { type ActionResult } from "../../../_internal/shared/types/action-result";
|
|
2
2
|
/**
|
|
3
3
|
* Buyer path — requests admin list the item on their behalf. Flips
|
|
4
4
|
* listingStatus to "pending_admin_approval"; the admin-notification fan-out
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
"use server";
|
|
2
|
-
|
|
2
|
+
/*
|
|
3
|
+
* 🛑 From the DEFINING module, never "@mohasinac/appkit/server".
|
|
4
|
+
*
|
|
5
|
+
* appkit importing its own public server barrel is a self-reference that drags
|
|
6
|
+
* the whole server surface in behind it — `dist/server.js` reaches next/og,
|
|
7
|
+
* next/navigation, next/headers and driver.js. Twenty `actions.ts` files did
|
|
8
|
+
* this for two symbols, and because `obligationSweep` reaches one of them, the
|
|
9
|
+
* Firebase Functions tsup bundle (noExternal: appkit) had to resolve Next:
|
|
10
|
+
* it failed on two `react-server-dom-webpack/*` requires and two @vercel/og
|
|
11
|
+
* .wasm files, naming none of the responsible files. Root Cause #18.
|
|
12
|
+
*/
|
|
13
|
+
import { wrapAction } from "../../../_internal/shared/types/action-result.js";
|
|
3
14
|
import { catalogueRepository } from "../../../repositories/index.js";
|
|
4
15
|
import { requireRoleUser } from "../../../providers/auth-firebase/helpers.js";
|
|
5
16
|
import { ValidationError, AuthorizationError } from "../../../errors/index.js";
|
|
@@ -113,6 +113,25 @@ export interface PriceObservationDocument {
|
|
|
113
113
|
createdAt: Date;
|
|
114
114
|
}
|
|
115
115
|
export declare const PRICE_OBSERVATION_COLLECTION = "priceObservations";
|
|
116
|
+
/**
|
|
117
|
+
* Field names, so a query names them through a constant rather than a literal.
|
|
118
|
+
*
|
|
119
|
+
* `audit-route-nav-field-constants` R3 blocks a raw `where("categoryId", …)`,
|
|
120
|
+
* and the constants it suggested for this one were
|
|
121
|
+
* `MODEL_COMPOSITION_FIELDS.CATEGORY_ID | BEY_FIELDS.CATEGORY_ID` — two
|
|
122
|
+
* different documents that happen to share the field NAME. Borrowing either
|
|
123
|
+
* would make the query read as though it were about that document, which is
|
|
124
|
+
* worse than the literal it replaced. A price observation needs its own.
|
|
125
|
+
*/
|
|
126
|
+
export declare const PRICE_OBSERVATION_FIELDS: {
|
|
127
|
+
readonly CATEGORY_ID: "categoryId";
|
|
128
|
+
readonly AMOUNT: "amount";
|
|
129
|
+
readonly CURRENCY: "currency";
|
|
130
|
+
readonly KIND: "kind";
|
|
131
|
+
readonly SEEN_AT: "seenAt";
|
|
132
|
+
readonly SOLD_OUT: "soldOut";
|
|
133
|
+
readonly SOURCE_KEY: "sourceKey";
|
|
134
|
+
};
|
|
116
135
|
/**
|
|
117
136
|
* Hard ceiling on one leaf's observation read.
|
|
118
137
|
*
|
|
@@ -59,6 +59,25 @@
|
|
|
59
59
|
*/
|
|
60
60
|
import { BaseRepository } from "../../../providers/db-firebase/index.js";
|
|
61
61
|
export const PRICE_OBSERVATION_COLLECTION = "priceObservations";
|
|
62
|
+
/**
|
|
63
|
+
* Field names, so a query names them through a constant rather than a literal.
|
|
64
|
+
*
|
|
65
|
+
* `audit-route-nav-field-constants` R3 blocks a raw `where("categoryId", …)`,
|
|
66
|
+
* and the constants it suggested for this one were
|
|
67
|
+
* `MODEL_COMPOSITION_FIELDS.CATEGORY_ID | BEY_FIELDS.CATEGORY_ID` — two
|
|
68
|
+
* different documents that happen to share the field NAME. Borrowing either
|
|
69
|
+
* would make the query read as though it were about that document, which is
|
|
70
|
+
* worse than the literal it replaced. A price observation needs its own.
|
|
71
|
+
*/
|
|
72
|
+
export const PRICE_OBSERVATION_FIELDS = {
|
|
73
|
+
CATEGORY_ID: "categoryId",
|
|
74
|
+
AMOUNT: "amount",
|
|
75
|
+
CURRENCY: "currency",
|
|
76
|
+
KIND: "kind",
|
|
77
|
+
SEEN_AT: "seenAt",
|
|
78
|
+
SOLD_OUT: "soldOut",
|
|
79
|
+
SOURCE_KEY: "sourceKey",
|
|
80
|
+
};
|
|
62
81
|
/**
|
|
63
82
|
* Hard ceiling on one leaf's observation read.
|
|
64
83
|
*
|
|
@@ -88,8 +107,8 @@ export class PriceObservationRepository extends BaseRepository {
|
|
|
88
107
|
*/
|
|
89
108
|
async findForLeaf(categoryId, limit = PRICE_OBSERVATION_SCAN_LIMIT) {
|
|
90
109
|
const snap = await this.getCollection()
|
|
91
|
-
.where(
|
|
92
|
-
.orderBy(
|
|
110
|
+
.where(PRICE_OBSERVATION_FIELDS.CATEGORY_ID, "==", categoryId)
|
|
111
|
+
.orderBy(PRICE_OBSERVATION_FIELDS.SEEN_AT, "desc")
|
|
93
112
|
.limit(limit)
|
|
94
113
|
.get();
|
|
95
114
|
return snap.docs.map((d) => this.mapDoc(d));
|
|
@@ -17,6 +17,7 @@ const ERR_EVENT_NOT_FOUND = "Event not found";
|
|
|
17
17
|
import { coerceMediaField, getMediaUrl } from "../../../utils/index.js";
|
|
18
18
|
import { finalizeStagedMediaField, finalizeStagedMediaObject, finalizeStagedMediaObjectArray, } from "../../media/finalize.js";
|
|
19
19
|
import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility.js";
|
|
20
|
+
import { sortPinnedFirst } from "../utils/sort-pinned.js";
|
|
20
21
|
// --- Admin: Create Event --------------------------------------------------
|
|
21
22
|
export async function createEvent(adminId, input) {
|
|
22
23
|
const finalizedCoverUrl = await finalizeStagedMediaField(getMediaUrl(input.coverImage) ??
|
|
@@ -139,7 +140,9 @@ export async function listPublicEvents(params) {
|
|
|
139
140
|
pageSize,
|
|
140
141
|
});
|
|
141
142
|
// PUBLIC by name — it backs the homepage strip and /events.
|
|
142
|
-
|
|
143
|
+
// `sortPinnedFirst` AFTER the test-data filter, so a pinned sandbox event
|
|
144
|
+
// cannot occupy the rail's first slot for a visitor who cannot see it.
|
|
145
|
+
return { ...result, items: sortPinnedFirst(hidePublicTestData(result.items)) };
|
|
143
146
|
}
|
|
144
147
|
/**
|
|
145
148
|
* 🛑 ONLY `draft` IS HIDDEN. Every other status is public history.
|
|
@@ -135,6 +135,7 @@ const DEFAULT_DRAFT = {
|
|
|
135
135
|
status: "draft",
|
|
136
136
|
createdBy: "",
|
|
137
137
|
allowGuestParticipation: false,
|
|
138
|
+
isPinned: false,
|
|
138
139
|
discountPercent: "10",
|
|
139
140
|
saleBannerText: "",
|
|
140
141
|
couponId: "",
|
|
@@ -211,6 +212,72 @@ function SpinPrizesEditor({ values, onChange, }) {
|
|
|
211
212
|
return (_jsxs(Stack, { gap: "sm", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Text, { size: "xs", weight: "medium", color: "muted", children: "Spin prizes (weighted random)" }), values.spinPrizes.length === 0 && (_jsx(Text, { size: "xs", color: "muted", children: "No spin prizes yet. Add at least one to enable spinning." })), values.spinPrizes.map((p) => (_jsxs(Grid, { align: "end", gap: "xs", className: "grid-cols-12", children: [_jsx(Div, { className: "col-span-5", children: _jsx(Input, { label: "Label", value: p.label, onChange: (e) => onChange({ spinPrizes: values.spinPrizes.map((sp) => sp.id === p.id ? { ...sp, label: e.target.value } : sp) }), placeholder: "\u20B9100 off" }) }), _jsxs(Div, { className: "col-span-3", children: [_jsx(Text, { size: "xs", weight: "medium", className: "mb-1", children: "Coupon" }), _jsx(CouponInlineSelect, { scope: "admin", value: p.couponId ?? "", onChange: (id) => onChange({ spinPrizes: values.spinPrizes.map((sp) => sp.id === p.id ? { ...sp, couponId: id || undefined } : sp) }), placeholder: "Search\u2026", allowCreate: false })] }), _jsx(Div, { className: "col-span-2", children: _jsx(Input, { label: "Weight", type: "number", value: String(p.weight), onChange: (e) => onChange({ spinPrizes: values.spinPrizes.map((sp) => sp.id === p.id ? { ...sp, weight: Number(e.target.value) || 0 } : sp) }) }) }), _jsx(Row, { centered: true, className: "col-span-1", padding: "b-xs", children: _jsx(Toggle, { checked: p.isActive, onChange: (v) => onChange({ spinPrizes: values.spinPrizes.map((sp) => sp.id === p.id ? { ...sp, isActive: v } : sp) }), label: "" }) }), _jsx(Row, { centered: true, className: "col-span-1", padding: "b-xs", children: _jsx(Button, { variant: "ghost", type: "button", onClick: () => onChange({ spinPrizes: values.spinPrizes.filter((sp) => sp.id !== p.id) }), className: CLS_REMOVE_BTN_LG, "aria-label": "Remove prize", children: "\u00D7" }) })] }, p.id))), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => onChange({ spinPrizes: [...values.spinPrizes, { id: `prize-${Date.now()}`, label: "", couponId: undefined, weight: 1, isActive: true }] }), children: "+ Add spin prize" }), _jsx(Input, { label: "Max spins per user", type: "number", value: values.spinMaxPerUser, onChange: (e) => onChange({ spinMaxPerUser: e.target.value }), placeholder: "1" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Input, { label: "Spin window start", type: "datetime-local", value: values.spinWindowStart, onChange: (e) => onChange({ spinWindowStart: e.target.value }) }), _jsx(Input, { label: "Spin window end", type: "datetime-local", value: values.spinWindowEnd, onChange: (e) => onChange({ spinWindowEnd: e.target.value }) })] })] }));
|
|
212
213
|
}
|
|
213
214
|
// --- Component ---------------------------------------------------------------
|
|
215
|
+
/**
|
|
216
|
+
* The API's event shape -> the editor's draft shape.
|
|
217
|
+
*
|
|
218
|
+
* A pure adapter at module scope. The extraction is not cosmetic: this was
|
|
219
|
+
* 45 lines of field mapping inside a `useEffect` inside a 455-line
|
|
220
|
+
* component, which is exactly where a wrong default hides.
|
|
221
|
+
*
|
|
222
|
+
* Every `|| ""` and `Boolean()` is load-bearing. An ABSENT field — any event
|
|
223
|
+
* predating the one that added it — must still seed a CONTROLLED input;
|
|
224
|
+
* `undefined` makes React treat it as uncontrolled and the field stops
|
|
225
|
+
* accepting edits.
|
|
226
|
+
*
|
|
227
|
+
* 🛑 `requireLogin: event.surveyConfig?.requireLogin !== false` is the one
|
|
228
|
+
* default deliberately INVERTED — absent means true, because a survey that
|
|
229
|
+
* silently stopped requiring a login would collect anonymous entries nobody
|
|
230
|
+
* asked for. Do not "tidy" it into `Boolean(...)`.
|
|
231
|
+
*/
|
|
232
|
+
function eventToDraft(event) {
|
|
233
|
+
return {
|
|
234
|
+
type: event.type || "sale",
|
|
235
|
+
title: event.title || "",
|
|
236
|
+
slug: event.slug || "",
|
|
237
|
+
description: event.description || "",
|
|
238
|
+
startsAt: toLocalDatetime(event.startsAt),
|
|
239
|
+
endsAt: toLocalDatetime(event.endsAt),
|
|
240
|
+
coverImageUrl: event.coverImageUrl || event.coverImage?.url || "",
|
|
241
|
+
tags: Array.isArray(event.tags) ? event.tags : [],
|
|
242
|
+
status: event.status || "draft",
|
|
243
|
+
createdBy: event.createdBy || "",
|
|
244
|
+
allowGuestParticipation: Boolean(event.allowGuestParticipation),
|
|
245
|
+
// `Boolean()` so an ABSENT field (every event predating the pin) seeds
|
|
246
|
+
// the toggle as off rather than leaving it `undefined`, which React
|
|
247
|
+
// would read as an uncontrolled input.
|
|
248
|
+
isPinned: Boolean(event.isPinned),
|
|
249
|
+
discountPercent: String(event.saleConfig?.discountPercent ?? 10),
|
|
250
|
+
saleBannerText: event.saleConfig?.bannerText || "",
|
|
251
|
+
couponId: event.offerConfig?.couponId || "",
|
|
252
|
+
displayCode: event.offerConfig?.displayCode || "",
|
|
253
|
+
offerBannerText: event.offerConfig?.bannerText || "",
|
|
254
|
+
pollOptions: event.pollConfig?.options?.length
|
|
255
|
+
? event.pollConfig.options
|
|
256
|
+
: [{ id: "opt1", label: "" }, { id: "opt2", label: "" }],
|
|
257
|
+
allowMultiSelect: Boolean(event.pollConfig?.allowMultiSelect),
|
|
258
|
+
allowComment: Boolean(event.pollConfig?.allowComment),
|
|
259
|
+
resultsVisibility: event.pollConfig?.resultsVisibility || "always",
|
|
260
|
+
requireLogin: event.surveyConfig?.requireLogin !== false,
|
|
261
|
+
maxEntriesPerUser: String(event.surveyConfig?.maxEntriesPerUser ?? 1),
|
|
262
|
+
hasLeaderboard: Boolean(event.surveyConfig?.hasLeaderboard),
|
|
263
|
+
hasPointSystem: Boolean(event.surveyConfig?.hasPointSystem),
|
|
264
|
+
pointsLabel: event.surveyConfig?.pointsLabel ?? "Points",
|
|
265
|
+
entryReviewRequired: Boolean(event.surveyConfig?.entryReviewRequired),
|
|
266
|
+
surveyFields: Array.isArray(event.surveyConfig?.formFields) ? event.surveyConfig.formFields : [],
|
|
267
|
+
anonymous: Boolean(event.feedbackConfig?.anonymous),
|
|
268
|
+
feedbackFields: Array.isArray(event.feedbackConfig?.formFields) ? event.feedbackConfig.formFields : [],
|
|
269
|
+
hasRaffle: Boolean(event.hasRaffle),
|
|
270
|
+
raffleType: event.raffleType || "open_raffle",
|
|
271
|
+
raffleTopN: String(event.raffleTopN ?? 10),
|
|
272
|
+
rafflePrize: event.rafflePrize || "",
|
|
273
|
+
rafflePrizeCouponId: event.rafflePrizeCouponId || "",
|
|
274
|
+
rafflePrizeProductIds: Array.isArray(event.rafflePrizeProductIds) ? event.rafflePrizeProductIds : [],
|
|
275
|
+
spinPrizes: Array.isArray(event.spinPrizes) ? event.spinPrizes : [],
|
|
276
|
+
spinMaxPerUser: String(event.spinMaxPerUser ?? 1),
|
|
277
|
+
spinWindowStart: toLocalDatetime(event.spinWindowStart),
|
|
278
|
+
spinWindowEnd: toLocalDatetime(event.spinWindowEnd),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
214
281
|
export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes, ...rest }) {
|
|
215
282
|
const isEdit = Boolean(eventId);
|
|
216
283
|
const [draft, setDraft] = React.useState(DEFAULT_DRAFT);
|
|
@@ -243,49 +310,8 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
|
|
|
243
310
|
const event = eventQuery.data?.data ?? eventQuery.data;
|
|
244
311
|
if (!event?.id)
|
|
245
312
|
return;
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
title: event.title || "",
|
|
249
|
-
slug: event.slug || "",
|
|
250
|
-
description: event.description || "",
|
|
251
|
-
startsAt: toLocalDatetime(event.startsAt),
|
|
252
|
-
endsAt: toLocalDatetime(event.endsAt),
|
|
253
|
-
coverImageUrl: event.coverImageUrl || event.coverImage?.url || "",
|
|
254
|
-
tags: Array.isArray(event.tags) ? event.tags : [],
|
|
255
|
-
status: event.status || "draft",
|
|
256
|
-
createdBy: event.createdBy || "",
|
|
257
|
-
allowGuestParticipation: Boolean(event.allowGuestParticipation),
|
|
258
|
-
discountPercent: String(event.saleConfig?.discountPercent ?? 10),
|
|
259
|
-
saleBannerText: event.saleConfig?.bannerText || "",
|
|
260
|
-
couponId: event.offerConfig?.couponId || "",
|
|
261
|
-
displayCode: event.offerConfig?.displayCode || "",
|
|
262
|
-
offerBannerText: event.offerConfig?.bannerText || "",
|
|
263
|
-
pollOptions: event.pollConfig?.options?.length
|
|
264
|
-
? event.pollConfig.options
|
|
265
|
-
: [{ id: "opt1", label: "" }, { id: "opt2", label: "" }],
|
|
266
|
-
allowMultiSelect: Boolean(event.pollConfig?.allowMultiSelect),
|
|
267
|
-
allowComment: Boolean(event.pollConfig?.allowComment),
|
|
268
|
-
resultsVisibility: event.pollConfig?.resultsVisibility || "always",
|
|
269
|
-
requireLogin: event.surveyConfig?.requireLogin !== false,
|
|
270
|
-
maxEntriesPerUser: String(event.surveyConfig?.maxEntriesPerUser ?? 1),
|
|
271
|
-
hasLeaderboard: Boolean(event.surveyConfig?.hasLeaderboard),
|
|
272
|
-
hasPointSystem: Boolean(event.surveyConfig?.hasPointSystem),
|
|
273
|
-
pointsLabel: event.surveyConfig?.pointsLabel ?? "Points",
|
|
274
|
-
entryReviewRequired: Boolean(event.surveyConfig?.entryReviewRequired),
|
|
275
|
-
surveyFields: Array.isArray(event.surveyConfig?.formFields) ? event.surveyConfig.formFields : [],
|
|
276
|
-
anonymous: Boolean(event.feedbackConfig?.anonymous),
|
|
277
|
-
feedbackFields: Array.isArray(event.feedbackConfig?.formFields) ? event.feedbackConfig.formFields : [],
|
|
278
|
-
hasRaffle: Boolean(event.hasRaffle),
|
|
279
|
-
raffleType: event.raffleType || "open_raffle",
|
|
280
|
-
raffleTopN: String(event.raffleTopN ?? 10),
|
|
281
|
-
rafflePrize: event.rafflePrize || "",
|
|
282
|
-
rafflePrizeCouponId: event.rafflePrizeCouponId || "",
|
|
283
|
-
rafflePrizeProductIds: Array.isArray(event.rafflePrizeProductIds) ? event.rafflePrizeProductIds : [],
|
|
284
|
-
spinPrizes: Array.isArray(event.spinPrizes) ? event.spinPrizes : [],
|
|
285
|
-
spinMaxPerUser: String(event.spinMaxPerUser ?? 1),
|
|
286
|
-
spinWindowStart: toLocalDatetime(event.spinWindowStart),
|
|
287
|
-
spinWindowEnd: toLocalDatetime(event.spinWindowEnd),
|
|
288
|
-
});
|
|
313
|
+
// The 45-line field mapping lives in `eventToDraft` at module scope.
|
|
314
|
+
setDraft(eventToDraft(event));
|
|
289
315
|
setSlugManual(true);
|
|
290
316
|
setRaffleWinnerName(event.raffleWinnerDisplayName || "");
|
|
291
317
|
setRaffleEntryCount(typeof event.raffleEntryCount === "number" ? event.raffleEntryCount : null);
|
|
@@ -304,6 +330,7 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
|
|
|
304
330
|
endsAt: toISOString(draft.endsAt),
|
|
305
331
|
coverImageUrl: draft.coverImageUrl || undefined,
|
|
306
332
|
allowGuestParticipation: draft.allowGuestParticipation,
|
|
333
|
+
isPinned: draft.isPinned,
|
|
307
334
|
...buildEventTypeConfig(draft),
|
|
308
335
|
...buildEventRaffleFields(draft),
|
|
309
336
|
};
|
|
@@ -397,11 +424,11 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
|
|
|
397
424
|
{
|
|
398
425
|
id: "settings",
|
|
399
426
|
label: "Settings",
|
|
400
|
-
fields: ["status", "allowGuestParticipation", "discountPercent", "saleBannerText", "couponId", "displayCode", "offerBannerText", "pollOptions", "resultsVisibility", "allowMultiSelect", "allowComment", "maxEntriesPerUser", "hasLeaderboard", "hasPointSystem", "pointsLabel", "entryReviewRequired", "surveyFields", "anonymous", "feedbackFields"],
|
|
427
|
+
fields: ["status", "allowGuestParticipation", "isPinned", "discountPercent", "saleBannerText", "couponId", "displayCode", "offerBannerText", "pollOptions", "resultsVisibility", "allowMultiSelect", "allowComment", "maxEntriesPerUser", "hasLeaderboard", "hasPointSystem", "pointsLabel", "entryReviewRequired", "surveyFields", "anonymous", "feedbackFields"],
|
|
401
428
|
// Same: the offer-coupon, offer-display-code and poll-minimum-options
|
|
402
429
|
// rules are all stated in eventDraftSchema's superRefine, which now
|
|
403
430
|
// surfaces them inline on the field instead of as a step-blocking banner.
|
|
404
|
-
render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Event Settings" }), isEdit && (_jsx(Select, { label: "Status", value: values.status, options: EVENT_STATUS_OPTIONS, onChange: (e) => onChange({ status: e.target.value }) })), values.createdBy && (_jsxs(Stack, { gap: "none", children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Created by" }), _jsx(Text, { size: "sm", color: "muted", children: values.createdBy })] })), _jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Toggle, { checked: values.allowGuestParticipation, onChange: (v) => onChange({ allowGuestParticipation: v }), label: "Allow guest participation (no login required)" }), _jsx(Text, { size: "xs", color: "muted", children: "When enabled, unauthenticated visitors can participate in this event (poll vote, survey/feedback response, raffle entry, or wheel spin), identified by a hashed IP address instead of an account. Applies uniformly to whatever entry mechanism this event type uses." })] }), values.type === "sale" && (_jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Sale configuration" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Input, { label: "Discount %", type: "number", value: values.discountPercent, onChange: (e) => onChange({ discountPercent: e.target.value }), placeholder: "10" }), _jsx(Input, { label: "Banner text (optional)", value: values.saleBannerText, onChange: (e) => onChange({ saleBannerText: e.target.value }), placeholder: "Limited time \u2014 ends Sunday!" })] })] })), values.type === "offer" && (_jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Offer configuration" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsxs(Div, { children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Coupon" }), _jsx(CouponInlineSelect, { scope: "admin", value: values.couponId, onChange: (id) => onChange({ couponId: id ?? "" }), placeholder: "Search coupons\u2026", allowCreate: false })] }), _jsx(Input, { label: "Display code", value: values.displayCode, onChange: (e) => onChange({ displayCode: e.target.value }), placeholder: "CHARIZARD25", required: true })] }), _jsx(Input, { label: "Banner text (optional)", value: values.offerBannerText, onChange: (e) => onChange({ offerBannerText: e.target.value }), placeholder: "Use CHARIZARD25 at checkout" })] })), values.type === "poll" && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Poll configuration" }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", color: "muted", children: "Options (minimum 2)" }), values.pollOptions.map((opt, idx) => (_jsxs(Row, { gap: "xs", children: [_jsx(Div, { className: "flex-1", children: _jsx(Input, { value: opt.label, onChange: (e) => onChange({ pollOptions: values.pollOptions.map((o) => o.id === opt.id ? { ...o, label: e.target.value } : o) }), placeholder: `Option ${idx + 1}` }) }), values.pollOptions.length > 2 && (_jsx(Button, { variant: "ghost", type: "button", onClick: () => onChange({ pollOptions: values.pollOptions.filter((o) => o.id !== opt.id) }), className: CLS_REMOVE_BTN_PX2, "aria-label": "Remove option", children: "\u00D7" }))] }, opt.id))), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => onChange({ pollOptions: [...values.pollOptions, { id: `opt${Date.now()}`, label: "" }] }), children: "+ Add option" })] }), _jsx(Select, { label: "Results visibility", value: values.resultsVisibility, options: POLL_VISIBILITY_OPTIONS, onChange: (e) => onChange({ resultsVisibility: e.target.value }) }), _jsxs(Stack, { gap: "sm", children: [_jsx(Toggle, { checked: values.allowMultiSelect, onChange: (v) => onChange({ allowMultiSelect: v }), label: "Allow multi-select" }), _jsx(Toggle, { checked: values.allowComment, onChange: (v) => onChange({ allowComment: v }), label: "Allow comment with vote" })] })] })), values.type === "survey" && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Survey configuration" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Input, { label: "Max entries per user", type: "number", value: values.maxEntriesPerUser, onChange: (e) => onChange({ maxEntriesPerUser: e.target.value }), placeholder: "1" }), values.hasPointSystem && (_jsx(Input, { label: "Points label", value: values.pointsLabel, onChange: (e) => onChange({ pointsLabel: e.target.value }), placeholder: "Points" }))] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Toggle, { checked: values.requireLogin, onChange: (v) => onChange({ requireLogin: v }), label: "Require login to participate" }), _jsx(Toggle, { checked: values.hasLeaderboard, onChange: (v) => onChange({ hasLeaderboard: v }), label: "Show leaderboard" }), _jsx(Toggle, { checked: values.hasPointSystem, onChange: (v) => onChange({ hasPointSystem: v }), label: "Enable point system" }), _jsx(Toggle, { checked: values.entryReviewRequired, onChange: (v) => onChange({ entryReviewRequired: v }), label: "Require employee review before entry is approved" })] }), _jsx(FormFieldBuilder, { fields: values.surveyFields, setFields: (f) => onChange({ surveyFields: f }) })] })), values.type === "feedback" && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Feedback configuration" }), _jsx(Toggle, { checked: values.anonymous, onChange: (v) => onChange({ anonymous: v }), label: "Allow anonymous submissions" }), _jsx(FormFieldBuilder, { fields: values.feedbackFields, setFields: (f) => onChange({ feedbackFields: f }) })] }))] })),
|
|
431
|
+
render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Event Settings" }), isEdit && (_jsx(Select, { label: "Status", value: values.status, options: EVENT_STATUS_OPTIONS, onChange: (e) => onChange({ status: e.target.value }) })), values.createdBy && (_jsxs(Stack, { gap: "none", children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Created by" }), _jsx(Text, { size: "sm", color: "muted", children: values.createdBy })] })), _jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Toggle, { checked: values.allowGuestParticipation, onChange: (v) => onChange({ allowGuestParticipation: v }), label: "Allow guest participation (no login required)" }), _jsx(Text, { size: "xs", color: "muted", children: "When enabled, unauthenticated visitors can participate in this event (poll vote, survey/feedback response, raffle entry, or wheel spin), identified by a hashed IP address instead of an account. Applies uniformly to whatever entry mechanism this event type uses." })] }), _jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Toggle, { checked: values.isPinned, onChange: (v) => onChange({ isPinned: v }), label: "Pin to the top of Events" }), _jsx(Text, { size: "xs", color: "muted", children: "Pinned events are listed first on the Events page and in the homepage strip, ahead of the usual soonest-first order. A pinned event still has to be Active to appear at all \u2014 pinning does not publish it." })] }), values.type === "sale" && (_jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Sale configuration" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Input, { label: "Discount %", type: "number", value: values.discountPercent, onChange: (e) => onChange({ discountPercent: e.target.value }), placeholder: "10" }), _jsx(Input, { label: "Banner text (optional)", value: values.saleBannerText, onChange: (e) => onChange({ saleBannerText: e.target.value }), placeholder: "Limited time \u2014 ends Sunday!" })] })] })), values.type === "offer" && (_jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Offer configuration" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsxs(Div, { children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Coupon" }), _jsx(CouponInlineSelect, { scope: "admin", value: values.couponId, onChange: (id) => onChange({ couponId: id ?? "" }), placeholder: "Search coupons\u2026", allowCreate: false })] }), _jsx(Input, { label: "Display code", value: values.displayCode, onChange: (e) => onChange({ displayCode: e.target.value }), placeholder: "CHARIZARD25", required: true })] }), _jsx(Input, { label: "Banner text (optional)", value: values.offerBannerText, onChange: (e) => onChange({ offerBannerText: e.target.value }), placeholder: "Use CHARIZARD25 at checkout" })] })), values.type === "poll" && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Poll configuration" }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", color: "muted", children: "Options (minimum 2)" }), values.pollOptions.map((opt, idx) => (_jsxs(Row, { gap: "xs", children: [_jsx(Div, { className: "flex-1", children: _jsx(Input, { value: opt.label, onChange: (e) => onChange({ pollOptions: values.pollOptions.map((o) => o.id === opt.id ? { ...o, label: e.target.value } : o) }), placeholder: `Option ${idx + 1}` }) }), values.pollOptions.length > 2 && (_jsx(Button, { variant: "ghost", type: "button", onClick: () => onChange({ pollOptions: values.pollOptions.filter((o) => o.id !== opt.id) }), className: CLS_REMOVE_BTN_PX2, "aria-label": "Remove option", children: "\u00D7" }))] }, opt.id))), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => onChange({ pollOptions: [...values.pollOptions, { id: `opt${Date.now()}`, label: "" }] }), children: "+ Add option" })] }), _jsx(Select, { label: "Results visibility", value: values.resultsVisibility, options: POLL_VISIBILITY_OPTIONS, onChange: (e) => onChange({ resultsVisibility: e.target.value }) }), _jsxs(Stack, { gap: "sm", children: [_jsx(Toggle, { checked: values.allowMultiSelect, onChange: (v) => onChange({ allowMultiSelect: v }), label: "Allow multi-select" }), _jsx(Toggle, { checked: values.allowComment, onChange: (v) => onChange({ allowComment: v }), label: "Allow comment with vote" })] })] })), values.type === "survey" && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Survey configuration" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Input, { label: "Max entries per user", type: "number", value: values.maxEntriesPerUser, onChange: (e) => onChange({ maxEntriesPerUser: e.target.value }), placeholder: "1" }), values.hasPointSystem && (_jsx(Input, { label: "Points label", value: values.pointsLabel, onChange: (e) => onChange({ pointsLabel: e.target.value }), placeholder: "Points" }))] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Toggle, { checked: values.requireLogin, onChange: (v) => onChange({ requireLogin: v }), label: "Require login to participate" }), _jsx(Toggle, { checked: values.hasLeaderboard, onChange: (v) => onChange({ hasLeaderboard: v }), label: "Show leaderboard" }), _jsx(Toggle, { checked: values.hasPointSystem, onChange: (v) => onChange({ hasPointSystem: v }), label: "Enable point system" }), _jsx(Toggle, { checked: values.entryReviewRequired, onChange: (v) => onChange({ entryReviewRequired: v }), label: "Require employee review before entry is approved" })] }), _jsx(FormFieldBuilder, { fields: values.surveyFields, setFields: (f) => onChange({ surveyFields: f }) })] })), values.type === "feedback" && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Feedback configuration" }), _jsx(Toggle, { checked: values.anonymous, onChange: (v) => onChange({ anonymous: v }), label: "Allow anonymous submissions" }), _jsx(FormFieldBuilder, { fields: values.feedbackFields, setFields: (f) => onChange({ feedbackFields: f }) })] }))] })),
|
|
405
432
|
},
|
|
406
433
|
{
|
|
407
434
|
id: "raffle",
|
|
@@ -8,6 +8,7 @@ import { sieveFilter, sieveMultiEq, sieveAnd, SIEVE_OP } from "../../../utils/si
|
|
|
8
8
|
import { sortBy } from "../../../constants/sort.js";
|
|
9
9
|
import { safeRead } from "../../../errors/safe-read.js";
|
|
10
10
|
import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility.js";
|
|
11
|
+
import { sortPinnedFirst } from "../utils/sort-pinned.js";
|
|
11
12
|
function sp(params, key) {
|
|
12
13
|
const v = params[key];
|
|
13
14
|
return Array.isArray(v) ? v[0] ?? "" : v ?? "";
|
|
@@ -69,7 +70,7 @@ export async function EventsListPageView({ searchParams = {} }) {
|
|
|
69
70
|
* refetch threads the real viewer, so a signed-in tester still sees them.
|
|
70
71
|
*/
|
|
71
72
|
const publicResult = result
|
|
72
|
-
? { ...result, items: hidePublicTestData(result.items) }
|
|
73
|
+
? { ...result, items: sortPinnedFirst(hidePublicTestData(result.items)) }
|
|
73
74
|
: result;
|
|
74
75
|
return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, className: "mb-8", color: "primary", size: "3xl", weight: "semibold", children: "Events" }), _jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-6" }), _jsx(EventsIndexListing, { initialData: publicResult, facetCounts: facetCounts }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] }) }) }));
|
|
75
76
|
}
|
|
@@ -17,6 +17,22 @@ export interface EventDocument extends BaseDocument {
|
|
|
17
17
|
title: string;
|
|
18
18
|
description: string;
|
|
19
19
|
status: EventStatus;
|
|
20
|
+
/**
|
|
21
|
+
* Lift this event to the top of `/events` and the homepage rail, ahead of
|
|
22
|
+
* the `startsAt` ASC default.
|
|
23
|
+
*
|
|
24
|
+
* 🛑 DELIBERATELY NOT IN `EVENT_INDEXED_FIELDS` AND NOT SORTABLE IN SIEVE.
|
|
25
|
+
* A Firestore `orderBy` EXCLUDES every document that lacks the ordering
|
|
26
|
+
* field (Root Cause #100), and `isPinned` is absent from all 1 live event
|
|
27
|
+
* and every seeded row that is not pinned — so ordering by it would empty
|
|
28
|
+
* the events page entirely while answering HTTP 200. The ordering is
|
|
29
|
+
* applied in memory by `sortPinnedFirst`; see the bound stated there.
|
|
30
|
+
*
|
|
31
|
+
* Absent means not pinned. Do not default it to `false` on write — a
|
|
32
|
+
* written `false` and an absent field must stay interchangeable, because
|
|
33
|
+
* every event that predates this field has the latter.
|
|
34
|
+
*/
|
|
35
|
+
isPinned?: boolean;
|
|
20
36
|
/** Test-data flag — set on the shared admin-seeded test event; swept by testDataCleanup. */
|
|
21
37
|
isTestData?: boolean;
|
|
22
38
|
/** When isTestData, the cutoff after which testDataCleanup deletes this doc. */
|
|
@@ -134,6 +150,7 @@ export declare const EVENT_FIELDS: {
|
|
|
134
150
|
readonly WINNER_IMAGES: "winnerImages";
|
|
135
151
|
readonly ADDITIONAL_IMAGES: "additionalImages";
|
|
136
152
|
readonly TAGS: "tags";
|
|
153
|
+
readonly IS_PINNED: "isPinned";
|
|
137
154
|
readonly ALLOW_GUEST_PARTICIPATION: "allowGuestParticipation";
|
|
138
155
|
readonly CREATED_BY: "createdBy";
|
|
139
156
|
readonly CREATED_AT: "createdAt";
|