@mohasinac/appkit 4.63.3 โ 4.63.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/server/features/checkout/actions.d.ts +2 -0
- package/dist/_internal/server/features/checkout/actions.js +22 -5
- package/dist/_internal/server/features/orders/adapters.js +3 -1
- 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/features/site-settings/adapters.js +20 -2
- package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
- package/dist/_internal/server/features/stores/adapters.js +1 -0
- package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +5 -2
- package/dist/_internal/server/jobs/core/jobRunners.js +5 -0
- package/dist/_internal/server/jobs/core/onOrderCreate.js +104 -10
- package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +2 -0
- package/dist/_internal/server/jobs/core/onOrderStatusChange.js +1 -0
- package/dist/_internal/server/jobs/core/onScamReportCreate.js +1 -0
- package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -0
- package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -0
- package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +1 -0
- package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +1 -0
- package/dist/_internal/server/jobs/core/triggerEventRaffle.js +45 -14
- package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +35 -0
- package/dist/_internal/server/jobs/core/whatsappNotify.js +140 -0
- package/dist/_internal/server/notifications/channel-health.d.ts +8 -6
- package/dist/_internal/server/notifications/channel-health.js +2 -2
- package/dist/_internal/server/notifications/send-guard.d.ts +1 -1
- package/dist/_internal/server/notifications/send-guard.js +4 -2
- package/dist/_internal/shared/actions/action-registry.js +28 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +1 -1
- package/dist/_internal/shared/checkout/order-math.js +1 -1
- package/dist/_internal/shared/features/checkout/config.d.ts +2 -0
- package/dist/_internal/shared/features/checkout/config.js +2 -0
- package/dist/_internal/shared/features/messaging/config.d.ts +5 -7
- package/dist/_internal/shared/features/messaging/config.js +4 -1
- package/dist/_internal/shared/features/site-settings/fees.d.ts +2 -0
- package/dist/_internal/shared/features/site-settings/fees.js +2 -0
- package/dist/_internal/shared/features/site-settings/types.d.ts +27 -2
- package/dist/_internal/shared/fees/calculator.d.ts +8 -0
- package/dist/_internal/shared/fees/calculator.js +7 -0
- package/dist/cli/index.js +2 -0
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/constants/api-endpoints.d.ts +15 -0
- package/dist/constants/api-endpoints.js +8 -0
- package/dist/constants/field-names.d.ts +4 -0
- package/dist/constants/field-names.js +4 -0
- package/dist/constants/notification-types.d.ts +1 -1
- package/dist/constants/notification-types.js +1 -1
- package/dist/core/integration-keys.d.ts +5 -0
- package/dist/core/integration-keys.js +10 -0
- package/dist/features/about/components/FeesView.js +6 -0
- package/dist/features/account/components/BuyerShoppingGuideView.js +1 -1
- package/dist/features/account/components/NotificationPreferencesPanel.js +4 -3
- package/dist/features/account/components/UserSidebar.js +1 -1
- package/dist/features/account/types/index.d.ts +2 -0
- package/dist/features/admin/actions/notification-actions.d.ts +17 -2
- package/dist/features/admin/actions/notification-actions.js +154 -9
- package/dist/features/admin/components/AdminBlogEditorView.js +10 -2
- package/dist/features/admin/components/AdminOrderEditorView.d.ts +1 -1
- package/dist/features/admin/components/AdminOrdersView.js +2 -0
- package/dist/features/admin/components/AdminSiteSettingsView.js +66 -6
- package/dist/features/admin/components/AdminStoreEditorView.js +1 -0
- package/dist/features/admin/constants/site-settings-tabs.d.ts +12 -7
- package/dist/features/admin/constants/site-settings-tabs.js +12 -6
- package/dist/features/admin/repository/site-settings.repository.js +1 -1
- package/dist/features/admin/schemas/admin-user-form.d.ts +61 -0
- package/dist/features/admin/schemas/admin-user-form.js +44 -3
- package/dist/features/admin/schemas/firestore.d.ts +73 -0
- package/dist/features/admin/schemas/firestore.js +8 -3
- package/dist/features/auth/checkout-value-otp.d.ts +1 -1
- package/dist/features/auth/checkout-value-otp.js +1 -1
- package/dist/features/auth/permissions/constants.d.ts +2 -2
- package/dist/features/auth/permissions/constants.js +2 -1
- 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/cart/components/CartPriceBreakdown.d.ts +2 -0
- package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
- package/dist/features/cart/components/StoreAddonsPicker.d.ts +5 -2
- package/dist/features/cart/components/StoreAddonsPicker.js +5 -4
- package/dist/features/cart/repository/cart.repository.js +2 -1
- package/dist/features/cart/schemas/firestore.d.ts +2 -1
- package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +5 -10
- package/dist/features/checkout/actions/checkout-value-otp-actions.js +88 -2
- 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/homepage/lib/section-renderer.js +1 -1
- package/dist/features/orders/actions/refund-actions.js +1 -0
- package/dist/features/orders/components/OrderAddonBadges.d.ts +3 -1
- package/dist/features/orders/components/OrderAddonBadges.js +4 -3
- package/dist/features/orders/repository/orders.repository.d.ts +8 -0
- package/dist/features/orders/repository/orders.repository.js +2 -0
- package/dist/features/orders/schemas/firestore.d.ts +4 -0
- package/dist/features/orders/types/index.d.ts +5 -2
- 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/search/action-index/settings-entries.js +9 -0
- package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +6 -1
- package/dist/features/store-extensions/repository/store-extensions.repositories.js +10 -1
- package/dist/features/store-extensions/schemas/firestore.d.ts +17 -1
- package/dist/features/store-extensions/schemas/firestore.js +10 -0
- package/dist/features/stores/components/StoreCapabilitiesGuideView.js +1 -0
- package/dist/features/stores/components/StoreGuideHubView.js +1 -1
- package/dist/features/stores/components/StoreSettingsGuideView.js +6 -2
- package/dist/features/stores/repository/store.repository.d.ts +23 -1
- package/dist/features/stores/repository/store.repository.js +29 -1
- package/dist/features/stores/schemas/firestore.d.ts +16 -6
- package/dist/features/stores/schemas/firestore.js +3 -6
- package/dist/features/stores/schemas/index.d.ts +86 -0
- package/dist/features/stores/schemas/index.js +14 -0
- package/dist/features/whatsapp-bot/api/send-status/route.d.ts +12 -0
- package/dist/features/whatsapp-bot/api/send-status/route.js +38 -0
- package/dist/features/whatsapp-bot/api/webhook/route.d.ts +12 -0
- package/dist/features/whatsapp-bot/api/webhook/route.js +43 -0
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +57 -0
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +76 -0
- package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +9 -0
- package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +7 -0
- package/dist/features/whatsapp-bot/components/index.d.ts +3 -0
- package/dist/features/whatsapp-bot/components/index.js +2 -0
- package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +52 -0
- package/dist/features/whatsapp-bot/helpers/whatsapp.js +288 -0
- package/dist/features/whatsapp-bot/index.d.ts +3 -0
- package/dist/features/whatsapp-bot/index.js +3 -0
- package/dist/features/whatsapp-bot/manifest.d.ts +2 -0
- package/dist/features/whatsapp-bot/manifest.js +13 -0
- package/dist/features/whatsapp-bot/messages/en.json +7 -0
- package/dist/features/whatsapp-bot/server.d.ts +7 -0
- package/dist/features/whatsapp-bot/server.js +7 -0
- package/dist/features/whatsapp-bot/types/index.d.ts +126 -0
- package/dist/features/whatsapp-bot/types/index.js +1 -0
- package/dist/index.d.ts +23 -6
- package/dist/index.js +38 -12
- package/dist/next/routing/route-map.d.ts +3 -0
- package/dist/next/routing/route-map.js +1 -0
- package/dist/providers/db-firebase/base.repository.d.ts +1 -1
- package/dist/providers/db-firebase/base.repository.js +1 -1
- package/dist/repositories/index.d.ts +1 -1
- package/dist/repositories/index.js +1 -1
- package/dist/schemas/registry.d.ts +121 -0
- package/dist/schemas/registry.js +2 -1
- package/dist/security/pii-encrypt.js +1 -1
- package/dist/server-entry.d.ts +1 -0
- package/dist/server-entry.js +2 -0
- package/dist/server.d.ts +11 -2
- package/dist/server.js +26 -2
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/NavigationLoader.d.ts +14 -1
- package/dist/ui/components/NavigationLoader.js +79 -30
- package/package.json +1 -1
- package/dist/features/orders/utils/order-announcement-message.d.ts +0 -18
- package/dist/features/orders/utils/order-announcement-message.js +0 -16
|
@@ -9,12 +9,17 @@
|
|
|
9
9
|
import { timingSafeEqual } from "crypto";
|
|
10
10
|
import { ValidationError } from "../../../errors/index.js";
|
|
11
11
|
import { normalizeError } from "../../../errors/normalize.js";
|
|
12
|
+
import { safeRead } from "../../../errors/safe-read.js";
|
|
12
13
|
import { serverLogger } from "../../../monitoring/index.js";
|
|
13
14
|
import { sendEmail } from "../../contact/email.js";
|
|
14
15
|
import { getAdminDb } from "../../../providers/db-firebase/index.js";
|
|
15
16
|
import { resolveDate } from "../../../utils/index.js";
|
|
17
|
+
import { addressesRepository } from "../../addresses/repository/addresses.repository.js";
|
|
18
|
+
import { siteSettingsRepository } from "../../admin/repository/site-settings.repository.js";
|
|
19
|
+
import { sendWhatsAppBusinessMessage } from "../../whatsapp-bot/helpers/whatsapp.js";
|
|
16
20
|
import { isChannelHealthy, withChannelRetry } from "../../../_internal/server/notifications/channel-health.js";
|
|
17
|
-
import {
|
|
21
|
+
import { guardSend, logSuppressedSend } from "../../../_internal/server/notifications/send-guard.js";
|
|
22
|
+
import { CHECKOUT_VALUE_OTP_EXPIRY_MS, CHECKOUT_VALUE_OTP_MAX_ATTEMPTS, hashOtp, generateOtpCode, checkoutValueOtpRef, enforceCheckoutValueOtpRateLimit, buildCheckoutValueOtpMessage, } from "../../auth/checkout-value-otp.js";
|
|
18
23
|
function maskEmail(userEmail) {
|
|
19
24
|
const [local, domain] = userEmail.split("@");
|
|
20
25
|
return domain
|
|
@@ -22,7 +27,10 @@ function maskEmail(userEmail) {
|
|
|
22
27
|
: "***";
|
|
23
28
|
}
|
|
24
29
|
/**
|
|
25
|
-
* Sends a checkout-value OTP by email
|
|
30
|
+
* Sends a checkout-value OTP either by email (default) or WhatsApp
|
|
31
|
+
* (opt-in โ the buyer must explicitly choose it in the UI). WhatsApp phone
|
|
32
|
+
* is always resolved server-side from the selected shipping address โ
|
|
33
|
+
* never trust a client-supplied phone number.
|
|
26
34
|
*
|
|
27
35
|
* If the chosen channel (and, for email, its skip-worthy fallback) is
|
|
28
36
|
* currently circuit-broken or unconfigured, the OTP gate is treated as
|
|
@@ -37,6 +45,84 @@ export async function sendCheckoutValueOtp(userId, userEmail, channel = "email",
|
|
|
37
45
|
const codeHash = hashOtp(code);
|
|
38
46
|
const expiresAt = new Date(Date.now() + CHECKOUT_VALUE_OTP_EXPIRY_MS);
|
|
39
47
|
const siteName = process.env.NEXT_PUBLIC_SITE_NAME || "LetItRip";
|
|
48
|
+
if (channel === "whatsapp") {
|
|
49
|
+
if (!addressId) {
|
|
50
|
+
throw new ValidationError("Select a delivery address before sending the code via WhatsApp.");
|
|
51
|
+
}
|
|
52
|
+
// A failed settings read makes the WhatsApp channel look deliberately
|
|
53
|
+
// switched off ("send the code by email instead") โ a defensible fallback,
|
|
54
|
+
// but one an operator has to be able to see, since it is indistinguishable
|
|
55
|
+
// from an admin having disabled it.
|
|
56
|
+
const settings = await safeRead(() => siteSettingsRepository.getSingleton(), {
|
|
57
|
+
route: "checkout/value-otp",
|
|
58
|
+
key: "checkoutValueOtp.siteSettings",
|
|
59
|
+
fallback: null,
|
|
60
|
+
});
|
|
61
|
+
const creds = settings?.credentials ?? {};
|
|
62
|
+
const whatsappOtpEnabled = settings?.notificationChannels?.whatsapp?.otpEnabled === true;
|
|
63
|
+
const phoneNumberId = creds.whatsappPhoneNumberId?.trim() ?? "";
|
|
64
|
+
const accessToken = creds.whatsappCloudApiToken?.trim() ?? "";
|
|
65
|
+
const credsReady = phoneNumberId && accessToken && !phoneNumberId.includes("PLACEHOLDER") && !accessToken.includes("PLACEHOLDER");
|
|
66
|
+
if (!whatsappOtpEnabled || !credsReady) {
|
|
67
|
+
throw new ValidationError("WhatsApp verification isn't available right now โ send the code by email instead.");
|
|
68
|
+
}
|
|
69
|
+
const address = await addressesRepository.findById(addressId);
|
|
70
|
+
/*
|
|
71
|
+
* REVEALED: this phone receives the high-value checkout OTP, so it has to
|
|
72
|
+
* be the real number. `addressesRepository` is ciphertext-by-default, and
|
|
73
|
+
* `.trim()` on the stored value is what the nominal brand caught โ the
|
|
74
|
+
* old intersection form let `Encrypted<string>` call string methods.
|
|
75
|
+
*/
|
|
76
|
+
const addressPlain = address ? addressesRepository.revealAddress(address) : null;
|
|
77
|
+
const phone = addressPlain?.phone?.trim();
|
|
78
|
+
if (!phone) {
|
|
79
|
+
throw new ValidationError("The selected address has no phone number on file โ send the code by email instead.");
|
|
80
|
+
}
|
|
81
|
+
if (!(await isChannelHealthy("whatsapp"))) {
|
|
82
|
+
serverLogger.warn("sendCheckoutValueOtp: WhatsApp channel unhealthy โ skipping OTP gate", { uid: userId, channel });
|
|
83
|
+
return { skipped: true };
|
|
84
|
+
}
|
|
85
|
+
await checkoutValueOtpRef(db, userId).set({
|
|
86
|
+
codeHash,
|
|
87
|
+
expiresAt,
|
|
88
|
+
attempts: 0,
|
|
89
|
+
verified: false,
|
|
90
|
+
createdAt: new Date(),
|
|
91
|
+
});
|
|
92
|
+
const message = buildCheckoutValueOtpMessage(code, siteName);
|
|
93
|
+
// Same guard as the email branch below, same reasoning: transactional, so
|
|
94
|
+
// the kill switch does not apply (the buyer cannot check out without the
|
|
95
|
+
// code), but the daily ceiling does.
|
|
96
|
+
const waGuard = {
|
|
97
|
+
channel: "whatsapp",
|
|
98
|
+
feature: "checkout_value_otp",
|
|
99
|
+
audience: "user",
|
|
100
|
+
transactional: true,
|
|
101
|
+
};
|
|
102
|
+
const waDecision = await guardSend(waGuard);
|
|
103
|
+
if (!waDecision.allow) {
|
|
104
|
+
logSuppressedSend(waGuard, waDecision);
|
|
105
|
+
throw new ValidationError("We couldn't send the verification code via WhatsApp โ please try again, or send it by email instead.");
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const sent = await withChannelRetry("whatsapp", async () => {
|
|
109
|
+
const ok = await sendWhatsAppBusinessMessage({ toPhone: phone, message, phoneNumberId, accessToken });
|
|
110
|
+
if (!ok)
|
|
111
|
+
throw new Error("WhatsApp Cloud API returned a non-OK response");
|
|
112
|
+
return ok;
|
|
113
|
+
});
|
|
114
|
+
if (!sent)
|
|
115
|
+
throw new Error("WhatsApp send failed");
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
void normalizeError(err);
|
|
119
|
+
serverLogger.error("Failed to send checkout value OTP via WhatsApp", { uid: userId, err });
|
|
120
|
+
throw new ValidationError("We couldn't send the verification code via WhatsApp โ please try again, or send it by email instead.");
|
|
121
|
+
}
|
|
122
|
+
const maskedPhone = phone.length > 4 ? `${"*".repeat(phone.length - 4)}${phone.slice(-4)}` : "***";
|
|
123
|
+
serverLogger.info(`Checkout value OTP sent via WhatsApp: uid=${userId}`);
|
|
124
|
+
return { maskedPhone };
|
|
125
|
+
}
|
|
40
126
|
if (!(await isChannelHealthy("email"))) {
|
|
41
127
|
serverLogger.warn("sendCheckoutValueOtp: email channel unhealthy โ skipping OTP gate", { uid: userId, channel });
|
|
42
128
|
return { skipped: true };
|
|
@@ -5,13 +5,15 @@ import { useApiMutation } from "@mohasinac/appkit/client";
|
|
|
5
5
|
import React, { useEffect } from "react";
|
|
6
6
|
import { useTranslations } from "next-intl";
|
|
7
7
|
import { useQuery } from "@tanstack/react-query";
|
|
8
|
-
import { Alert, Button, Div, Grid, Heading, Input, Label, RichTextEditor, Row, Select, Stack, StackedViewShell, TagInput, Text, Textarea, Toggle, } from "../../../ui/index.js";
|
|
8
|
+
import { Alert, Button, Div, Grid, Heading, Input, Label, RichTextEditor, Row, Select, Span, Stack, StackedViewShell, TagInput, Text, Textarea, Toggle, } from "../../../ui/index.js";
|
|
9
9
|
import { FormShellContext, useFormShellState, applyZodIssues, FormErrorSummary } from "../../../ui/forms/index.js";
|
|
10
10
|
import { apiClient } from "../../../http/index.js";
|
|
11
11
|
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
12
12
|
import { ACTIONS } from "../../../_internal/shared/actions/action-registry.js";
|
|
13
13
|
import { toUserMessage } from "../../../errors/error-display-map.js";
|
|
14
14
|
import { ALL_EVENT_TYPES, EVENT_TYPE_LABELS } from "../types/index.js";
|
|
15
|
+
import { rafflePrizeCouponIdsOf } from "../schemas/firestore.js";
|
|
16
|
+
import { ATTACHED_COUPON_MAX } from "../../promotions/constants/attached-coupons.js";
|
|
15
17
|
import { SectionForm, useSectionFormNav } from "../../shell/index.js";
|
|
16
18
|
import { eventDraftSchema } from "../schemas/event-form.js";
|
|
17
19
|
import { ProductInlineSelect } from "../../seller/components/ProductInlineSelect.js";
|
|
@@ -136,6 +138,7 @@ const DEFAULT_DRAFT = {
|
|
|
136
138
|
createdBy: "",
|
|
137
139
|
allowGuestParticipation: false,
|
|
138
140
|
isPinned: false,
|
|
141
|
+
couponIds: [],
|
|
139
142
|
discountPercent: "10",
|
|
140
143
|
saleBannerText: "",
|
|
141
144
|
couponId: "",
|
|
@@ -158,7 +161,7 @@ const DEFAULT_DRAFT = {
|
|
|
158
161
|
raffleType: "open_raffle",
|
|
159
162
|
raffleTopN: "10",
|
|
160
163
|
rafflePrize: "",
|
|
161
|
-
|
|
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],
|