@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.
Files changed (119) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/auctions/actions.d.ts +1 -1
  3. package/dist/_internal/server/features/auctions/actions.js +12 -1
  4. package/dist/_internal/server/features/blog/actions.d.ts +1 -1
  5. package/dist/_internal/server/features/blog/actions.js +12 -1
  6. package/dist/_internal/server/features/brands/actions.d.ts +1 -1
  7. package/dist/_internal/server/features/brands/actions.js +12 -1
  8. package/dist/_internal/server/features/cart/actions.d.ts +1 -1
  9. package/dist/_internal/server/features/cart/actions.js +12 -1
  10. package/dist/_internal/server/features/checkout/actions.js +0 -1
  11. package/dist/_internal/server/features/events/actions.d.ts +1 -1
  12. package/dist/_internal/server/features/events/actions.js +12 -1
  13. package/dist/_internal/server/features/gateway-events/redact.js +14 -2
  14. package/dist/_internal/server/features/history/actions.d.ts +1 -1
  15. package/dist/_internal/server/features/history/actions.js +12 -1
  16. package/dist/_internal/server/features/lottery/actions.d.ts +1 -1
  17. package/dist/_internal/server/features/lottery/actions.js +12 -1
  18. package/dist/_internal/server/features/orders/actions.d.ts +1 -1
  19. package/dist/_internal/server/features/orders/actions.js +12 -1
  20. package/dist/_internal/server/features/orders/cancellation-refund.js +0 -1
  21. package/dist/_internal/server/features/orders/service.js +31 -1
  22. package/dist/_internal/server/features/payouts/actions.d.ts +1 -1
  23. package/dist/_internal/server/features/payouts/actions.js +12 -1
  24. package/dist/_internal/server/features/points/award.js +9 -1
  25. package/dist/_internal/server/features/pre-orders/actions.d.ts +1 -1
  26. package/dist/_internal/server/features/pre-orders/actions.js +12 -1
  27. package/dist/_internal/server/features/prize-draws/actions.d.ts +1 -1
  28. package/dist/_internal/server/features/prize-draws/actions.js +12 -1
  29. package/dist/_internal/server/features/products/actions.d.ts +1 -1
  30. package/dist/_internal/server/features/products/actions.js +12 -1
  31. package/dist/_internal/server/features/promotions/actions.d.ts +1 -1
  32. package/dist/_internal/server/features/promotions/actions.js +12 -1
  33. package/dist/_internal/server/features/protection-claims/actions.d.ts +2 -2
  34. package/dist/_internal/server/features/protection-claims/actions.js +21 -12
  35. package/dist/_internal/server/features/raffle/actions.d.ts +1 -1
  36. package/dist/_internal/server/features/raffle/actions.js +12 -1
  37. package/dist/_internal/server/features/refunds/actions.d.ts +1 -3
  38. package/dist/_internal/server/features/refunds/actions.js +14 -2
  39. package/dist/_internal/server/features/reviews/actions.d.ts +1 -1
  40. package/dist/_internal/server/features/reviews/actions.js +12 -1
  41. package/dist/_internal/server/features/wishlist/actions.d.ts +1 -1
  42. package/dist/_internal/server/features/wishlist/actions.js +12 -1
  43. package/dist/_internal/server/jobs/core/obligationSweep.js +0 -1
  44. package/dist/_internal/server/jobs/core/paymentReconcile.js +84 -57
  45. package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
  46. package/dist/_internal/server/jobs/core/testDataCleanup.js +22 -1
  47. package/dist/_internal/server/jobs/handlers/onShipmentAllocationSync.js +16 -4
  48. package/dist/_internal/shared/features/promotions/schema.d.ts +66 -0
  49. package/dist/_internal/shared/features/promotions/schema.js +7 -0
  50. package/dist/_internal/shared/obligations/deadlines.js +11 -1
  51. package/dist/_internal/shared/tester/window.d.ts +25 -0
  52. package/dist/_internal/shared/tester/window.js +66 -0
  53. package/dist/core/unit-of-work.js +15 -1
  54. package/dist/errors/firestore-codes.d.ts +37 -0
  55. package/dist/errors/firestore-codes.js +52 -0
  56. package/dist/errors/index.d.ts +0 -1
  57. package/dist/errors/index.js +12 -2
  58. package/dist/features/admin/components/AdminCouponEditorView.js +8 -0
  59. package/dist/features/admin/components/AdminGatewayEventsView.js +2 -2
  60. package/dist/features/admin/components/DataListingView.js +2 -0
  61. package/dist/features/admin/components/analytics/AdminStatCard.js +4 -1
  62. package/dist/features/admin/repository/site-settings.repository.d.ts +2 -1
  63. package/dist/features/admin/repository/site-settings.repository.js +7 -0
  64. package/dist/features/auctions/components/MarketplaceAuctionCard.js +1 -1
  65. package/dist/features/catalogue/actions/approve-catalogue-listing.d.ts +1 -1
  66. package/dist/features/catalogue/actions/approve-catalogue-listing.js +12 -1
  67. package/dist/features/catalogue/actions/list-from-catalogue.d.ts +1 -1
  68. package/dist/features/catalogue/actions/list-from-catalogue.js +12 -1
  69. package/dist/features/catalogue/actions/submit-for-approval.d.ts +1 -1
  70. package/dist/features/catalogue/actions/submit-for-approval.js +12 -1
  71. package/dist/features/categories/repository/price-observation.repository.d.ts +19 -0
  72. package/dist/features/categories/repository/price-observation.repository.js +21 -2
  73. package/dist/features/events/actions/event-actions.js +4 -1
  74. package/dist/features/events/components/AdminEventEditorView.js +72 -45
  75. package/dist/features/events/components/EventsListPageView.js +2 -1
  76. package/dist/features/events/schemas/firestore.d.ts +17 -0
  77. package/dist/features/events/schemas/firestore.js +1 -0
  78. package/dist/features/events/utils/sort-pinned.d.ts +33 -0
  79. package/dist/features/events/utils/sort-pinned.js +46 -0
  80. package/dist/features/gateway-events/repository/gateway-events.repository.js +3 -40
  81. package/dist/features/gateway-events/schemas/index.d.ts +2 -2
  82. package/dist/features/inventory/components/AdminStorageBoxesView.js +55 -39
  83. package/dist/features/inventory/components/ReceiveStationView.js +61 -17
  84. package/dist/features/inventory/repository/storage-boxes.repository.d.ts +3 -1
  85. package/dist/features/inventory/repository/storage-boxes.repository.js +36 -3
  86. package/dist/features/obligations/repository/obligations.repository.js +10 -1
  87. package/dist/features/orders/schemas/firestore.d.ts +19 -0
  88. package/dist/features/orders/schemas/firestore.js +19 -0
  89. package/dist/features/payment-cases/repository/payment-cases.repository.d.ts +1 -0
  90. package/dist/features/payment-cases/repository/payment-cases.repository.js +3 -14
  91. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +1 -1
  92. package/dist/features/products/components/MarketplaceBundleCard.js +1 -1
  93. package/dist/features/products/components/MarketplacePrizeDrawCard.js +1 -1
  94. package/dist/features/products/components/ProductForm.js +2 -2
  95. package/dist/features/products/components/ProductGrid.js +1 -1
  96. package/dist/features/promotions/repository/claimed-coupons.repository.js +31 -1
  97. package/dist/features/promotions/schemas/firestore.d.ts +22 -0
  98. package/dist/features/promotions/schemas/firestore.js +22 -0
  99. package/dist/features/promotions/schemas/index.d.ts +479 -6
  100. package/dist/features/promotions/schemas/index.js +153 -0
  101. package/dist/features/protection-claims/repository/protection-claims.repository.js +22 -15
  102. package/dist/features/seller/schemas/coupon-form.d.ts +5 -0
  103. package/dist/features/seller/schemas/coupon-form.js +18 -0
  104. package/dist/features/shell/SectionForm.js +31 -9
  105. package/dist/features/sticker-trace/server.d.ts +2 -0
  106. package/dist/http/api-handler.js +4 -1
  107. package/dist/index.d.ts +3 -3
  108. package/dist/index.js +3 -3
  109. package/dist/next/routing/route-map.d.ts +0 -2
  110. package/dist/next/routing/route-map.js +10 -1
  111. package/dist/schemas/registry.d.ts +11 -6
  112. package/dist/server.d.ts +0 -1
  113. package/dist/server.js +5 -3
  114. package/dist/styles.css +51 -0
  115. package/dist/ui/components/PaginatedSelect.js +0 -0
  116. package/package.json +1 -1
  117. package/scripts/seed-cli.mjs +1822 -1812
  118. package/dist/features/tester/utils/phases.d.ts +0 -48
  119. 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
+ }
@@ -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";
@@ -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
- // error-handler uses `next/server` — only import in server/API-route contexts
17
- export { handleApiError, logError, isAppError } from "./error-handler.js";
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("strong", { 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("strong", { 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" })] })] }) })] }));
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", { "aria-hidden": "true", children: [trendArrow, " "] }), trendText, trendLabel ? (_jsxs(Text, { as: "span", size: "xs", color: "muted", weight: "normal", children: [" ", trendLabel] })) : null] })) : (_jsx("span", {})), showSpark && (
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: Record<string, unknown>, prefix?: string): Record<string, unknown>;
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, { className: "flex h-full flex-col", children: [card, _jsx(Div, { className: "flex justify-end px-[var(--appkit-space-3)] pb-[var(--appkit-space-3)]", children: ownerActions })] }));
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 "@mohasinac/appkit/server";
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
- import { wrapAction } from "@mohasinac/appkit/server";
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,4 +1,4 @@
1
- import { type ActionResult } from "@mohasinac/appkit/server";
1
+ import { type ActionResult } from "../../../_internal/shared/types/action-result";
2
2
  /**
3
3
  * Direct-listing path — turns a catalogue item straight into a real listing,
4
4
  * no approval queue. Two callers:
@@ -1,5 +1,16 @@
1
1
  "use server";
2
- import { wrapAction } from "@mohasinac/appkit/server";
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 "@mohasinac/appkit/server";
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
- import { wrapAction } from "@mohasinac/appkit/server";
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("categoryId", "==", categoryId)
92
- .orderBy("seenAt", "desc")
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
- return { ...result, items: hidePublicTestData(result.items) };
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
- setDraft({
247
- type: event.type || "sale",
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";
@@ -38,6 +38,7 @@ export const EVENT_FIELDS = {
38
38
  WINNER_IMAGES: "winnerImages",
39
39
  ADDITIONAL_IMAGES: "additionalImages",
40
40
  TAGS: "tags",
41
+ IS_PINNED: "isPinned",
41
42
  ALLOW_GUEST_PARTICIPATION: "allowGuestParticipation",
42
43
  CREATED_BY: "createdBy",
43
44
  CREATED_AT: "createdAt",