@bash-app/bash-common 30.433.0 → 30.434.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/appleIapProducts.test.js +1 -0
- package/dist/__tests__/appleIapProducts.test.js.map +1 -1
- package/dist/__tests__/promoCodeScope.test.js +9 -2
- package/dist/__tests__/promoCodeScope.test.js.map +1 -1
- package/dist/__tests__/statusEnums.test.js +1 -0
- package/dist/__tests__/statusEnums.test.js.map +1 -1
- package/dist/appleIapProducts.d.ts +12 -2
- package/dist/appleIapProducts.d.ts.map +1 -1
- package/dist/appleIapProducts.js +13 -0
- package/dist/appleIapProducts.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/intros/__tests__/introsReleaseGates.test.d.ts +2 -0
- package/dist/intros/__tests__/introsReleaseGates.test.d.ts.map +1 -0
- package/dist/intros/__tests__/introsReleaseGates.test.js +140 -0
- package/dist/intros/__tests__/introsReleaseGates.test.js.map +1 -0
- package/dist/intros/__tests__/psychExperiment.test.d.ts +2 -0
- package/dist/intros/__tests__/psychExperiment.test.d.ts.map +1 -0
- package/dist/intros/__tests__/psychExperiment.test.js +63 -0
- package/dist/intros/__tests__/psychExperiment.test.js.map +1 -0
- package/dist/intros/__tests__/reasons.test.d.ts +2 -0
- package/dist/intros/__tests__/reasons.test.d.ts.map +1 -0
- package/dist/intros/__tests__/reasons.test.js +72 -0
- package/dist/intros/__tests__/reasons.test.js.map +1 -0
- package/dist/intros/__tests__/sharedNotes.test.d.ts +2 -0
- package/dist/intros/__tests__/sharedNotes.test.d.ts.map +1 -0
- package/dist/intros/__tests__/sharedNotes.test.js +19 -0
- package/dist/intros/__tests__/sharedNotes.test.js.map +1 -0
- package/dist/intros/__tests__/transitions.test.d.ts +2 -0
- package/dist/intros/__tests__/transitions.test.d.ts.map +1 -0
- package/dist/intros/__tests__/transitions.test.js +43 -0
- package/dist/intros/__tests__/transitions.test.js.map +1 -0
- package/dist/intros/__tests__/warmDisclosure.test.d.ts +2 -0
- package/dist/intros/__tests__/warmDisclosure.test.d.ts.map +1 -0
- package/dist/intros/__tests__/warmDisclosure.test.js +10 -0
- package/dist/intros/__tests__/warmDisclosure.test.js.map +1 -0
- package/dist/intros/age.d.ts +4 -0
- package/dist/intros/age.d.ts.map +1 -0
- package/dist/intros/age.js +14 -0
- package/dist/intros/age.js.map +1 -0
- package/dist/intros/attendance.d.ts +11 -0
- package/dist/intros/attendance.d.ts.map +1 -0
- package/dist/intros/attendance.js +27 -0
- package/dist/intros/attendance.js.map +1 -0
- package/dist/intros/attributeCatalog.d.ts +17 -0
- package/dist/intros/attributeCatalog.d.ts.map +1 -0
- package/dist/intros/attributeCatalog.js +96 -0
- package/dist/intros/attributeCatalog.js.map +1 -0
- package/dist/intros/index.d.ts +10 -0
- package/dist/intros/index.d.ts.map +1 -0
- package/dist/intros/index.js +10 -0
- package/dist/intros/index.js.map +1 -0
- package/dist/intros/intents.d.ts +8 -0
- package/dist/intros/intents.d.ts.map +1 -0
- package/dist/intros/intents.js +44 -0
- package/dist/intros/intents.js.map +1 -0
- package/dist/intros/psychExperiment.d.ts +29 -0
- package/dist/intros/psychExperiment.d.ts.map +1 -0
- package/dist/intros/psychExperiment.js +82 -0
- package/dist/intros/psychExperiment.js.map +1 -0
- package/dist/intros/reasons.d.ts +38 -0
- package/dist/intros/reasons.d.ts.map +1 -0
- package/dist/intros/reasons.js +162 -0
- package/dist/intros/reasons.js.map +1 -0
- package/dist/intros/sharedNotes.d.ts +10 -0
- package/dist/intros/sharedNotes.d.ts.map +1 -0
- package/dist/intros/sharedNotes.js +56 -0
- package/dist/intros/sharedNotes.js.map +1 -0
- package/dist/intros/transitions.d.ts +29 -0
- package/dist/intros/transitions.d.ts.map +1 -0
- package/dist/intros/transitions.js +128 -0
- package/dist/intros/transitions.js.map +1 -0
- package/dist/intros/validation.d.ts +50 -0
- package/dist/intros/validation.d.ts.map +1 -0
- package/dist/intros/validation.js +212 -0
- package/dist/intros/validation.js.map +1 -0
- package/dist/mirroredPrismaEnums.d.ts +1 -0
- package/dist/mirroredPrismaEnums.d.ts.map +1 -1
- package/dist/mirroredPrismaEnums.js +1 -0
- package/dist/mirroredPrismaEnums.js.map +1 -1
- package/dist/promoCodeScope.d.ts +7 -1
- package/dist/promoCodeScope.d.ts.map +1 -1
- package/dist/promoCodeScope.js +13 -4
- package/dist/promoCodeScope.js.map +1 -1
- package/package.json +1 -1
- package/prisma/schema.prisma +321 -2
- package/src/__tests__/appleIapProducts.test.ts +3 -0
- package/src/__tests__/promoCodeScope.test.ts +16 -3
- package/src/__tests__/statusEnums.test.ts +1 -0
- package/src/appleIapProducts.ts +29 -2
- package/src/index.ts +1 -0
- package/src/intros/__tests__/introsReleaseGates.test.ts +181 -0
- package/src/intros/__tests__/psychExperiment.test.ts +99 -0
- package/src/intros/__tests__/reasons.test.ts +122 -0
- package/src/intros/__tests__/sharedNotes.test.ts +20 -0
- package/src/intros/__tests__/transitions.test.ts +61 -0
- package/src/intros/__tests__/warmDisclosure.test.ts +10 -0
- package/src/intros/age.ts +13 -0
- package/src/intros/attendance.ts +33 -0
- package/src/intros/attributeCatalog.ts +113 -0
- package/src/intros/index.ts +9 -0
- package/src/intros/intents.ts +49 -0
- package/src/intros/psychExperiment.ts +130 -0
- package/src/intros/reasons.ts +187 -0
- package/src/intros/sharedNotes.ts +59 -0
- package/src/intros/transitions.ts +172 -0
- package/src/intros/validation.ts +248 -0
- package/src/mirroredPrismaEnums.ts +1 -0
- package/src/promoCodeScope.ts +20 -4
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import {
|
|
2
|
+
attributeMayBeUsedInExperiments,
|
|
3
|
+
isIntrosAttributeKind,
|
|
4
|
+
validateAttributeValue,
|
|
5
|
+
type IntrosAttributeKind,
|
|
6
|
+
} from "./attributeCatalog.js";
|
|
7
|
+
import { isIntrosIntent, type IntrosIntent } from "./intents.js";
|
|
8
|
+
|
|
9
|
+
export const WARM_INVITE_PREFS = ["Off", "Anonymous", "Named"] as const;
|
|
10
|
+
export type WarmIntroInvitePref = (typeof WARM_INVITE_PREFS)[number];
|
|
11
|
+
|
|
12
|
+
export const INTROS_EVENT_MODES = ["Off", "Platonic", "All"] as const;
|
|
13
|
+
export type IntrosEventMode = (typeof INTROS_EVENT_MODES)[number];
|
|
14
|
+
|
|
15
|
+
const GENDERS = ["women", "men", "nonbinary", "everyone"] as const;
|
|
16
|
+
|
|
17
|
+
export type AttributePatch = {
|
|
18
|
+
value: unknown;
|
|
19
|
+
visibility: "OnlyMe" | "RevealedIntros";
|
|
20
|
+
allowExperimentalMatching: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ProfilePatch = {
|
|
24
|
+
shortIntro?: string | null;
|
|
25
|
+
conversationTopics?: string[];
|
|
26
|
+
professionalInterests?: string[];
|
|
27
|
+
travelRadiusMiles?: number | null;
|
|
28
|
+
intents?: IntrosIntent[];
|
|
29
|
+
warmIntroInvites?: WarmIntroInvitePref | null;
|
|
30
|
+
shareRegistrationStatus?: boolean;
|
|
31
|
+
revealOnlyAfterCheckIn?: boolean;
|
|
32
|
+
notifyIntros?: boolean;
|
|
33
|
+
warmSuggestionAudience?: "None" | "Connections" | "ConnectionsAndHosts";
|
|
34
|
+
romance?: { seekingGenders: string[]; ageMin: number; ageMax: number } | null;
|
|
35
|
+
attributes?: Partial<Record<IntrosAttributeKind, AttributePatch | null>>;
|
|
36
|
+
/** Unknown attribute keys sent as null, so a retired note can be removed. */
|
|
37
|
+
discardAttributes?: string[];
|
|
38
|
+
activate?: boolean;
|
|
39
|
+
globalMatching?: boolean;
|
|
40
|
+
romanceConsent?: boolean;
|
|
41
|
+
psychologyExperiment?: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type ValidationOk<T> = { ok: true; value: T };
|
|
45
|
+
export type ValidationErr = { ok: false; error: string };
|
|
46
|
+
|
|
47
|
+
function cleanList(raw: unknown, label: string): ValidationOk<string[]> | ValidationErr {
|
|
48
|
+
if (!Array.isArray(raw)) return { ok: false, error: `${label} must be a list` };
|
|
49
|
+
if (raw.length > 10) return { ok: false, error: `${label} can have at most 10 items` };
|
|
50
|
+
const next: string[] = [];
|
|
51
|
+
for (const item of raw) {
|
|
52
|
+
if (typeof item !== "string") return { ok: false, error: `${label} must be text` };
|
|
53
|
+
const trimmed = item.trim().slice(0, 40);
|
|
54
|
+
if (trimmed) next.push(trimmed);
|
|
55
|
+
}
|
|
56
|
+
return { ok: true, value: next };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parseWarmIntroInvites(
|
|
60
|
+
raw: unknown
|
|
61
|
+
): ValidationOk<WarmIntroInvitePref | null> | ValidationErr {
|
|
62
|
+
if (raw == null) return { ok: true, value: null };
|
|
63
|
+
if (typeof raw !== "string" || !(WARM_INVITE_PREFS as readonly string[]).includes(raw)) {
|
|
64
|
+
return { ok: false, error: "Choose Off, Anonymous, or Named" };
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, value: raw as WarmIntroInvitePref };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseIntrosEventMode(raw: unknown): ValidationOk<IntrosEventMode> | ValidationErr {
|
|
70
|
+
if (typeof raw !== "string" || !(INTROS_EVENT_MODES as readonly string[]).includes(raw)) {
|
|
71
|
+
return { ok: false, error: "Choose Off, Platonic, or All" };
|
|
72
|
+
}
|
|
73
|
+
return { ok: true, value: raw as IntrosEventMode };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function parseProfilePatch(body: unknown): ValidationOk<ProfilePatch> | ValidationErr {
|
|
77
|
+
if (body == null || typeof body !== "object") {
|
|
78
|
+
return { ok: false, error: "Request body is required" };
|
|
79
|
+
}
|
|
80
|
+
const record = body as Record<string, unknown>;
|
|
81
|
+
const patch: ProfilePatch = {};
|
|
82
|
+
|
|
83
|
+
if ("shortIntro" in record) {
|
|
84
|
+
if (record.shortIntro == null) patch.shortIntro = null;
|
|
85
|
+
else if (typeof record.shortIntro !== "string") {
|
|
86
|
+
return { ok: false, error: "Introduction must be text" };
|
|
87
|
+
} else {
|
|
88
|
+
const text = record.shortIntro.replace(/https?:\/\/\S+/gi, "").trim();
|
|
89
|
+
if (text.length > 140) {
|
|
90
|
+
return { ok: false, error: "Introduction must be 140 characters or fewer" };
|
|
91
|
+
}
|
|
92
|
+
patch.shortIntro = text;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if ("conversationTopics" in record) {
|
|
96
|
+
const parsed = cleanList(record.conversationTopics, "Conversation topics");
|
|
97
|
+
if (!parsed.ok) return parsed;
|
|
98
|
+
patch.conversationTopics = parsed.value;
|
|
99
|
+
}
|
|
100
|
+
if ("professionalInterests" in record) {
|
|
101
|
+
const parsed = cleanList(record.professionalInterests, "Interests");
|
|
102
|
+
if (!parsed.ok) return parsed;
|
|
103
|
+
patch.professionalInterests = parsed.value;
|
|
104
|
+
}
|
|
105
|
+
if ("travelRadiusMiles" in record) {
|
|
106
|
+
if (record.travelRadiusMiles == null) patch.travelRadiusMiles = null;
|
|
107
|
+
else if (
|
|
108
|
+
typeof record.travelRadiusMiles !== "number" ||
|
|
109
|
+
record.travelRadiusMiles < 1 ||
|
|
110
|
+
record.travelRadiusMiles > 200
|
|
111
|
+
) {
|
|
112
|
+
return { ok: false, error: "Travel distance must be between 1 and 200 miles" };
|
|
113
|
+
} else patch.travelRadiusMiles = Math.round(record.travelRadiusMiles);
|
|
114
|
+
}
|
|
115
|
+
if ("intents" in record) {
|
|
116
|
+
if (!Array.isArray(record.intents)) return { ok: false, error: "Intents must be a list" };
|
|
117
|
+
const intents: IntrosIntent[] = [];
|
|
118
|
+
for (const item of record.intents) {
|
|
119
|
+
if (typeof item !== "string" || !isIntrosIntent(item)) {
|
|
120
|
+
return { ok: false, error: "Unknown connection intention" };
|
|
121
|
+
}
|
|
122
|
+
if (!intents.includes(item)) intents.push(item);
|
|
123
|
+
}
|
|
124
|
+
patch.intents = intents;
|
|
125
|
+
}
|
|
126
|
+
if ("warmIntroInvites" in record) {
|
|
127
|
+
const parsed = parseWarmIntroInvites(record.warmIntroInvites);
|
|
128
|
+
if (!parsed.ok) return parsed;
|
|
129
|
+
patch.warmIntroInvites = parsed.value;
|
|
130
|
+
}
|
|
131
|
+
for (const key of [
|
|
132
|
+
"shareRegistrationStatus",
|
|
133
|
+
"revealOnlyAfterCheckIn",
|
|
134
|
+
"notifyIntros",
|
|
135
|
+
"activate",
|
|
136
|
+
"globalMatching",
|
|
137
|
+
"romanceConsent",
|
|
138
|
+
"psychologyExperiment",
|
|
139
|
+
] as const) {
|
|
140
|
+
if (key in record) {
|
|
141
|
+
if (typeof record[key] !== "boolean") return { ok: false, error: `${key} must be on or off` };
|
|
142
|
+
patch[key] = record[key] as boolean;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if ("warmSuggestionAudience" in record) {
|
|
146
|
+
const value = record.warmSuggestionAudience;
|
|
147
|
+
if (value !== "None" && value !== "Connections" && value !== "ConnectionsAndHosts") {
|
|
148
|
+
return { ok: false, error: "Choose who may be asked to introduce you" };
|
|
149
|
+
}
|
|
150
|
+
patch.warmSuggestionAudience = value;
|
|
151
|
+
}
|
|
152
|
+
if ("romance" in record) {
|
|
153
|
+
if (record.romance == null) patch.romance = null;
|
|
154
|
+
else if (typeof record.romance !== "object") {
|
|
155
|
+
return { ok: false, error: "Romance preferences are invalid" };
|
|
156
|
+
} else {
|
|
157
|
+
const romance = record.romance as Record<string, unknown>;
|
|
158
|
+
const ageMin = romance.ageMin;
|
|
159
|
+
const ageMax = romance.ageMax;
|
|
160
|
+
if (
|
|
161
|
+
typeof ageMin !== "number" ||
|
|
162
|
+
typeof ageMax !== "number" ||
|
|
163
|
+
ageMin < 18 ||
|
|
164
|
+
ageMax > 99 ||
|
|
165
|
+
ageMin > ageMax
|
|
166
|
+
) {
|
|
167
|
+
return { ok: false, error: "Romance ages must be between 18 and 99" };
|
|
168
|
+
}
|
|
169
|
+
if (!Array.isArray(romance.seekingGenders)) {
|
|
170
|
+
return { ok: false, error: "Choose who you want to meet" };
|
|
171
|
+
}
|
|
172
|
+
const seeking = romance.seekingGenders.filter((g): g is string => typeof g === "string");
|
|
173
|
+
if (seeking.some((g) => !(GENDERS as readonly string[]).includes(g))) {
|
|
174
|
+
return { ok: false, error: "Choose who you want to meet" };
|
|
175
|
+
}
|
|
176
|
+
patch.romance = { seekingGenders: seeking, ageMin, ageMax };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (record.attributes != null && typeof record.attributes === "object") {
|
|
180
|
+
const attributes: NonNullable<ProfilePatch["attributes"]> = {};
|
|
181
|
+
for (const [key, raw] of Object.entries(record.attributes as Record<string, unknown>)) {
|
|
182
|
+
if (!isIntrosAttributeKind(key)) {
|
|
183
|
+
if (raw == null) {
|
|
184
|
+
patch.discardAttributes = [...(patch.discardAttributes ?? []), key];
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
return { ok: false, error: "Unknown profile attribute" };
|
|
188
|
+
}
|
|
189
|
+
if (raw == null) {
|
|
190
|
+
attributes[key] = null;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (typeof raw !== "object") return { ok: false, error: "Attributes are invalid" };
|
|
194
|
+
const attr = raw as Record<string, unknown>;
|
|
195
|
+
const parsed = validateAttributeValue(key, attr.value);
|
|
196
|
+
if (!parsed.ok) return parsed;
|
|
197
|
+
if (attr.visibility !== "OnlyMe" && attr.visibility !== "RevealedIntros") {
|
|
198
|
+
return { ok: false, error: "Choose who can see this attribute" };
|
|
199
|
+
}
|
|
200
|
+
if (attr.allowExperimentalMatching === true && !attributeMayBeUsedInExperiments(key)) {
|
|
201
|
+
return { ok: false, error: "This attribute cannot be used for matching" };
|
|
202
|
+
}
|
|
203
|
+
attributes[key] = {
|
|
204
|
+
value: parsed.value,
|
|
205
|
+
visibility: attr.visibility,
|
|
206
|
+
allowExperimentalMatching: attr.allowExperimentalMatching === true,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
patch.attributes = attributes;
|
|
210
|
+
}
|
|
211
|
+
return { ok: true, value: patch };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function validateLegacyIntroPreference(key: string, value: unknown): string | null {
|
|
215
|
+
if (key === "introIntents") {
|
|
216
|
+
if (!Array.isArray(value)) return "Intro intents must be a list";
|
|
217
|
+
const allowed = new Set(["friends", "professional", "mentorship"]);
|
|
218
|
+
if (value.some((item) => typeof item !== "string" || !allowed.has(item))) {
|
|
219
|
+
return "Unknown intro intent";
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (key === "personalityType" && value != null) {
|
|
223
|
+
if (typeof value !== "string" || !/^[A-Za-z]{1,4}$/.test(value)) {
|
|
224
|
+
return "Personality type must be 1 to 4 letters";
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (
|
|
228
|
+
key === "locusOfControl" &&
|
|
229
|
+
value != null &&
|
|
230
|
+
value !== "internal" &&
|
|
231
|
+
value !== "external" &&
|
|
232
|
+
value !== "mixed"
|
|
233
|
+
) {
|
|
234
|
+
return "Locus of control must be internal, external, or mixed";
|
|
235
|
+
}
|
|
236
|
+
if (key === "openToIntros" && typeof value !== "boolean") {
|
|
237
|
+
return "Open to intros must be on or off";
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Warm invite names the other person only when both recipients chose Named. */
|
|
243
|
+
export function warmInviteNamesOtherPerson(
|
|
244
|
+
a: WarmIntroInvitePref | null | undefined,
|
|
245
|
+
b: WarmIntroInvitePref | null | undefined
|
|
246
|
+
): boolean {
|
|
247
|
+
return a === "Named" && b === "Named";
|
|
248
|
+
}
|
|
@@ -202,6 +202,7 @@ export const CreditSourceType = {
|
|
|
202
202
|
CompetitionFinalist: "CompetitionFinalist",
|
|
203
203
|
CompetitionWin: "CompetitionWin",
|
|
204
204
|
HornClear: "HornClear",
|
|
205
|
+
IntrosEarlyReveal: "IntrosEarlyReveal",
|
|
205
206
|
} as const satisfies Record<CreditSourceTypeEnum, CreditSourceTypeEnum>;
|
|
206
207
|
|
|
207
208
|
export type CreditSourceType = CreditSourceTypeEnum;
|
package/src/promoCodeScope.ts
CHANGED
|
@@ -9,6 +9,17 @@ import { getPublicBashDetailPath } from "./utils/slugUtils.js";
|
|
|
9
9
|
export type PromoDiscountKind = "NONE" | "PERCENT" | "FIXED_CASH";
|
|
10
10
|
export type PromoEligibilityMode = "ALL_TIERS" | "SELECTED_TIERS";
|
|
11
11
|
|
|
12
|
+
/** How the host pays a promoter for a code. BashPoints is credited here; the others are paid outside Bash. */
|
|
13
|
+
export const PROMO_COMPENSATION_METHODS = ["Venmo", "Zelle", "CashApp", "BashPoints"] as const;
|
|
14
|
+
export type PromoCompensationMethod = (typeof PROMO_COMPENSATION_METHODS)[number];
|
|
15
|
+
|
|
16
|
+
export function isPromoCompensationMethod(value: unknown): value is PromoCompensationMethod {
|
|
17
|
+
return (
|
|
18
|
+
typeof value === "string" &&
|
|
19
|
+
(PROMO_COMPENSATION_METHODS as readonly string[]).includes(value)
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
12
23
|
export type PromoScopeFields = {
|
|
13
24
|
ticketTierId?: string | null;
|
|
14
25
|
bashEventId?: string | null;
|
|
@@ -98,7 +109,7 @@ export type PromoterCodeInput = {
|
|
|
98
109
|
promoterUserId?: string | null;
|
|
99
110
|
promoterEmail?: string | null;
|
|
100
111
|
discountType: PromoDiscountKind;
|
|
101
|
-
/** Percent (
|
|
112
|
+
/** Percent (0-100) for PERCENT, cents (0 or more) for FIXED_CASH, ignored for NONE. */
|
|
102
113
|
discountValue?: number | null;
|
|
103
114
|
eligibilityMode: PromoEligibilityMode;
|
|
104
115
|
eligibleTierIds?: string[];
|
|
@@ -112,6 +123,8 @@ export type PromoterCodeInput = {
|
|
|
112
123
|
promoterCommissionBps?: number | null;
|
|
113
124
|
/** The host opened and confirmed the Promoter reward section. */
|
|
114
125
|
promoterRewardReviewed?: boolean;
|
|
126
|
+
/** Venmo, Zelle, Cash App, or BashPoints. Null clears a saved choice. */
|
|
127
|
+
compensationMethod?: PromoCompensationMethod | null;
|
|
115
128
|
};
|
|
116
129
|
|
|
117
130
|
export type PromoterCodeStatusLabel = "active" | "expired" | "exhausted" | "disabled";
|
|
@@ -129,12 +142,12 @@ export function promoterCodeInputErrors(
|
|
|
129
142
|
}
|
|
130
143
|
if (input.discountType === "PERCENT") {
|
|
131
144
|
const pct = Number(input.discountValue);
|
|
132
|
-
if (!Number.isInteger(pct) || pct <
|
|
133
|
-
errors.push("Guest discount must be between
|
|
145
|
+
if (!Number.isInteger(pct) || pct < 0 || pct > 100) {
|
|
146
|
+
errors.push("Guest discount must be between 0% and 100%.");
|
|
134
147
|
}
|
|
135
148
|
} else if (input.discountType === "FIXED_CASH") {
|
|
136
149
|
const cents = Number(input.discountValue);
|
|
137
|
-
if (!Number.isInteger(cents) || cents
|
|
150
|
+
if (!Number.isInteger(cents) || cents < 0) errors.push("Enter a fixed discount of $0 or more.");
|
|
138
151
|
}
|
|
139
152
|
if (input.eligibilityMode === "SELECTED_TIERS") {
|
|
140
153
|
const ids = input.eligibleTierIds ?? [];
|
|
@@ -160,5 +173,8 @@ export function promoterCodeInputErrors(
|
|
|
160
173
|
if (hasPromoter && !input.promoterRewardReviewed) {
|
|
161
174
|
errors.push("Review the promoter reward before saving.");
|
|
162
175
|
}
|
|
176
|
+
if (input.compensationMethod != null && !isPromoCompensationMethod(input.compensationMethod)) {
|
|
177
|
+
errors.push("Choose Venmo, Zelle, Cash App, or BashPoints.");
|
|
178
|
+
}
|
|
163
179
|
return errors;
|
|
164
180
|
}
|