@mohasinac/appkit 4.63.3 โ†’ 4.63.5

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 (179) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/checkout/actions.d.ts +2 -0
  3. package/dist/_internal/server/features/checkout/actions.js +22 -5
  4. package/dist/_internal/server/features/orders/adapters.js +3 -1
  5. package/dist/_internal/server/features/promotions/adapters.d.ts +25 -0
  6. package/dist/_internal/server/features/promotions/adapters.js +43 -0
  7. package/dist/_internal/server/features/promotions/data.d.ts +26 -0
  8. package/dist/_internal/server/features/promotions/data.js +45 -0
  9. package/dist/_internal/server/features/promotions/index.d.ts +2 -1
  10. package/dist/_internal/server/features/promotions/index.js +2 -1
  11. package/dist/_internal/server/features/site-settings/adapters.js +20 -2
  12. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  13. package/dist/_internal/server/features/stores/adapters.js +1 -0
  14. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +5 -2
  15. package/dist/_internal/server/jobs/core/jobRunners.js +5 -0
  16. package/dist/_internal/server/jobs/core/onOrderCreate.js +104 -10
  17. package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +2 -0
  18. package/dist/_internal/server/jobs/core/onOrderStatusChange.js +1 -0
  19. package/dist/_internal/server/jobs/core/onScamReportCreate.js +1 -0
  20. package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -0
  21. package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -0
  22. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +1 -0
  23. package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +1 -0
  24. package/dist/_internal/server/jobs/core/triggerEventRaffle.js +45 -14
  25. package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +35 -0
  26. package/dist/_internal/server/jobs/core/whatsappNotify.js +140 -0
  27. package/dist/_internal/server/notifications/channel-health.d.ts +8 -6
  28. package/dist/_internal/server/notifications/channel-health.js +2 -2
  29. package/dist/_internal/server/notifications/send-guard.d.ts +1 -1
  30. package/dist/_internal/server/notifications/send-guard.js +4 -2
  31. package/dist/_internal/shared/actions/action-registry.js +28 -0
  32. package/dist/_internal/shared/checkout/order-math.d.ts +1 -1
  33. package/dist/_internal/shared/checkout/order-math.js +1 -1
  34. package/dist/_internal/shared/features/checkout/config.d.ts +2 -0
  35. package/dist/_internal/shared/features/checkout/config.js +2 -0
  36. package/dist/_internal/shared/features/messaging/config.d.ts +5 -7
  37. package/dist/_internal/shared/features/messaging/config.js +4 -1
  38. package/dist/_internal/shared/features/site-settings/fees.d.ts +2 -0
  39. package/dist/_internal/shared/features/site-settings/fees.js +2 -0
  40. package/dist/_internal/shared/features/site-settings/types.d.ts +27 -2
  41. package/dist/_internal/shared/fees/calculator.d.ts +8 -0
  42. package/dist/_internal/shared/fees/calculator.js +7 -0
  43. package/dist/cli/index.js +2 -0
  44. package/dist/client.d.ts +2 -2
  45. package/dist/client.js +1 -1
  46. package/dist/constants/api-endpoints.d.ts +15 -0
  47. package/dist/constants/api-endpoints.js +8 -0
  48. package/dist/constants/field-names.d.ts +4 -0
  49. package/dist/constants/field-names.js +4 -0
  50. package/dist/constants/notification-types.d.ts +1 -1
  51. package/dist/constants/notification-types.js +1 -1
  52. package/dist/core/integration-keys.d.ts +5 -0
  53. package/dist/core/integration-keys.js +10 -0
  54. package/dist/features/about/components/FeesView.js +6 -0
  55. package/dist/features/account/components/BuyerShoppingGuideView.js +1 -1
  56. package/dist/features/account/components/NotificationPreferencesPanel.js +4 -3
  57. package/dist/features/account/components/UserSidebar.js +1 -1
  58. package/dist/features/account/types/index.d.ts +2 -0
  59. package/dist/features/admin/actions/notification-actions.d.ts +17 -2
  60. package/dist/features/admin/actions/notification-actions.js +154 -9
  61. package/dist/features/admin/components/AdminBlogEditorView.js +10 -2
  62. package/dist/features/admin/components/AdminOrderEditorView.d.ts +1 -1
  63. package/dist/features/admin/components/AdminOrdersView.js +2 -0
  64. package/dist/features/admin/components/AdminSiteSettingsView.js +66 -6
  65. package/dist/features/admin/components/AdminStoreEditorView.js +1 -0
  66. package/dist/features/admin/constants/site-settings-tabs.d.ts +12 -7
  67. package/dist/features/admin/constants/site-settings-tabs.js +12 -6
  68. package/dist/features/admin/repository/site-settings.repository.js +1 -1
  69. package/dist/features/admin/schemas/admin-user-form.d.ts +61 -0
  70. package/dist/features/admin/schemas/admin-user-form.js +44 -3
  71. package/dist/features/admin/schemas/firestore.d.ts +73 -0
  72. package/dist/features/admin/schemas/firestore.js +8 -3
  73. package/dist/features/auth/checkout-value-otp.d.ts +1 -1
  74. package/dist/features/auth/checkout-value-otp.js +1 -1
  75. package/dist/features/auth/permissions/constants.d.ts +2 -2
  76. package/dist/features/auth/permissions/constants.js +2 -1
  77. package/dist/features/blog/schemas/blog-form.d.ts +3 -0
  78. package/dist/features/blog/schemas/blog-form.js +14 -0
  79. package/dist/features/blog/schemas/firestore.d.ts +17 -0
  80. package/dist/features/blog/schemas/firestore.js +1 -0
  81. package/dist/features/blog/schemas/index.d.ts +6 -0
  82. package/dist/features/blog/schemas/index.js +3 -0
  83. package/dist/features/blog/types/index.d.ts +2 -0
  84. package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
  85. package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
  86. package/dist/features/cart/components/StoreAddonsPicker.d.ts +5 -2
  87. package/dist/features/cart/components/StoreAddonsPicker.js +5 -4
  88. package/dist/features/cart/repository/cart.repository.js +2 -1
  89. package/dist/features/cart/schemas/firestore.d.ts +2 -1
  90. package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +5 -10
  91. package/dist/features/checkout/actions/checkout-value-otp-actions.js +88 -2
  92. package/dist/features/events/components/AdminEventEditorView.js +64 -9
  93. package/dist/features/events/components/EventOfferCard.js +3 -18
  94. package/dist/features/events/components/EventRaffleWinnerView.d.ts +6 -0
  95. package/dist/features/events/components/EventRaffleWinnerView.js +11 -1
  96. package/dist/features/events/schemas/event-form.d.ts +10 -0
  97. package/dist/features/events/schemas/event-form.js +19 -0
  98. package/dist/features/events/schemas/firestore.d.ts +68 -0
  99. package/dist/features/events/schemas/firestore.js +25 -0
  100. package/dist/features/events/schemas/index.d.ts +7 -0
  101. package/dist/features/events/schemas/index.js +5 -0
  102. package/dist/features/events/types/index.d.ts +4 -0
  103. package/dist/features/homepage/lib/section-renderer.js +1 -1
  104. package/dist/features/orders/actions/refund-actions.js +1 -0
  105. package/dist/features/orders/components/OrderAddonBadges.d.ts +3 -1
  106. package/dist/features/orders/components/OrderAddonBadges.js +4 -3
  107. package/dist/features/orders/repository/orders.repository.d.ts +8 -0
  108. package/dist/features/orders/repository/orders.repository.js +2 -0
  109. package/dist/features/orders/schemas/firestore.d.ts +4 -0
  110. package/dist/features/orders/types/index.d.ts +5 -2
  111. package/dist/features/promotions/components/AttachedCouponsSection.d.ts +30 -0
  112. package/dist/features/promotions/components/AttachedCouponsSection.js +99 -0
  113. package/dist/features/promotions/components/CouponCodeCopyRow.d.ts +19 -0
  114. package/dist/features/promotions/components/CouponCodeCopyRow.js +39 -0
  115. package/dist/features/promotions/components/index.d.ts +4 -0
  116. package/dist/features/promotions/components/index.js +2 -0
  117. package/dist/features/promotions/constants/attached-coupons.d.ts +55 -0
  118. package/dist/features/promotions/constants/attached-coupons.js +24 -0
  119. package/dist/features/promotions/repository/coupons.repository.d.ts +20 -0
  120. package/dist/features/promotions/repository/coupons.repository.js +39 -0
  121. package/dist/features/promotions/schemas/attached-coupons.d.ts +39 -0
  122. package/dist/features/promotions/schemas/attached-coupons.js +66 -0
  123. package/dist/features/search/action-index/settings-entries.js +9 -0
  124. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +6 -1
  125. package/dist/features/store-extensions/repository/store-extensions.repositories.js +10 -1
  126. package/dist/features/store-extensions/schemas/firestore.d.ts +17 -1
  127. package/dist/features/store-extensions/schemas/firestore.js +10 -0
  128. package/dist/features/stores/components/StoreCapabilitiesGuideView.js +1 -0
  129. package/dist/features/stores/components/StoreGuideHubView.js +1 -1
  130. package/dist/features/stores/components/StoreSettingsGuideView.js +6 -2
  131. package/dist/features/stores/repository/store.repository.d.ts +23 -1
  132. package/dist/features/stores/repository/store.repository.js +29 -1
  133. package/dist/features/stores/schemas/firestore.d.ts +16 -6
  134. package/dist/features/stores/schemas/firestore.js +3 -6
  135. package/dist/features/stores/schemas/index.d.ts +86 -0
  136. package/dist/features/stores/schemas/index.js +14 -0
  137. package/dist/features/whatsapp-bot/api/send-status/route.d.ts +12 -0
  138. package/dist/features/whatsapp-bot/api/send-status/route.js +38 -0
  139. package/dist/features/whatsapp-bot/api/webhook/route.d.ts +12 -0
  140. package/dist/features/whatsapp-bot/api/webhook/route.js +43 -0
  141. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +57 -0
  142. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +76 -0
  143. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +9 -0
  144. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +7 -0
  145. package/dist/features/whatsapp-bot/components/index.d.ts +3 -0
  146. package/dist/features/whatsapp-bot/components/index.js +2 -0
  147. package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +52 -0
  148. package/dist/features/whatsapp-bot/helpers/whatsapp.js +288 -0
  149. package/dist/features/whatsapp-bot/index.d.ts +3 -0
  150. package/dist/features/whatsapp-bot/index.js +3 -0
  151. package/dist/features/whatsapp-bot/manifest.d.ts +2 -0
  152. package/dist/features/whatsapp-bot/manifest.js +13 -0
  153. package/dist/features/whatsapp-bot/messages/en.json +7 -0
  154. package/dist/features/whatsapp-bot/server.d.ts +7 -0
  155. package/dist/features/whatsapp-bot/server.js +7 -0
  156. package/dist/features/whatsapp-bot/types/index.d.ts +126 -0
  157. package/dist/features/whatsapp-bot/types/index.js +1 -0
  158. package/dist/index.d.ts +23 -6
  159. package/dist/index.js +38 -12
  160. package/dist/next/routing/route-map.d.ts +3 -0
  161. package/dist/next/routing/route-map.js +1 -0
  162. package/dist/providers/db-firebase/base.repository.d.ts +1 -1
  163. package/dist/providers/db-firebase/base.repository.js +1 -1
  164. package/dist/repositories/index.d.ts +1 -1
  165. package/dist/repositories/index.js +1 -1
  166. package/dist/schemas/registry.d.ts +121 -0
  167. package/dist/schemas/registry.js +2 -1
  168. package/dist/security/pii-encrypt.js +1 -1
  169. package/dist/server-entry.d.ts +1 -0
  170. package/dist/server-entry.js +2 -0
  171. package/dist/server.d.ts +11 -2
  172. package/dist/server.js +26 -2
  173. package/dist/styles.css +1 -1
  174. package/dist/tailwind-utilities.css +1 -1
  175. package/dist/ui/components/NavigationLoader.d.ts +14 -1
  176. package/dist/ui/components/NavigationLoader.js +79 -30
  177. package/package.json +1 -1
  178. package/dist/features/orders/utils/order-announcement-message.d.ts +0 -18
  179. package/dist/features/orders/utils/order-announcement-message.js +0 -16
@@ -9,12 +9,17 @@
9
9
  import { timingSafeEqual } from "crypto";
10
10
  import { ValidationError } from "../../../errors/index.js";
11
11
  import { normalizeError } from "../../../errors/normalize.js";
12
+ import { safeRead } from "../../../errors/safe-read.js";
12
13
  import { serverLogger } from "../../../monitoring/index.js";
13
14
  import { sendEmail } from "../../contact/email.js";
14
15
  import { getAdminDb } from "../../../providers/db-firebase/index.js";
15
16
  import { resolveDate } from "../../../utils/index.js";
17
+ import { addressesRepository } from "../../addresses/repository/addresses.repository.js";
18
+ import { siteSettingsRepository } from "../../admin/repository/site-settings.repository.js";
19
+ import { sendWhatsAppBusinessMessage } from "../../whatsapp-bot/helpers/whatsapp.js";
16
20
  import { isChannelHealthy, withChannelRetry } from "../../../_internal/server/notifications/channel-health.js";
17
- import { CHECKOUT_VALUE_OTP_EXPIRY_MS, CHECKOUT_VALUE_OTP_MAX_ATTEMPTS, hashOtp, generateOtpCode, checkoutValueOtpRef, enforceCheckoutValueOtpRateLimit, } from "../../auth/checkout-value-otp.js";
21
+ import { guardSend, logSuppressedSend } from "../../../_internal/server/notifications/send-guard.js";
22
+ import { CHECKOUT_VALUE_OTP_EXPIRY_MS, CHECKOUT_VALUE_OTP_MAX_ATTEMPTS, hashOtp, generateOtpCode, checkoutValueOtpRef, enforceCheckoutValueOtpRateLimit, buildCheckoutValueOtpMessage, } from "../../auth/checkout-value-otp.js";
18
23
  function maskEmail(userEmail) {
19
24
  const [local, domain] = userEmail.split("@");
20
25
  return domain
@@ -22,7 +27,10 @@ function maskEmail(userEmail) {
22
27
  : "***";
23
28
  }
24
29
  /**
25
- * Sends a checkout-value OTP by email.
30
+ * Sends a checkout-value OTP either by email (default) or WhatsApp
31
+ * (opt-in โ€” the buyer must explicitly choose it in the UI). WhatsApp phone
32
+ * is always resolved server-side from the selected shipping address โ€”
33
+ * never trust a client-supplied phone number.
26
34
  *
27
35
  * If the chosen channel (and, for email, its skip-worthy fallback) is
28
36
  * currently circuit-broken or unconfigured, the OTP gate is treated as
@@ -37,6 +45,84 @@ export async function sendCheckoutValueOtp(userId, userEmail, channel = "email",
37
45
  const codeHash = hashOtp(code);
38
46
  const expiresAt = new Date(Date.now() + CHECKOUT_VALUE_OTP_EXPIRY_MS);
39
47
  const siteName = process.env.NEXT_PUBLIC_SITE_NAME || "LetItRip";
48
+ if (channel === "whatsapp") {
49
+ if (!addressId) {
50
+ throw new ValidationError("Select a delivery address before sending the code via WhatsApp.");
51
+ }
52
+ // A failed settings read makes the WhatsApp channel look deliberately
53
+ // switched off ("send the code by email instead") โ€” a defensible fallback,
54
+ // but one an operator has to be able to see, since it is indistinguishable
55
+ // from an admin having disabled it.
56
+ const settings = await safeRead(() => siteSettingsRepository.getSingleton(), {
57
+ route: "checkout/value-otp",
58
+ key: "checkoutValueOtp.siteSettings",
59
+ fallback: null,
60
+ });
61
+ const creds = settings?.credentials ?? {};
62
+ const whatsappOtpEnabled = settings?.notificationChannels?.whatsapp?.otpEnabled === true;
63
+ const phoneNumberId = creds.whatsappPhoneNumberId?.trim() ?? "";
64
+ const accessToken = creds.whatsappCloudApiToken?.trim() ?? "";
65
+ const credsReady = phoneNumberId && accessToken && !phoneNumberId.includes("PLACEHOLDER") && !accessToken.includes("PLACEHOLDER");
66
+ if (!whatsappOtpEnabled || !credsReady) {
67
+ throw new ValidationError("WhatsApp verification isn't available right now โ€” send the code by email instead.");
68
+ }
69
+ const address = await addressesRepository.findById(addressId);
70
+ /*
71
+ * REVEALED: this phone receives the high-value checkout OTP, so it has to
72
+ * be the real number. `addressesRepository` is ciphertext-by-default, and
73
+ * `.trim()` on the stored value is what the nominal brand caught โ€” the
74
+ * old intersection form let `Encrypted<string>` call string methods.
75
+ */
76
+ const addressPlain = address ? addressesRepository.revealAddress(address) : null;
77
+ const phone = addressPlain?.phone?.trim();
78
+ if (!phone) {
79
+ throw new ValidationError("The selected address has no phone number on file โ€” send the code by email instead.");
80
+ }
81
+ if (!(await isChannelHealthy("whatsapp"))) {
82
+ serverLogger.warn("sendCheckoutValueOtp: WhatsApp channel unhealthy โ€” skipping OTP gate", { uid: userId, channel });
83
+ return { skipped: true };
84
+ }
85
+ await checkoutValueOtpRef(db, userId).set({
86
+ codeHash,
87
+ expiresAt,
88
+ attempts: 0,
89
+ verified: false,
90
+ createdAt: new Date(),
91
+ });
92
+ const message = buildCheckoutValueOtpMessage(code, siteName);
93
+ // Same guard as the email branch below, same reasoning: transactional, so
94
+ // the kill switch does not apply (the buyer cannot check out without the
95
+ // code), but the daily ceiling does.
96
+ const waGuard = {
97
+ channel: "whatsapp",
98
+ feature: "checkout_value_otp",
99
+ audience: "user",
100
+ transactional: true,
101
+ };
102
+ const waDecision = await guardSend(waGuard);
103
+ if (!waDecision.allow) {
104
+ logSuppressedSend(waGuard, waDecision);
105
+ throw new ValidationError("We couldn't send the verification code via WhatsApp โ€” please try again, or send it by email instead.");
106
+ }
107
+ try {
108
+ const sent = await withChannelRetry("whatsapp", async () => {
109
+ const ok = await sendWhatsAppBusinessMessage({ toPhone: phone, message, phoneNumberId, accessToken });
110
+ if (!ok)
111
+ throw new Error("WhatsApp Cloud API returned a non-OK response");
112
+ return ok;
113
+ });
114
+ if (!sent)
115
+ throw new Error("WhatsApp send failed");
116
+ }
117
+ catch (err) {
118
+ void normalizeError(err);
119
+ serverLogger.error("Failed to send checkout value OTP via WhatsApp", { uid: userId, err });
120
+ throw new ValidationError("We couldn't send the verification code via WhatsApp โ€” please try again, or send it by email instead.");
121
+ }
122
+ const maskedPhone = phone.length > 4 ? `${"*".repeat(phone.length - 4)}${phone.slice(-4)}` : "***";
123
+ serverLogger.info(`Checkout value OTP sent via WhatsApp: uid=${userId}`);
124
+ return { maskedPhone };
125
+ }
40
126
  if (!(await isChannelHealthy("email"))) {
41
127
  serverLogger.warn("sendCheckoutValueOtp: email channel unhealthy โ€” skipping OTP gate", { uid: userId, channel });
42
128
  return { skipped: true };
@@ -5,13 +5,15 @@ import { useApiMutation } from "@mohasinac/appkit/client";
5
5
  import React, { useEffect } from "react";
6
6
  import { useTranslations } from "next-intl";
7
7
  import { useQuery } from "@tanstack/react-query";
8
- import { Alert, Button, Div, Grid, Heading, Input, Label, RichTextEditor, Row, Select, Stack, StackedViewShell, TagInput, Text, Textarea, Toggle, } from "../../../ui/index.js";
8
+ import { Alert, Button, Div, Grid, Heading, Input, Label, RichTextEditor, Row, Select, Span, Stack, StackedViewShell, TagInput, Text, Textarea, Toggle, } from "../../../ui/index.js";
9
9
  import { FormShellContext, useFormShellState, applyZodIssues, FormErrorSummary } from "../../../ui/forms/index.js";
10
10
  import { apiClient } from "../../../http/index.js";
11
11
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints.js";
12
12
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry.js";
13
13
  import { toUserMessage } from "../../../errors/error-display-map.js";
14
14
  import { ALL_EVENT_TYPES, EVENT_TYPE_LABELS } from "../types/index.js";
15
+ import { rafflePrizeCouponIdsOf } from "../schemas/firestore.js";
16
+ import { ATTACHED_COUPON_MAX } from "../../promotions/constants/attached-coupons.js";
15
17
  import { SectionForm, useSectionFormNav } from "../../shell/index.js";
16
18
  import { eventDraftSchema } from "../schemas/event-form.js";
17
19
  import { ProductInlineSelect } from "../../seller/components/ProductInlineSelect.js";
@@ -136,6 +138,7 @@ const DEFAULT_DRAFT = {
136
138
  createdBy: "",
137
139
  allowGuestParticipation: false,
138
140
  isPinned: false,
141
+ couponIds: [],
139
142
  discountPercent: "10",
140
143
  saleBannerText: "",
141
144
  couponId: "",
@@ -158,7 +161,7 @@ const DEFAULT_DRAFT = {
158
161
  raffleType: "open_raffle",
159
162
  raffleTopN: "10",
160
163
  rafflePrize: "",
161
- rafflePrizeCouponId: "",
164
+ rafflePrizeCouponIds: [],
162
165
  rafflePrizeProductIds: [],
163
166
  spinPrizes: [],
164
167
  spinMaxPerUser: "1",
@@ -190,8 +193,27 @@ function buildEventRaffleFields(draft) {
190
193
  fields.raffleTopN = Number(draft.raffleTopN);
191
194
  if (draft.rafflePrize)
192
195
  fields.rafflePrize = draft.rafflePrize;
193
- if (draft.rafflePrizeCouponId)
194
- fields.rafflePrizeCouponId = draft.rafflePrizeCouponId;
196
+ /*
197
+ * ๐Ÿ›‘ Writes the ARRAY and never the deprecated `rafflePrizeCouponId`. The
198
+ * editor LOADS the legacy scalar (see the hydration below, which folds it in
199
+ * through `rafflePrizeCouponIdsOf`) so an old event's prize is visible and
200
+ * editable โ€” but the moment an admin saves, the value lives in the array
201
+ * alone. Writing both would leave two fields to keep in step, which is the
202
+ * mirror-drift trap in Root Cause #42.
203
+ *
204
+ * The legacy scalar is deliberately NOT cleared here: a merge-write cannot
205
+ * remove a field anyway (ยง Seed API Reference), and `rafflePrizeCouponIdsOf`
206
+ * dedupes, so a stale scalar duplicating an array entry awards nothing twice.
207
+ */
208
+ /*
209
+ * Unconditional โ€” `[]` included. Every neighbouring field here is written
210
+ * only when non-empty, which means none of them can be CLEARED through this
211
+ * form: the route merges, the stored value survives, and the admin's
212
+ * deletion silently reverts on reload. Not fixing the others in this change
213
+ * (each needs its own think about what "cleared" means for an event
214
+ * mid-flight), but a brand-new field must not be born with the bug.
215
+ */
216
+ fields.rafflePrizeCouponIds = draft.rafflePrizeCouponIds;
195
217
  if (draft.rafflePrizeProductIds.length > 0)
196
218
  fields.rafflePrizeProductIds = draft.rafflePrizeProductIds;
197
219
  if (draft.raffleType === "spin_wheel" || draft.type === "spin_wheel") {
@@ -205,6 +227,22 @@ function buildEventRaffleFields(draft) {
205
227
  }
206
228
  return fields;
207
229
  }
230
+ /**
231
+ * Promotional coupons โ€” shown to everyone who opens the public event page.
232
+ *
233
+ * ๐Ÿ›‘ NOT gated on event type, unlike the sale/offer/poll blocks beside it in
234
+ * the Settings step. Any event can advertise a code: a tournament can hand out
235
+ * a shipping coupon, a sale event can list three more. Gating it would make
236
+ * the field invisible on most of the catalogue for no reason.
237
+ *
238
+ * Module-level for the same reason `SpinPrizesEditor` below is โ€” the Settings
239
+ * step's render closure is counted by `audit-code-quality`'s LARGE_COMPONENT
240
+ * rule and this block put it 3 lines over. Purely a line-count split; no
241
+ * behaviour differs from when it was inline.
242
+ */
243
+ function PromotionalCouponsField({ values, onChange, }) {
244
+ return (_jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Promotional coupons" }), _jsx(CouponInlineSelect, { scope: "admin", multiple: true, value: values.couponIds, onChange: (ids) => onChange({ couponIds: ids }), placeholder: "Search coupons to feature on this event\u2026", allowCreate: false }), _jsxs(Text, { size: "xs", color: "muted", children: ["Shown on the public event page for readers to copy and use at checkout. Up to ", ATTACHED_COUPON_MAX, ". Expired and deactivated coupons are hidden automatically, so an old event never advertises a dead code. These are ", _jsx(Span, { className: "italic", children: "not" }), " prizes \u2014 raffle prizes are set in the Raffle step."] })] }));
245
+ }
208
246
  // Extracted out of the raffle/spin step's render() so that closure stays
209
247
  // under the LARGE_COMPONENT line-count threshold โ€” purely a line-count
210
248
  // split, no behavior change from when this was inline.
@@ -246,6 +284,7 @@ function eventToDraft(event) {
246
284
  // the toggle as off rather than leaving it `undefined`, which React
247
285
  // would read as an uncontrolled input.
248
286
  isPinned: Boolean(event.isPinned),
287
+ couponIds: Array.isArray(event.couponIds) ? event.couponIds : [],
249
288
  discountPercent: String(event.saleConfig?.discountPercent ?? 10),
250
289
  saleBannerText: event.saleConfig?.bannerText || "",
251
290
  couponId: event.offerConfig?.couponId || "",
@@ -270,7 +309,14 @@ function eventToDraft(event) {
270
309
  raffleType: event.raffleType || "open_raffle",
271
310
  raffleTopN: String(event.raffleTopN ?? 10),
272
311
  rafflePrize: event.rafflePrize || "",
273
- rafflePrizeCouponId: event.rafflePrizeCouponId || "",
312
+ /*
313
+ * ๐Ÿ›‘ Through `rafflePrizeCouponIdsOf`, never `event.rafflePrizeCouponIds`
314
+ * directly. An event created before the widening carries ONLY the
315
+ * deprecated scalar, and reading just the array would show an empty
316
+ * picker โ€” so the first save of an unrelated field would silently drop
317
+ * a prize the admin never knew was set.
318
+ */
319
+ rafflePrizeCouponIds: rafflePrizeCouponIdsOf(event),
274
320
  rafflePrizeProductIds: Array.isArray(event.rafflePrizeProductIds) ? event.rafflePrizeProductIds : [],
275
321
  spinPrizes: Array.isArray(event.spinPrizes) ? event.spinPrizes : [],
276
322
  spinMaxPerUser: String(event.spinMaxPerUser ?? 1),
@@ -331,6 +377,15 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
331
377
  coverImageUrl: draft.coverImageUrl || undefined,
332
378
  allowGuestParticipation: draft.allowGuestParticipation,
333
379
  isPinned: draft.isPinned,
380
+ /*
381
+ * Sent UNCONDITIONALLY, including as `[]`. An editor that omits the
382
+ * key when the list is empty can never REMOVE the last coupon: the
383
+ * route merges, so the stored array survives and the admin watches
384
+ * their deletion silently undo itself on reload. Root Cause #76's
385
+ * shape, from the other side โ€” there the form could express state it
386
+ * did not own; here it must be able to express an empty one it does.
387
+ */
388
+ couponIds: draft.couponIds,
334
389
  ...buildEventTypeConfig(draft),
335
390
  ...buildEventRaffleFields(draft),
336
391
  };
@@ -424,23 +479,23 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
424
479
  {
425
480
  id: "settings",
426
481
  label: "Settings",
427
- fields: ["status", "allowGuestParticipation", "isPinned", "discountPercent", "saleBannerText", "couponId", "displayCode", "offerBannerText", "pollOptions", "resultsVisibility", "allowMultiSelect", "allowComment", "maxEntriesPerUser", "hasLeaderboard", "hasPointSystem", "pointsLabel", "entryReviewRequired", "surveyFields", "anonymous", "feedbackFields"],
482
+ fields: ["status", "allowGuestParticipation", "isPinned", "couponIds", "discountPercent", "saleBannerText", "couponId", "displayCode", "offerBannerText", "pollOptions", "resultsVisibility", "allowMultiSelect", "allowComment", "maxEntriesPerUser", "hasLeaderboard", "hasPointSystem", "pointsLabel", "entryReviewRequired", "surveyFields", "anonymous", "feedbackFields"],
428
483
  // Same: the offer-coupon, offer-display-code and poll-minimum-options
429
484
  // rules are all stated in eventDraftSchema's superRefine, which now
430
485
  // surfaces them inline on the field instead of as a step-blocking banner.
431
- render: ({ values, onChange, errors }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Event Settings" }), isEdit && (_jsx(Select, { label: "Status", error: errors.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 %", error: errors.discountPercent, type: "number", value: values.discountPercent, onChange: (e) => onChange({ discountPercent: e.target.value }), placeholder: "10" }), _jsx(Input, { label: "Banner text (optional)", value: values.saleBannerText, error: errors.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", error: errors.displayCode, value: values.displayCode, onChange: (e) => onChange({ displayCode: e.target.value }), placeholder: "CHARIZARD25", required: true })] }), _jsx(Input, { label: "Banner text (optional)", value: values.offerBannerText, error: errors.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,
486
+ render: ({ values, onChange, errors }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Event Settings" }), isEdit && (_jsx(Select, { label: "Status", error: errors.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." })] }), _jsx(PromotionalCouponsField, { values: values, onChange: onChange }), 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 %", error: errors.discountPercent, type: "number", value: values.discountPercent, onChange: (e) => onChange({ discountPercent: e.target.value }), placeholder: "10" }), _jsx(Input, { label: "Banner text (optional)", value: values.saleBannerText, error: errors.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", error: errors.displayCode, value: values.displayCode, onChange: (e) => onChange({ displayCode: e.target.value }), placeholder: "CHARIZARD25", required: true })] }), _jsx(Input, { label: "Banner text (optional)", value: values.offerBannerText, error: errors.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,
432
487
  /* Indexed: a failure on option 2 arrives as `pollOptions.1.label`. */
433
488
  error: errors[`pollOptions.${idx}.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", error: errors.resultsVisibility, 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", error: errors.maxEntriesPerUser, type: "number", value: values.maxEntriesPerUser, onChange: (e) => onChange({ maxEntriesPerUser: e.target.value }), placeholder: "1" }), values.hasPointSystem && (_jsx(Input, { label: "Points label", error: errors.pointsLabel, 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 }) })] }))] })),
434
489
  },
435
490
  {
436
491
  id: "raffle",
437
492
  label: "Raffle / Spin",
438
- fields: ["hasRaffle", "raffleType", "raffleTopN", "rafflePrize", "rafflePrizeCouponId", "rafflePrizeProductIds", "spinPrizes", "spinMaxPerUser", "spinWindowStart", "spinWindowEnd"],
493
+ fields: ["hasRaffle", "raffleType", "raffleTopN", "rafflePrize", "rafflePrizeCouponIds", "rafflePrizeProductIds", "spinPrizes", "spinMaxPerUser", "spinWindowStart", "spinWindowEnd"],
439
494
  render: ({ values, onChange, errors }) => {
440
495
  const isRaffleType = values.type === "raffle" || values.type === "spin_wheel";
441
496
  const showRaffleConfig = isRaffleType || values.hasRaffle;
442
497
  const isSpinMode = values.type === "spin_wheel" || values.raffleType === "spin_wheel";
443
- return (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Raffle & Spin Wheel" }), !isRaffleType && (_jsx(Toggle, { checked: values.hasRaffle, onChange: (v) => onChange({ hasRaffle: v }), label: "Attach a raffle to this event" })), !isRaffleType && !values.hasRaffle && (_jsx(Text, { size: "sm", color: "muted", children: "Enable the toggle above to attach a raffle draw to this event. Not applicable for this event type by default." })), showRaffleConfig && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Select, { label: "Raffle type", error: errors.raffleType, value: values.type === "spin_wheel" ? "spin_wheel" : values.raffleType, options: RAFFLE_TYPE_OPTIONS, onChange: (e) => onChange({ raffleType: e.target.value }), disabled: values.type === "spin_wheel" }), _jsx(Input, { label: "Top N (for top-N raffle types)", error: errors.raffleTopN, type: "number", value: values.raffleTopN, onChange: (e) => onChange({ raffleTopN: e.target.value }), placeholder: "10" })] }), _jsx(Input, { label: "Prize description", error: errors.rafflePrize, value: values.rafflePrize, onChange: (e) => onChange({ rafflePrize: e.target.value }), placeholder: "\u20B92,000 store credit + exclusive Pikachu sticker" }), _jsxs(Div, { children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Coupon for winner (optional)" }), _jsx(CouponInlineSelect, { scope: "admin", value: values.rafflePrizeCouponId, onChange: (id) => onChange({ rafflePrizeCouponId: id ?? "" }), placeholder: "Search coupons\u2026", allowCreate: false })] }), _jsxs(Div, { children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Prize products (optional)" }), _jsx(ProductInlineSelect, { scope: "admin", multiple: true, value: values.rafflePrizeProductIds, onChange: (ids) => onChange({ rafflePrizeProductIds: ids }), placeholder: "Search products to feature as prizes\u2026" }), _jsx(Text, { size: "xs", color: "muted", className: "mt-1", children: "Products shown to winners or displayed as the prize showcase." })] }), isSpinMode && _jsx(SpinPrizesEditor, { values: values, onChange: onChange }), isEdit && (_jsxs(Stack, { gap: "xs", rounded: "lg", padding: "sm", className: CLS_RAFFLE_PANEL, children: [_jsxs(Row, { justify: "between", gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", className: CLS_RAFFLE_HEADING, children: raffleWinnerName ? `Winner: ${raffleWinnerName}` : "Raffle not yet triggered" }), _jsx(Button, { type: "button", action: ACTIONS.EVENT["draw-raffle-winner"], size: "sm", onClick: () => triggerRaffleMutation.mutate(), disabled: triggerRaffleMutation.isPending || Boolean(raffleWinnerName), children: triggerRaffleMutation.isPending ? "Triggeringโ€ฆ" : raffleWinnerName ? "Already triggered" : "Trigger Raffle Now" })] }), raffleEntryCount !== null && _jsxs(Text, { size: "xs", className: CLS_RAFFLE_BODY, children: ["Pool size: ", raffleEntryCount] }), raffleGithubUrl && _jsxs(Text, { size: "xs", className: `break-all ${CLS_RAFFLE_BODY}`, children: ["Fairness proof: ", raffleGithubUrl] }), triggerMessage && _jsx(Text, { size: "xs", className: CLS_RAFFLE_HEADING, children: triggerMessage })] }))] }))] }));
498
+ return (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Raffle & Spin Wheel" }), !isRaffleType && (_jsx(Toggle, { checked: values.hasRaffle, onChange: (v) => onChange({ hasRaffle: v }), label: "Attach a raffle to this event" })), !isRaffleType && !values.hasRaffle && (_jsx(Text, { size: "sm", color: "muted", children: "Enable the toggle above to attach a raffle draw to this event. Not applicable for this event type by default." })), showRaffleConfig && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Select, { label: "Raffle type", error: errors.raffleType, value: values.type === "spin_wheel" ? "spin_wheel" : values.raffleType, options: RAFFLE_TYPE_OPTIONS, onChange: (e) => onChange({ raffleType: e.target.value }), disabled: values.type === "spin_wheel" }), _jsx(Input, { label: "Top N (for top-N raffle types)", error: errors.raffleTopN, type: "number", value: values.raffleTopN, onChange: (e) => onChange({ raffleTopN: e.target.value }), placeholder: "10" })] }), _jsx(Input, { label: "Prize description", error: errors.rafflePrize, value: values.rafflePrize, onChange: (e) => onChange({ rafflePrize: e.target.value }), placeholder: "\u20B92,000 store credit + exclusive Pikachu sticker" }), _jsxs(Div, { children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Coupons for winner (optional)" }), _jsx(CouponInlineSelect, { scope: "admin", multiple: true, value: values.rafflePrizeCouponIds, onChange: (ids) => onChange({ rafflePrizeCouponIds: ids }), placeholder: "Search coupons\u2026", allowCreate: false }), _jsxs(Text, { size: "xs", color: "muted", className: "mt-1", children: ["Every coupon listed here is awarded to the winner when the draw runs. Up to ", ATTACHED_COUPON_MAX, "."] })] }), _jsxs(Div, { children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Prize products (optional)" }), _jsx(ProductInlineSelect, { scope: "admin", multiple: true, value: values.rafflePrizeProductIds, onChange: (ids) => onChange({ rafflePrizeProductIds: ids }), placeholder: "Search products to feature as prizes\u2026" }), _jsx(Text, { size: "xs", color: "muted", className: "mt-1", children: "Products shown to winners or displayed as the prize showcase." })] }), isSpinMode && _jsx(SpinPrizesEditor, { values: values, onChange: onChange }), isEdit && (_jsxs(Stack, { gap: "xs", rounded: "lg", padding: "sm", className: CLS_RAFFLE_PANEL, children: [_jsxs(Row, { justify: "between", gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", className: CLS_RAFFLE_HEADING, children: raffleWinnerName ? `Winner: ${raffleWinnerName}` : "Raffle not yet triggered" }), _jsx(Button, { type: "button", action: ACTIONS.EVENT["draw-raffle-winner"], size: "sm", onClick: () => triggerRaffleMutation.mutate(), disabled: triggerRaffleMutation.isPending || Boolean(raffleWinnerName), children: triggerRaffleMutation.isPending ? "Triggeringโ€ฆ" : raffleWinnerName ? "Already triggered" : "Trigger Raffle Now" })] }), raffleEntryCount !== null && _jsxs(Text, { size: "xs", className: CLS_RAFFLE_BODY, children: ["Pool size: ", raffleEntryCount] }), raffleGithubUrl && _jsxs(Text, { size: "xs", className: `break-all ${CLS_RAFFLE_BODY}`, children: ["Fairness proof: ", raffleGithubUrl] }), triggerMessage && _jsx(Text, { size: "xs", className: CLS_RAFFLE_HEADING, children: triggerMessage })] }))] }))] }));
444
499
  },
445
500
  },
446
501
  ];
@@ -1,11 +1,10 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { normalizeError } from "../../../errors/normalize.js";
4
- import { useState } from "react";
5
3
  import Link from "next/link.js";
6
- import { Code, Div, Heading, Row, Stack, Text } from "../../../ui/index.js";
4
+ import { Div, Heading, Stack, Text } from "../../../ui/index.js";
7
5
  import { Button } from "../../../ui/components/Button.js";
8
6
  import { ROUTES } from "../../../next/routing/route-map.js";
7
+ import { CouponCodeCopyRow } from "../../promotions/components/CouponCodeCopyRow.js";
9
8
  const __P = {
10
9
  p6: "p-[var(--appkit-space-6)]",
11
10
  };
@@ -15,22 +14,8 @@ const __P = {
15
14
  * products page.
16
15
  */
17
16
  export function EventOfferCard({ couponCode, offerDescription, title = "Exclusive offer", filterParam, }) {
18
- const [copied, setCopied] = useState(false);
19
17
  const href = filterParam
20
18
  ? `${ROUTES.PUBLIC.PRODUCTS}?${encodeURIComponent(filterParam.key)}=${encodeURIComponent(filterParam.value)}`
21
19
  : String(ROUTES.PUBLIC.PRODUCTS);
22
- const copyCode = async () => {
23
- try {
24
- await navigator.clipboard.writeText(couponCode);
25
- setCopied(true);
26
- setTimeout(() => setCopied(false), 2000);
27
- // Clipboard access is denied outright on insecure origins and by user
28
- // permission. The button simply never flips to "Copied", and the code is
29
- // still visible on screen to select by hand.
30
- }
31
- catch (_err) {
32
- void normalizeError(_err);
33
- }
34
- };
35
- return (_jsx(Div, { className: `${__P.p6}`, rounded: "2xl", border: "default", children: _jsxs(Stack, { gap: "md", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 2, weight: "semibold", size: "2xl", children: title }), offerDescription ? (_jsx(Text, { size: "sm", color: "muted", children: offerDescription })) : null] }), _jsxs(Row, { surface: "muted", padding: "inline", align: "center", gap: "3", rounded: "lg", children: [_jsx(Code, { className: "flex-1", weight: "bold", size: "base", color: "primary", children: couponCode }), _jsx(Button, { type: "button", variant: "outline", onClick: copyCode, children: copied ? "Copied!" : "Copy code" })] }), _jsx(Div, { children: _jsx(Link, { href: href, children: _jsx(Button, { variant: "primary", children: "Apply at checkout" }) }) })] }) }));
20
+ return (_jsx(Div, { className: `${__P.p6}`, rounded: "2xl", border: "default", children: _jsxs(Stack, { gap: "md", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 2, weight: "semibold", size: "2xl", children: title }), offerDescription ? (_jsx(Text, { size: "sm", color: "muted", children: offerDescription })) : null] }), _jsx(CouponCodeCopyRow, { code: couponCode, ariaContext: title }), _jsx(Div, { children: _jsx(Link, { href: href, children: _jsx(Button, { variant: "primary", children: "Apply at checkout" }) }) })] }) }));
36
21
  }
@@ -11,6 +11,12 @@ export interface RaffleWinnerEvent {
11
11
  * `currentUserIsWinner` so the CTA only renders for the actual winner.
12
12
  */
13
13
  rafflePrizeCouponCode?: string;
14
+ /**
15
+ * Every prize coupon's code, for a raffle awarding more than one. Supersedes
16
+ * the singular field above, which stays declared because raffles drawn
17
+ * before this existed recorded only that.
18
+ */
19
+ rafflePrizeCouponCodes?: string[];
14
20
  }
15
21
  export interface EventRaffleWinnerViewProps {
16
22
  event: RaffleWinnerEvent;
@@ -17,8 +17,18 @@ const DEFAULT_LABELS = {
17
17
  };
18
18
  export function EventRaffleWinnerView({ event, currentUserIsWinner, labels }) {
19
19
  const l = { ...DEFAULT_LABELS, ...(labels ?? {}) };
20
+ /*
21
+ * Array first, singular as the fallback โ€” and deduped, because the page
22
+ * feeds `rafflePrizeCouponCode` from the first entry of the same list, so a
23
+ * naive concat would render the first prize twice.
24
+ */
25
+ const winnerCouponCodes = Array.from(new Set(event.rafflePrizeCouponCodes?.length
26
+ ? event.rafflePrizeCouponCodes
27
+ : event.rafflePrizeCouponCode
28
+ ? [event.rafflePrizeCouponCode]
29
+ : []));
20
30
  if (!event.raffleWinnerUserId) {
21
31
  return (_jsx(Div, { className: "border-dashed text-left", border: "strong", paddingY: "y-2xl", paddingX: "x-lg", rounded: "xl", children: _jsx(Text, { color: "muted", children: l.notDrawnYet }) }));
22
32
  }
23
- return (_jsx(Article, { children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Heading, { level: 2, size: "xl", weight: "semibold", color: "primary", children: ["\uD83C\uDF9F\uFE0F ", l.heading] }), _jsxs(Div, { className: CLS_PANEL, children: [_jsxs(Div, { children: [_jsx(Text, { className: CLS_LABEL, children: l.winnerLabel }), _jsx(Text, { className: CLS_WINNER_NAME, children: event.raffleWinnerDisplayName?.trim() || l.anonymous })] }), event.rafflePrize ? (_jsxs(Div, { children: [_jsx(Text, { className: CLS_LABEL, children: l.prizeLabel }), _jsx(Text, { className: "mt-1", color: "primary", size: "base", children: event.rafflePrize })] })) : null, currentUserIsWinner && event.rafflePrizeCouponCode ? (_jsx(Div, { padding: "t-xs", children: _jsx(ClaimCouponButton, { couponCode: event.rafflePrizeCouponCode, source: "raffle", size: "sm" }) })) : null, _jsxs(Row, { wrap: true, textSize: "sm", className: "gap-x-6 gap-y-2", color: "muted", padding: "t-xs", children: [typeof event.raffleEntryCount === "number" ? (_jsxs(Span, { children: [l.poolLabel, ":", " ", _jsx(Span, { weight: "medium", color: "primary", children: event.raffleEntryCount })] })) : null, event.raffleTriggeredAt ? (_jsx(Span, { children: new Date(event.raffleTriggeredAt).toLocaleString() })) : null] })] }), event.raffleGithubFunctionUrl ? (_jsxs(Div, { textSize: "sm", className: `${__P.p4}`, rounded: "lg", border: "default", children: [_jsx(Text, { weight: "medium", color: "primary", children: l.fairnessLabel }), _jsx(TextLink, { href: event.raffleGithubFunctionUrl, className: "mt-1 block break-all text-primary-600 dark:text-primary-400 hover:underline", children: event.raffleGithubFunctionUrl })] })) : null] }) }));
33
+ return (_jsx(Article, { children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Heading, { level: 2, size: "xl", weight: "semibold", color: "primary", children: ["\uD83C\uDF9F\uFE0F ", l.heading] }), _jsxs(Div, { className: CLS_PANEL, children: [_jsxs(Div, { children: [_jsx(Text, { className: CLS_LABEL, children: l.winnerLabel }), _jsx(Text, { className: CLS_WINNER_NAME, children: event.raffleWinnerDisplayName?.trim() || l.anonymous })] }), event.rafflePrize ? (_jsxs(Div, { children: [_jsx(Text, { className: CLS_LABEL, children: l.prizeLabel }), _jsx(Text, { className: "mt-1", color: "primary", size: "base", children: event.rafflePrize })] })) : null, currentUserIsWinner && winnerCouponCodes.length > 0 ? (_jsx(Div, { padding: "t-xs", children: _jsx(Row, { wrap: true, className: "gap-x-3 gap-y-2", children: winnerCouponCodes.map((code) => (_jsx(ClaimCouponButton, { couponCode: code, source: "raffle", size: "sm" }, code))) }) })) : null, _jsxs(Row, { wrap: true, textSize: "sm", className: "gap-x-6 gap-y-2", color: "muted", padding: "t-xs", children: [typeof event.raffleEntryCount === "number" ? (_jsxs(Span, { children: [l.poolLabel, ":", " ", _jsx(Span, { weight: "medium", color: "primary", children: event.raffleEntryCount })] })) : null, event.raffleTriggeredAt ? (_jsx(Span, { children: new Date(event.raffleTriggeredAt).toLocaleString() })) : null] })] }), event.raffleGithubFunctionUrl ? (_jsxs(Div, { textSize: "sm", className: `${__P.p4}`, rounded: "lg", border: "default", children: [_jsx(Text, { weight: "medium", color: "primary", children: l.fairnessLabel }), _jsx(TextLink, { href: event.raffleGithubFunctionUrl, className: "mt-1 block break-all text-primary-600 dark:text-primary-400 hover:underline", children: event.raffleGithubFunctionUrl })] })) : null] }) }));
24
34
  }
@@ -42,6 +42,8 @@ export declare const eventDraftSchema: z.ZodEffects<z.ZodObject<{
42
42
  id: string;
43
43
  label: string;
44
44
  }>, "many">;
45
+ couponIds: z.ZodType<string[] | undefined, z.ZodTypeDef, string[] | undefined>;
46
+ rafflePrizeCouponIds: z.ZodType<string[] | undefined, z.ZodTypeDef, string[] | undefined>;
45
47
  }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
46
48
  type: z.ZodString;
47
49
  title: z.ZodString;
@@ -59,6 +61,8 @@ export declare const eventDraftSchema: z.ZodEffects<z.ZodObject<{
59
61
  id: string;
60
62
  label: string;
61
63
  }>, "many">;
64
+ couponIds: z.ZodType<string[] | undefined, z.ZodTypeDef, string[] | undefined>;
65
+ rafflePrizeCouponIds: z.ZodType<string[] | undefined, z.ZodTypeDef, string[] | undefined>;
62
66
  }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
63
67
  type: z.ZodString;
64
68
  title: z.ZodString;
@@ -76,6 +80,8 @@ export declare const eventDraftSchema: z.ZodEffects<z.ZodObject<{
76
80
  id: string;
77
81
  label: string;
78
82
  }>, "many">;
83
+ couponIds: z.ZodType<string[] | undefined, z.ZodTypeDef, string[] | undefined>;
84
+ rafflePrizeCouponIds: z.ZodType<string[] | undefined, z.ZodTypeDef, string[] | undefined>;
79
85
  }, z.ZodTypeAny, "passthrough">>, z.objectOutputType<{
80
86
  type: z.ZodString;
81
87
  title: z.ZodString;
@@ -93,6 +99,8 @@ export declare const eventDraftSchema: z.ZodEffects<z.ZodObject<{
93
99
  id: string;
94
100
  label: string;
95
101
  }>, "many">;
102
+ couponIds: z.ZodType<string[] | undefined, z.ZodTypeDef, string[] | undefined>;
103
+ rafflePrizeCouponIds: z.ZodType<string[] | undefined, z.ZodTypeDef, string[] | undefined>;
96
104
  }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
97
105
  type: z.ZodString;
98
106
  title: z.ZodString;
@@ -110,4 +118,6 @@ export declare const eventDraftSchema: z.ZodEffects<z.ZodObject<{
110
118
  id: string;
111
119
  label: string;
112
120
  }>, "many">;
121
+ couponIds: z.ZodType<string[] | undefined, z.ZodTypeDef, string[] | undefined>;
122
+ rafflePrizeCouponIds: z.ZodType<string[] | undefined, z.ZodTypeDef, string[] | undefined>;
113
123
  }, z.ZodTypeAny, "passthrough">>;
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { z } from "zod";
18
18
  import { annotate } from "../../shell/field-ui-meta.js";
19
+ import { attachedCouponIdsSchema } from "../../promotions/schemas/attached-coupons.js";
19
20
  /**
20
21
  * ## `.passthrough()` STAYS here, deliberately
21
22
  *
@@ -67,6 +68,24 @@ export const eventDraftSchema = z.object({
67
68
  pollOptions: annotate(z.array(z.object({ id: z.string(), label: z.string() })), {
68
69
  section: "settings", order: 3, row: "full", kind: "list",
69
70
  }),
71
+ /*
72
+ * Named explicitly even though this object is `.passthrough()` and would
73
+ * carry them regardless โ€” passthrough preserves a value, it does not
74
+ * VALIDATE one. Without these two entries the cap would be enforced only by
75
+ * the API route, so an admin who picked eleven coupons would fill the form,
76
+ * hit Save, and be told by a 400 what the field could have told them while
77
+ * they were still looking at it.
78
+ *
79
+ * ๐Ÿ›‘ `couponIds` (promotional, shown to every reader) and
80
+ * `rafflePrizeCouponIds` (awarded to one winner) are DIFFERENT fields on
81
+ * purpose โ€” see `EventDocument`. They share a schema, never a value.
82
+ */
83
+ couponIds: annotate(attachedCouponIdsSchema, {
84
+ section: "settings", order: 4, row: "full", kind: "list",
85
+ }),
86
+ rafflePrizeCouponIds: annotate(attachedCouponIdsSchema, {
87
+ section: "raffle", sectionLabel: "Raffle & Spin Wheel", order: 5, row: "full", kind: "list",
88
+ }),
70
89
  }).passthrough().superRefine((v, ctx) => {
71
90
  if (v.type === "offer") {
72
91
  if (!v.couponId.trim()) {
@@ -45,6 +45,24 @@ export interface EventDocument extends BaseDocument {
45
45
  winnerImages?: MediaField[];
46
46
  additionalImages?: MediaField[];
47
47
  tags?: string[];
48
+ /**
49
+ * PROMOTIONAL coupons shown on the event page for a reader to copy.
50
+ *
51
+ * ๐Ÿ›‘ Not a prize, and not the same field as `rafflePrizeCouponIds` below.
52
+ * These are advertised to everyone who opens the page; those are awarded to
53
+ * one winner. The two were deliberately kept apart rather than overloaded,
54
+ * because every reader of a prize field asks "who won it" and every reader
55
+ * of a promo field asks "can I use it", and one array cannot answer both.
56
+ *
57
+ * Capped at `ATTACHED_COUPON_MAX` by the write schemas AND re-clamped by
58
+ * `couponsRepository.findByIds`. Resolved for display by
59
+ * `getAttachedCoupons`, which drops expired and inactive entries so a page
60
+ * never advertises a code the till will refuse.
61
+ *
62
+ * Mirrors `BlogPostDocument.couponIds` โ€” same name, same shape, same
63
+ * renderer, on purpose.
64
+ */
65
+ couponIds?: string[];
48
66
  saleConfig?: SaleConfig;
49
67
  offerConfig?: OfferConfig;
50
68
  pollConfig?: PollConfig;
@@ -54,12 +72,43 @@ export interface EventDocument extends BaseDocument {
54
72
  raffleType?: RaffleType;
55
73
  raffleTopN?: number;
56
74
  rafflePrize?: string;
75
+ /**
76
+ * @deprecated Legacy single raffle-prize coupon. READ-ONLY โ€” kept because
77
+ * `appkit-seed load` writes with `set({merge:true})` and therefore CANNOT
78
+ * remove a field from a live document (ยง Seed API Reference), so events
79
+ * written before this widening still carry it and a reader that stops
80
+ * looking silently stops awarding their prize.
81
+ *
82
+ * ๐Ÿ›‘ Never write this from new code. Resolve both through
83
+ * `rafflePrizeCouponIdsOf(event)`, which folds the legacy scalar into the
84
+ * array โ€” the reader-side fallback Root Cause #42 prescribes, so no data
85
+ * migration is required and a forgotten write path cannot strand a prize.
86
+ */
57
87
  rafflePrizeCouponId?: string;
88
+ /**
89
+ * Coupons awarded to the raffle winner. Replaces the single
90
+ * `rafflePrizeCouponId` above; a raffle may now hand over several.
91
+ */
92
+ rafflePrizeCouponIds?: string[];
58
93
  rafflePrizeProductIds?: string[];
59
94
  raffleGithubFunctionUrl?: string;
60
95
  raffleWinnerUserId?: string;
61
96
  raffleWinnerDisplayName?: string;
62
97
  raffleWinnerEntryId?: string;
98
+ /**
99
+ * The prize coupon CODES, resolved and frozen at draw time by
100
+ * `triggerEventRaffle` so the prize survives a later edit of the event's
101
+ * coupon list โ€” the same reason an entry stores `spinPrizeCouponCode`
102
+ * instead of re-deriving it.
103
+ *
104
+ * ๐Ÿ›‘ Both were WRITTEN by the raffle job and declared on no interface until
105
+ * this change. An undeclared field is invisible to every type-driven review
106
+ * and to `audit-public-projection-parity`, which is how five settings fields
107
+ * ended up publicly readable in Root Cause #70. Declared now, even though
108
+ * both are already public by design (a winner announcement is a public page).
109
+ */
110
+ rafflePrizeCouponCode?: string;
111
+ rafflePrizeCouponCodes?: string[];
63
112
  raffleTriggeredAt?: Date;
64
113
  raffleEntryCount?: number;
65
114
  spinPrizes?: SpinPrize[];
@@ -150,6 +199,8 @@ export declare const EVENT_FIELDS: {
150
199
  readonly WINNER_IMAGES: "winnerImages";
151
200
  readonly ADDITIONAL_IMAGES: "additionalImages";
152
201
  readonly TAGS: "tags";
202
+ readonly COUPON_IDS: "couponIds";
203
+ readonly RAFFLE_PRIZE_COUPON_IDS: "rafflePrizeCouponIds";
153
204
  readonly IS_PINNED: "isPinned";
154
205
  readonly ALLOW_GUEST_PARTICIPATION: "allowGuestParticipation";
155
206
  readonly CREATED_BY: "createdBy";
@@ -213,6 +264,23 @@ export declare const EVENT_ENTRY_FIELDS: {
213
264
  export type EventCreateInput = Omit<EventDocument, "id" | "createdAt" | "updatedAt" | "stats">;
214
265
  export type EventUpdateInput = Partial<Omit<EventDocument, "id" | "createdAt" | "createdBy">>;
215
266
  export type EventEntryCreateInput = Omit<EventEntryPlain, "id" | "submittedAt">;
267
+ /**
268
+ * Every coupon this event awards its raffle winner, folding the deprecated
269
+ * single `rafflePrizeCouponId` into the array.
270
+ *
271
+ * ๐Ÿ›‘ EVERY reader goes through this โ€” never touch either field directly.
272
+ * `appkit-seed load` merge-writes, so it cannot remove a field from a live
273
+ * document: events created before the widening still carry only the scalar,
274
+ * and a reader that looks solely at `rafflePrizeCouponIds` hands those
275
+ * winners nothing while erroring nowhere. Making the READER resilient is the
276
+ * fix Root Cause #42 prescribes, and it means this change needs no migration
277
+ * and no backfill script.
278
+ *
279
+ * Deduped because an admin who edits an old event through the new picker ends
280
+ * up with the same id in both fields, and awarding it twice would double the
281
+ * prize.
282
+ */
283
+ export declare function rafflePrizeCouponIdsOf(event: Pick<EventDocument, "rafflePrizeCouponId" | "rafflePrizeCouponIds">): string[];
216
284
  export declare const eventQueryHelpers: {
217
285
  readonly active: () => readonly ["status", "==", EventStatus];
218
286
  readonly byType: (type: EventType) => readonly ["type", "==", EventType];
@@ -38,6 +38,8 @@ export const EVENT_FIELDS = {
38
38
  WINNER_IMAGES: "winnerImages",
39
39
  ADDITIONAL_IMAGES: "additionalImages",
40
40
  TAGS: "tags",
41
+ COUPON_IDS: "couponIds",
42
+ RAFFLE_PRIZE_COUPON_IDS: "rafflePrizeCouponIds",
41
43
  IS_PINNED: "isPinned",
42
44
  ALLOW_GUEST_PARTICIPATION: "allowGuestParticipation",
43
45
  CREATED_BY: "createdBy",
@@ -98,6 +100,29 @@ export const EVENT_ENTRY_FIELDS = {
98
100
  FLAGGED: "flagged",
99
101
  },
100
102
  };
103
+ /**
104
+ * Every coupon this event awards its raffle winner, folding the deprecated
105
+ * single `rafflePrizeCouponId` into the array.
106
+ *
107
+ * ๐Ÿ›‘ EVERY reader goes through this โ€” never touch either field directly.
108
+ * `appkit-seed load` merge-writes, so it cannot remove a field from a live
109
+ * document: events created before the widening still carry only the scalar,
110
+ * and a reader that looks solely at `rafflePrizeCouponIds` hands those
111
+ * winners nothing while erroring nowhere. Making the READER resilient is the
112
+ * fix Root Cause #42 prescribes, and it means this change needs no migration
113
+ * and no backfill script.
114
+ *
115
+ * Deduped because an admin who edits an old event through the new picker ends
116
+ * up with the same id in both fields, and awarding it twice would double the
117
+ * prize.
118
+ */
119
+ export function rafflePrizeCouponIdsOf(event) {
120
+ const ids = [
121
+ ...(event.rafflePrizeCouponIds ?? []),
122
+ ...(event.rafflePrizeCouponId ? [event.rafflePrizeCouponId] : []),
123
+ ].filter(Boolean);
124
+ return Array.from(new Set(ids));
125
+ }
101
126
  export const eventQueryHelpers = {
102
127
  active: () => ["status", "==", "active"],
103
128
  byType: (type) => ["type", "==", type],