@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.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/server/features/promotions/adapters.d.ts +25 -0
- package/dist/_internal/server/features/promotions/adapters.js +43 -0
- package/dist/_internal/server/features/promotions/data.d.ts +26 -0
- package/dist/_internal/server/features/promotions/data.js +45 -0
- package/dist/_internal/server/features/promotions/index.d.ts +2 -1
- package/dist/_internal/server/features/promotions/index.js +2 -1
- package/dist/_internal/server/jobs/core/triggerEventRaffle.js +45 -14
- package/dist/constants/field-names.d.ts +4 -0
- package/dist/constants/field-names.js +4 -0
- package/dist/features/account/components/UserSidebar.js +1 -1
- package/dist/features/admin/components/AdminBlogEditorView.js +10 -2
- package/dist/features/blog/schemas/blog-form.d.ts +3 -0
- package/dist/features/blog/schemas/blog-form.js +14 -0
- package/dist/features/blog/schemas/firestore.d.ts +17 -0
- package/dist/features/blog/schemas/firestore.js +1 -0
- package/dist/features/blog/schemas/index.d.ts +6 -0
- package/dist/features/blog/schemas/index.js +3 -0
- package/dist/features/blog/types/index.d.ts +2 -0
- package/dist/features/events/components/AdminEventEditorView.js +64 -9
- package/dist/features/events/components/EventOfferCard.js +3 -18
- package/dist/features/events/components/EventRaffleWinnerView.d.ts +6 -0
- package/dist/features/events/components/EventRaffleWinnerView.js +11 -1
- package/dist/features/events/schemas/event-form.d.ts +10 -0
- package/dist/features/events/schemas/event-form.js +19 -0
- package/dist/features/events/schemas/firestore.d.ts +68 -0
- package/dist/features/events/schemas/firestore.js +25 -0
- package/dist/features/events/schemas/index.d.ts +7 -0
- package/dist/features/events/schemas/index.js +5 -0
- package/dist/features/events/types/index.d.ts +4 -0
- package/dist/features/promotions/components/AttachedCouponsSection.d.ts +30 -0
- package/dist/features/promotions/components/AttachedCouponsSection.js +99 -0
- package/dist/features/promotions/components/CouponCodeCopyRow.d.ts +19 -0
- package/dist/features/promotions/components/CouponCodeCopyRow.js +39 -0
- package/dist/features/promotions/components/index.d.ts +4 -0
- package/dist/features/promotions/components/index.js +2 -0
- package/dist/features/promotions/constants/attached-coupons.d.ts +55 -0
- package/dist/features/promotions/constants/attached-coupons.js +24 -0
- package/dist/features/promotions/repository/coupons.repository.d.ts +20 -0
- package/dist/features/promotions/repository/coupons.repository.js +39 -0
- package/dist/features/promotions/schemas/attached-coupons.d.ts +39 -0
- package/dist/features/promotions/schemas/attached-coupons.js +66 -0
- package/dist/features/shell/SectionForm.js +47 -2
- package/dist/schemas/registry.d.ts +16 -0
- package/dist/server-entry.d.ts +1 -0
- package/dist/server-entry.js +2 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +4 -0
- 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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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", "
|
|
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: "
|
|
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 {
|
|
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
|
-
|
|
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 &&
|
|
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;
|