@mohasinac/appkit 4.63.2 โ†’ 4.63.4

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 (49) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/promotions/adapters.d.ts +25 -0
  3. package/dist/_internal/server/features/promotions/adapters.js +43 -0
  4. package/dist/_internal/server/features/promotions/data.d.ts +26 -0
  5. package/dist/_internal/server/features/promotions/data.js +45 -0
  6. package/dist/_internal/server/features/promotions/index.d.ts +2 -1
  7. package/dist/_internal/server/features/promotions/index.js +2 -1
  8. package/dist/_internal/server/jobs/core/triggerEventRaffle.js +45 -14
  9. package/dist/constants/field-names.d.ts +4 -0
  10. package/dist/constants/field-names.js +4 -0
  11. package/dist/features/account/components/UserSidebar.js +1 -1
  12. package/dist/features/admin/components/AdminBlogEditorView.js +10 -2
  13. package/dist/features/blog/schemas/blog-form.d.ts +3 -0
  14. package/dist/features/blog/schemas/blog-form.js +14 -0
  15. package/dist/features/blog/schemas/firestore.d.ts +17 -0
  16. package/dist/features/blog/schemas/firestore.js +1 -0
  17. package/dist/features/blog/schemas/index.d.ts +6 -0
  18. package/dist/features/blog/schemas/index.js +3 -0
  19. package/dist/features/blog/types/index.d.ts +2 -0
  20. package/dist/features/events/components/AdminEventEditorView.js +64 -9
  21. package/dist/features/events/components/EventOfferCard.js +3 -18
  22. package/dist/features/events/components/EventRaffleWinnerView.d.ts +6 -0
  23. package/dist/features/events/components/EventRaffleWinnerView.js +11 -1
  24. package/dist/features/events/schemas/event-form.d.ts +10 -0
  25. package/dist/features/events/schemas/event-form.js +19 -0
  26. package/dist/features/events/schemas/firestore.d.ts +68 -0
  27. package/dist/features/events/schemas/firestore.js +25 -0
  28. package/dist/features/events/schemas/index.d.ts +7 -0
  29. package/dist/features/events/schemas/index.js +5 -0
  30. package/dist/features/events/types/index.d.ts +4 -0
  31. package/dist/features/promotions/components/AttachedCouponsSection.d.ts +30 -0
  32. package/dist/features/promotions/components/AttachedCouponsSection.js +99 -0
  33. package/dist/features/promotions/components/CouponCodeCopyRow.d.ts +19 -0
  34. package/dist/features/promotions/components/CouponCodeCopyRow.js +39 -0
  35. package/dist/features/promotions/components/index.d.ts +4 -0
  36. package/dist/features/promotions/components/index.js +2 -0
  37. package/dist/features/promotions/constants/attached-coupons.d.ts +55 -0
  38. package/dist/features/promotions/constants/attached-coupons.js +24 -0
  39. package/dist/features/promotions/repository/coupons.repository.d.ts +20 -0
  40. package/dist/features/promotions/repository/coupons.repository.js +39 -0
  41. package/dist/features/promotions/schemas/attached-coupons.d.ts +39 -0
  42. package/dist/features/promotions/schemas/attached-coupons.js +66 -0
  43. package/dist/features/shell/SectionForm.js +47 -2
  44. package/dist/schemas/registry.d.ts +16 -0
  45. package/dist/server-entry.d.ts +1 -0
  46. package/dist/server-entry.js +2 -0
  47. package/dist/server.d.ts +1 -0
  48. package/dist/server.js +4 -0
  49. package/package.json +1 -1
@@ -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],
@@ -835,11 +835,14 @@ export declare const eventItemSchema: z.ZodObject<{
835
835
  placeholder?: string | undefined;
836
836
  }[];
837
837
  }>>;
838
+ couponIds: z.ZodType<string[] | undefined, z.ZodTypeDef, string[] | undefined>;
838
839
  hasRaffle: z.ZodOptional<z.ZodBoolean>;
839
840
  raffleType: z.ZodOptional<z.ZodEnum<["top_n_scorers", "top_n_participants", "open_raffle", "spin_wheel"]>>;
840
841
  raffleTopN: z.ZodOptional<z.ZodNumber>;
841
842
  rafflePrize: z.ZodOptional<z.ZodString>;
843
+ /** @deprecated read-only legacy scalar โ€” see `rafflePrizeCouponIdsOf`. */
842
844
  rafflePrizeCouponId: z.ZodOptional<z.ZodString>;
845
+ rafflePrizeCouponIds: z.ZodType<string[] | undefined, z.ZodTypeDef, string[] | undefined>;
843
846
  rafflePrizeProductIds: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
844
847
  raffleGithubFunctionUrl: z.ZodOptional<z.ZodString>;
845
848
  raffleWinnerUserId: z.ZodOptional<z.ZodString>;
@@ -992,11 +995,13 @@ export declare const eventItemSchema: z.ZodObject<{
992
995
  duration?: number | undefined;
993
996
  }[];
994
997
  slug?: string | undefined;
998
+ couponIds?: string[] | undefined;
995
999
  hasRaffle?: boolean | undefined;
996
1000
  raffleType?: "spin_wheel" | "open_raffle" | "top_n_scorers" | "top_n_participants" | undefined;
997
1001
  raffleTopN?: number | undefined;
998
1002
  rafflePrize?: string | undefined;
999
1003
  rafflePrizeCouponId?: string | undefined;
1004
+ rafflePrizeCouponIds?: string[] | undefined;
1000
1005
  raffleGithubFunctionUrl?: string | undefined;
1001
1006
  raffleWinnerUserId?: string | undefined;
1002
1007
  raffleWinnerDisplayName?: string | undefined;
@@ -1108,11 +1113,13 @@ export declare const eventItemSchema: z.ZodObject<{
1108
1113
  flaggedEntries: number;
1109
1114
  };
1110
1115
  slug?: string | undefined;
1116
+ couponIds?: string[] | undefined;
1111
1117
  hasRaffle?: boolean | undefined;
1112
1118
  raffleType?: "spin_wheel" | "open_raffle" | "top_n_scorers" | "top_n_participants" | undefined;
1113
1119
  raffleTopN?: number | undefined;
1114
1120
  rafflePrize?: string | undefined;
1115
1121
  rafflePrizeCouponId?: string | undefined;
1122
+ rafflePrizeCouponIds?: string[] | undefined;
1116
1123
  raffleGithubFunctionUrl?: string | undefined;
1117
1124
  raffleWinnerUserId?: string | undefined;
1118
1125
  raffleWinnerDisplayName?: string | undefined;
@@ -2,6 +2,7 @@ export * from "./firestore.js";
2
2
  import { z } from "zod";
3
3
  import { mediaUrlSchema } from "../../../validation/schemas.js";
4
4
  import { mediaFieldSchema } from "../../media/types/index.js";
5
+ import { attachedCouponIdsSchema } from "../../promotions/schemas/attached-coupons.js";
5
6
  const eventCoverImageSchema = z
6
7
  .union([
7
8
  mediaFieldSchema,
@@ -125,12 +126,16 @@ export const eventItemSchema = z.object({
125
126
  pollConfig: pollConfigSchema.optional(),
126
127
  surveyConfig: surveyConfigSchema.optional(),
127
128
  feedbackConfig: feedbackConfigSchema.optional(),
129
+ // Promotional coupons shown on the event page โ€” NOT prizes (see below).
130
+ couponIds: attachedCouponIdsSchema,
128
131
  // Raffle fields
129
132
  hasRaffle: z.boolean().optional(),
130
133
  raffleType: z.enum(["top_n_scorers", "top_n_participants", "open_raffle", "spin_wheel"]).optional(),
131
134
  raffleTopN: z.number().optional(),
132
135
  rafflePrize: z.string().optional(),
136
+ /** @deprecated read-only legacy scalar โ€” see `rafflePrizeCouponIdsOf`. */
133
137
  rafflePrizeCouponId: z.string().optional(),
138
+ rafflePrizeCouponIds: attachedCouponIdsSchema,
134
139
  rafflePrizeProductIds: z.array(z.string()).optional(),
135
140
  raffleGithubFunctionUrl: z.string().optional(),
136
141
  raffleWinnerUserId: z.string().optional(),
@@ -121,11 +121,15 @@ export interface EventItem {
121
121
  pollConfig?: PollConfig;
122
122
  surveyConfig?: SurveyConfig;
123
123
  feedbackConfig?: FeedbackConfig;
124
+ /** Promotional coupons shown on the page โ€” see `EventDocument.couponIds`. */
125
+ couponIds?: string[];
124
126
  hasRaffle?: boolean;
125
127
  raffleType?: RaffleType;
126
128
  raffleTopN?: number;
127
129
  rafflePrize?: string;
130
+ /** @deprecated legacy scalar โ€” resolve via `rafflePrizeCouponIdsOf`. */
128
131
  rafflePrizeCouponId?: string;
132
+ rafflePrizeCouponIds?: string[];
129
133
  rafflePrizeProductIds?: string[];
130
134
  raffleGithubFunctionUrl?: string;
131
135
  raffleWinnerUserId?: string;
@@ -0,0 +1,30 @@
1
+ import type { AttachedCoupon } from "../constants/attached-coupons";
2
+ export interface AttachedCouponsSectionProps {
3
+ /** Already resolved, already filtered โ€” see `getAttachedCoupons`. */
4
+ coupons: AttachedCoupon[];
5
+ /** Section heading. Events and blog posts word this differently. */
6
+ title?: string;
7
+ /** Optional one-line lead-in under the heading. */
8
+ description?: string;
9
+ }
10
+ /**
11
+ * The coupons a piece of content advertises โ€” events and blog posts alike.
12
+ *
13
+ * Copy-code only, by design: the reader takes the code to checkout, where
14
+ * every existing validation, per-user limit and stacking rule already applies
15
+ * against a real cart. An "apply to cart" button here would have to reproduce
16
+ * `detectCouponConflict`'s one-seller-coupon-per-store-plus-one-admin rule and
17
+ * explain its refusals on a page that knows nothing about the cart.
18
+ *
19
+ * ๐Ÿ›‘ Renders NOTHING when there are no coupons โ€” not an empty state. This sits
20
+ * inside an article or an event description, and a "no coupons available" box
21
+ * on the 95% of posts that never had one is noise that makes the 5% harder to
22
+ * notice.
23
+ *
24
+ * ๐Ÿ›‘ It deliberately does NOT label which seller a coupon belongs to, even
25
+ * though `CouponDocument` carries `scope`/`storeId`. A reader cannot act on
26
+ * that: the coupon either applies to their cart or it does not, and checkout
27
+ * is where that is decided and explained. Printing a store name here would
28
+ * imply a scope the card cannot actually enforce.
29
+ */
30
+ export declare function AttachedCouponsSection({ coupons, title, description, }: AttachedCouponsSectionProps): import("react").JSX.Element | null;