@bash-app/bash-common 30.333.0 → 30.335.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__/causeTags.test.d.ts +2 -0
- package/dist/__tests__/causeTags.test.d.ts.map +1 -0
- package/dist/__tests__/causeTags.test.js +94 -0
- package/dist/__tests__/causeTags.test.js.map +1 -0
- package/dist/__tests__/hostCrmGrowthPack.test.js +12 -2
- package/dist/__tests__/hostCrmGrowthPack.test.js.map +1 -1
- package/dist/bashFeedTypes.d.ts +4 -0
- package/dist/bashFeedTypes.d.ts.map +1 -1
- package/dist/causeTags.d.ts +42 -0
- package/dist/causeTags.d.ts.map +1 -0
- package/dist/causeTags.js +173 -0
- package/dist/causeTags.js.map +1 -0
- package/dist/definitions.d.ts +23 -0
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js +28 -0
- package/dist/definitions.js.map +1 -1
- package/dist/hostCrmAutomation.d.ts +12 -2
- package/dist/hostCrmAutomation.d.ts.map +1 -1
- package/dist/hostCrmAutomation.js +16 -2
- package/dist/hostCrmAutomation.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/sms/smsTemplates.d.ts +6 -1
- package/dist/sms/smsTemplates.d.ts.map +1 -1
- package/dist/sms/smsTemplates.js +1 -0
- package/dist/sms/smsTemplates.js.map +1 -1
- package/dist/utils/__tests__/coatCheckUtils.test.d.ts +2 -0
- package/dist/utils/__tests__/coatCheckUtils.test.d.ts.map +1 -0
- package/dist/utils/__tests__/coatCheckUtils.test.js +76 -0
- package/dist/utils/__tests__/coatCheckUtils.test.js.map +1 -0
- package/dist/utils/coatCheckUtils.d.ts +30 -0
- package/dist/utils/coatCheckUtils.d.ts.map +1 -0
- package/dist/utils/coatCheckUtils.js +85 -0
- package/dist/utils/coatCheckUtils.js.map +1 -0
- package/dist/utils/slugUtils.d.ts +3 -4
- package/dist/utils/slugUtils.d.ts.map +1 -1
- package/dist/utils/slugUtils.js +3 -4
- package/dist/utils/slugUtils.js.map +1 -1
- package/package.json +1 -1
- package/prisma/schema.prisma +429 -3
- package/src/__tests__/causeTags.test.ts +143 -0
- package/src/__tests__/hostCrmGrowthPack.test.ts +14 -1
- package/src/bashFeedTypes.ts +4 -0
- package/src/causeTags.ts +243 -0
- package/src/definitions.ts +46 -0
- package/src/hostCrmAutomation.ts +25 -2
- package/src/index.ts +2 -0
- package/src/sms/smsTemplates.ts +6 -0
- package/src/utils/__tests__/coatCheckUtils.test.ts +110 -0
- package/src/utils/coatCheckUtils.ts +134 -0
- package/src/utils/slugUtils.ts +3 -4
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import {
|
|
2
2
|
hostCrmGrowthPackIsComplete,
|
|
3
|
+
hostCrmHasLateBuyerPackAutomation,
|
|
3
4
|
hostCrmHasPostEventPackAutomation,
|
|
4
5
|
hostCrmHasWinBackPackAutomation,
|
|
6
|
+
HOST_CRM_GROWTH_PACK_LATE_BUYER_PRESET_ID,
|
|
5
7
|
HOST_CRM_GROWTH_PACK_WINBACK_PRESET_ID,
|
|
6
8
|
} from "../hostCrmAutomation.js";
|
|
7
9
|
|
|
8
10
|
describe("host CRM growth pack detection", () => {
|
|
9
|
-
it("detects win-back
|
|
11
|
+
it("detects win-back, post-event, and late-buyer pack rows", () => {
|
|
10
12
|
const empty: Array<{ trigger: string; channel: string; audiencePresetId?: string | null }> =
|
|
11
13
|
[];
|
|
12
14
|
expect(hostCrmHasWinBackPackAutomation(empty)).toBe(false);
|
|
13
15
|
expect(hostCrmHasPostEventPackAutomation(empty)).toBe(false);
|
|
16
|
+
expect(hostCrmHasLateBuyerPackAutomation(empty)).toBe(false);
|
|
14
17
|
expect(hostCrmGrowthPackIsComplete(empty)).toBe(false);
|
|
15
18
|
|
|
16
19
|
const winBack = [
|
|
@@ -29,5 +32,15 @@ describe("host CRM growth pack detection", () => {
|
|
|
29
32
|
];
|
|
30
33
|
expect(hostCrmHasPostEventPackAutomation(both)).toBe(true);
|
|
31
34
|
expect(hostCrmGrowthPackIsComplete(both)).toBe(true);
|
|
35
|
+
|
|
36
|
+
const withLateBuyer = [
|
|
37
|
+
...both,
|
|
38
|
+
{
|
|
39
|
+
trigger: "Recurring",
|
|
40
|
+
channel: "Email",
|
|
41
|
+
audiencePresetId: HOST_CRM_GROWTH_PACK_LATE_BUYER_PRESET_ID,
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
expect(hostCrmHasLateBuyerPackAutomation(withLateBuyer)).toBe(true);
|
|
32
45
|
});
|
|
33
46
|
});
|
package/src/bashFeedTypes.ts
CHANGED
|
@@ -36,6 +36,8 @@ export interface BashFeedPostBashSummary {
|
|
|
36
36
|
rsvpGoing?: number;
|
|
37
37
|
/** `PublicBashRsvp` rows with status Maybe. */
|
|
38
38
|
rsvpMaybe?: number;
|
|
39
|
+
/** Classic `Review` rows for the event (feed ratings modal / event reviews). */
|
|
40
|
+
reviews?: number;
|
|
39
41
|
};
|
|
40
42
|
activeOffers?: Array<{
|
|
41
43
|
id: string;
|
|
@@ -158,6 +160,8 @@ export interface BashFeedPostBashSummaryWire {
|
|
|
158
160
|
checkedIn?: number;
|
|
159
161
|
rsvpGoing?: number;
|
|
160
162
|
rsvpMaybe?: number;
|
|
163
|
+
/** Classic `Review` rows for the event (feed ratings modal / event reviews). */
|
|
164
|
+
reviews?: number;
|
|
161
165
|
};
|
|
162
166
|
}
|
|
163
167
|
|
package/src/causeTags.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated cause / community tags for bash + service Why flows.
|
|
3
|
+
* Stored as stable string IDs on `causeTags` (Service / BashEvent) so search
|
|
4
|
+
* can filter later without parsing free text.
|
|
5
|
+
*/
|
|
6
|
+
export type CauseTagId =
|
|
7
|
+
| "neighborhood"
|
|
8
|
+
| "mutual_aid"
|
|
9
|
+
| "arts_culture"
|
|
10
|
+
| "music_scene"
|
|
11
|
+
| "youth"
|
|
12
|
+
| "lgbtq"
|
|
13
|
+
| "environment"
|
|
14
|
+
| "mental_health"
|
|
15
|
+
| "education"
|
|
16
|
+
| "food_security"
|
|
17
|
+
| "animal_welfare"
|
|
18
|
+
| "sports_rec"
|
|
19
|
+
| "faith_spiritual"
|
|
20
|
+
| "civic"
|
|
21
|
+
| "fundraiser"
|
|
22
|
+
| "small_business";
|
|
23
|
+
|
|
24
|
+
export type CauseTag = {
|
|
25
|
+
id: CauseTagId;
|
|
26
|
+
label: string;
|
|
27
|
+
/** Short seed line for bash descriptions when a chip is selected. */
|
|
28
|
+
bashSeed: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const CAUSE_TAGS: readonly CauseTag[] = [
|
|
32
|
+
{
|
|
33
|
+
id: "neighborhood",
|
|
34
|
+
label: "Neighborhood / block",
|
|
35
|
+
bashSeed: "This bash is for the neighbors — building our block crew.",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "mutual_aid",
|
|
39
|
+
label: "Mutual aid",
|
|
40
|
+
bashSeed: "We're gathering to support mutual aid in our community.",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "arts_culture",
|
|
44
|
+
label: "Arts & culture",
|
|
45
|
+
bashSeed: "This night celebrates local arts and culture.",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "music_scene",
|
|
49
|
+
label: "Music scene",
|
|
50
|
+
bashSeed: "Built for the local music scene — come through for the sound.",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "youth",
|
|
54
|
+
label: "Youth & kids",
|
|
55
|
+
bashSeed: "This bash supports youth and families in our area.",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "lgbtq",
|
|
59
|
+
label: "LGBTQ+",
|
|
60
|
+
bashSeed: "A queer-friendly bash — everyone who shows respect is welcome.",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "environment",
|
|
64
|
+
label: "Environment",
|
|
65
|
+
bashSeed: "We're tying this night to environmental care and climate action.",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "mental_health",
|
|
69
|
+
label: "Mental health",
|
|
70
|
+
bashSeed: "This gathering centers mental health, rest, and real connection.",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "education",
|
|
74
|
+
label: "Education",
|
|
75
|
+
bashSeed: "We're here for learning, mentoring, and education in our community.",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "food_security",
|
|
79
|
+
label: "Food security",
|
|
80
|
+
bashSeed: "Part of this night supports food access for people nearby.",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "animal_welfare",
|
|
84
|
+
label: "Animal welfare",
|
|
85
|
+
bashSeed: "We're throwing this with animal welfare in mind.",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "sports_rec",
|
|
89
|
+
label: "Sports & recreation",
|
|
90
|
+
bashSeed: "Come move, play, and build the local sports / rec crew.",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "faith_spiritual",
|
|
94
|
+
label: "Faith / spiritual",
|
|
95
|
+
bashSeed: "This bash brings our faith / spiritual community together.",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: "civic",
|
|
99
|
+
label: "Civic / local politics",
|
|
100
|
+
bashSeed: "A civic hang — talk, organize, and show up for our city.",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "fundraiser",
|
|
104
|
+
label: "Fundraiser",
|
|
105
|
+
bashSeed: "This bash raises support for a cause we care about.",
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: "small_business",
|
|
109
|
+
label: "Small business / makers",
|
|
110
|
+
bashSeed: "Celebrating local small businesses and makers.",
|
|
111
|
+
},
|
|
112
|
+
] as const;
|
|
113
|
+
|
|
114
|
+
export const CAUSE_TAG_BY_ID: Readonly<Record<CauseTagId, CauseTag>> =
|
|
115
|
+
CAUSE_TAGS.reduce(
|
|
116
|
+
(acc, tag) => {
|
|
117
|
+
acc[tag.id] = tag;
|
|
118
|
+
return acc;
|
|
119
|
+
},
|
|
120
|
+
{} as Record<CauseTagId, CauseTag>
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
export function isCauseTagId(value: string): value is CauseTagId {
|
|
124
|
+
return Object.prototype.hasOwnProperty.call(CAUSE_TAG_BY_ID, value);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function normalizeCauseTags(ids: string[] | null | undefined): CauseTagId[] {
|
|
128
|
+
if (!ids?.length) return [];
|
|
129
|
+
const seen = new Set<CauseTagId>();
|
|
130
|
+
for (const id of ids) {
|
|
131
|
+
if (isCauseTagId(id)) seen.add(id);
|
|
132
|
+
}
|
|
133
|
+
return [...seen];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function causeTagLabels(ids: string[] | null | undefined): string[] {
|
|
137
|
+
return normalizeCauseTags(ids).map((id) => CAUSE_TAG_BY_ID[id].label);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Cause tag IDs whose label or id matches a free-text search query.
|
|
142
|
+
* Used by keyword search (Prisma `hasSome`) and client-side filters.
|
|
143
|
+
*/
|
|
144
|
+
export function causeTagIdsMatchingSearch(query: string): CauseTagId[] {
|
|
145
|
+
const term = query.trim().toLowerCase();
|
|
146
|
+
if (!term) return [];
|
|
147
|
+
|
|
148
|
+
const termAsId = term.replace(/\s+/g, "_");
|
|
149
|
+
return CAUSE_TAGS.filter((tag) => {
|
|
150
|
+
const idSpaced = tag.id.replace(/_/g, " ");
|
|
151
|
+
return (
|
|
152
|
+
tag.label.toLowerCase().includes(term) ||
|
|
153
|
+
tag.id.includes(termAsId) ||
|
|
154
|
+
idSpaced.includes(term)
|
|
155
|
+
);
|
|
156
|
+
}).map((tag) => tag.id);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** True when any of the entity's cause tags match the search query. */
|
|
160
|
+
export function causeTagsMatchSearch(
|
|
161
|
+
causeTags: string[] | null | undefined,
|
|
162
|
+
query: string
|
|
163
|
+
): boolean {
|
|
164
|
+
const matching = causeTagIdsMatchingSearch(query);
|
|
165
|
+
if (matching.length === 0) return false;
|
|
166
|
+
const selected = new Set(normalizeCauseTags(causeTags));
|
|
167
|
+
return matching.some((id) => selected.has(id));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export type BashDonationCauseFields = {
|
|
171
|
+
donationGoalCents?: number | null;
|
|
172
|
+
donationCausePercent?: number | null;
|
|
173
|
+
donationCauseTagId?: string | null;
|
|
174
|
+
donationCauseLabel?: string | null;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Resolve the public cause label for host-declared donation split.
|
|
179
|
+
* Prefers curated tag label over free-text.
|
|
180
|
+
*/
|
|
181
|
+
export function resolveDonationCauseLabel(
|
|
182
|
+
fields: Pick<
|
|
183
|
+
BashDonationCauseFields,
|
|
184
|
+
"donationCauseTagId" | "donationCauseLabel"
|
|
185
|
+
>
|
|
186
|
+
): string | null {
|
|
187
|
+
const tagId = fields.donationCauseTagId?.trim();
|
|
188
|
+
if (tagId && isCauseTagId(tagId)) {
|
|
189
|
+
return CAUSE_TAG_BY_ID[tagId].label;
|
|
190
|
+
}
|
|
191
|
+
const label = fields.donationCauseLabel?.trim();
|
|
192
|
+
return label ? label : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export type DonationCauseValidationError =
|
|
196
|
+
| "invalid_goal"
|
|
197
|
+
| "invalid_percent"
|
|
198
|
+
| "percent_requires_cause"
|
|
199
|
+
| "invalid_cause_tag";
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Validate bash donation goal / cause-split fields before persist.
|
|
203
|
+
* Returns null when valid.
|
|
204
|
+
*/
|
|
205
|
+
export function validateBashDonationCauseFields(
|
|
206
|
+
fields: BashDonationCauseFields
|
|
207
|
+
): DonationCauseValidationError | null {
|
|
208
|
+
const goal = fields.donationGoalCents;
|
|
209
|
+
if (
|
|
210
|
+
goal != null &&
|
|
211
|
+
(typeof goal !== "number" ||
|
|
212
|
+
!Number.isFinite(goal) ||
|
|
213
|
+
goal <= 0 ||
|
|
214
|
+
!Number.isInteger(goal))
|
|
215
|
+
) {
|
|
216
|
+
return "invalid_goal";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const rawTag = fields.donationCauseTagId?.trim() || null;
|
|
220
|
+
if (rawTag && !isCauseTagId(rawTag)) {
|
|
221
|
+
return "invalid_cause_tag";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const percent = fields.donationCausePercent;
|
|
225
|
+
if (percent != null) {
|
|
226
|
+
if (
|
|
227
|
+
typeof percent !== "number" ||
|
|
228
|
+
!Number.isFinite(percent) ||
|
|
229
|
+
!Number.isInteger(percent) ||
|
|
230
|
+
percent < 0 ||
|
|
231
|
+
percent > 100
|
|
232
|
+
) {
|
|
233
|
+
return "invalid_percent";
|
|
234
|
+
}
|
|
235
|
+
const hasTag = !!rawTag && isCauseTagId(rawTag);
|
|
236
|
+
const hasLabel = !!fields.donationCauseLabel?.trim();
|
|
237
|
+
if (!hasTag && !hasLabel) {
|
|
238
|
+
return "percent_requires_cause";
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return null;
|
|
243
|
+
}
|
package/src/definitions.ts
CHANGED
|
@@ -229,6 +229,45 @@ export const CHECKOUT_PURPOSE_DOOR_SQUARE_SALE = "door_square_sale" as const;
|
|
|
229
229
|
/** Walk-up / PAD door sale via Square Terminal (card present). */
|
|
230
230
|
export const CHECKOUT_PURPOSE_DOOR_SQUARE_TERMINAL_SALE =
|
|
231
231
|
"door_square_terminal_sale" as const;
|
|
232
|
+
/** Online ticket checkout line: coat/bag check add-on. */
|
|
233
|
+
export const CHECKOUT_PURPOSE_COAT_CHECK_ONLINE = "coat_check_online" as const;
|
|
234
|
+
/** Walk-up coat/bag check via Stripe Checkout (guest pays on phone). */
|
|
235
|
+
export const CHECKOUT_PURPOSE_COAT_CHECK_DOOR_SALE = "coat_check_door_sale" as const;
|
|
236
|
+
/** Coat/bag check via Stripe Terminal (card present). */
|
|
237
|
+
export const CHECKOUT_PURPOSE_COAT_CHECK_DOOR_TERMINAL_SALE =
|
|
238
|
+
"coat_check_door_terminal_sale" as const;
|
|
239
|
+
/** Walk-up coat/bag check via Square Payment Link. */
|
|
240
|
+
export const CHECKOUT_PURPOSE_COAT_CHECK_DOOR_SQUARE_SALE =
|
|
241
|
+
"coat_check_door_square_sale" as const;
|
|
242
|
+
/** Coat/bag check via Square Terminal. */
|
|
243
|
+
export const CHECKOUT_PURPOSE_COAT_CHECK_DOOR_SQUARE_TERMINAL_SALE =
|
|
244
|
+
"coat_check_door_square_terminal_sale" as const;
|
|
245
|
+
|
|
246
|
+
export const COAT_CHECK_ENTITLEMENT_STATUSES = [
|
|
247
|
+
"Paid",
|
|
248
|
+
"CheckedIn",
|
|
249
|
+
"Claimed",
|
|
250
|
+
"Refunded",
|
|
251
|
+
"Cancelled",
|
|
252
|
+
] as const;
|
|
253
|
+
export type CoatCheckEntitlementStatusName =
|
|
254
|
+
(typeof COAT_CHECK_ENTITLEMENT_STATUSES)[number];
|
|
255
|
+
|
|
256
|
+
export const COAT_CHECK_QR_TYPE = "coat_check" as const;
|
|
257
|
+
|
|
258
|
+
export const COAT_CHECK_SCAN_OUTCOMES = [
|
|
259
|
+
"ready_for_dropoff",
|
|
260
|
+
"ready_for_claim",
|
|
261
|
+
"already_claimed",
|
|
262
|
+
"invalid_or_expired",
|
|
263
|
+
"wrong_event",
|
|
264
|
+
"cancelled_or_refunded",
|
|
265
|
+
] as const;
|
|
266
|
+
export type CoatCheckScanOutcome = (typeof COAT_CHECK_SCAN_OUTCOMES)[number];
|
|
267
|
+
|
|
268
|
+
/** Statuses that occupy a coat-check slot. */
|
|
269
|
+
export const COAT_CHECK_SLOT_OCCUPYING_STATUSES: CoatCheckEntitlementStatusName[] =
|
|
270
|
+
["Paid", "CheckedIn"];
|
|
232
271
|
|
|
233
272
|
/** Which processor the door app uses for card capture (host preferred + ready). */
|
|
234
273
|
export type DoorPaymentRail = "STRIPE" | "SQUARE";
|
|
@@ -1038,6 +1077,13 @@ export interface StripeCreateBashEventTicketsCheckoutSessionArgs extends UtmFiel
|
|
|
1038
1077
|
|
|
1039
1078
|
/** EventGroup id when purchasing a group package for the crew */
|
|
1040
1079
|
groupId?: string;
|
|
1080
|
+
|
|
1081
|
+
/** Optional coat/bag check units to buy with this ticket checkout (0 = none). */
|
|
1082
|
+
coatCheckQuantity?: number;
|
|
1083
|
+
/** When true, add oversized surcharge per coat unit (if host configured). */
|
|
1084
|
+
coatCheckOversized?: boolean;
|
|
1085
|
+
/** Optional tip cents for coat check (when host enables tips). */
|
|
1086
|
+
coatCheckTipCents?: number;
|
|
1041
1087
|
}
|
|
1042
1088
|
|
|
1043
1089
|
export interface StripeCreateBashEventDonationCheckoutSessionArgs extends UtmFields {
|
package/src/hostCrmAutomation.ts
CHANGED
|
@@ -93,11 +93,22 @@ export const HOST_CRM_WINBACK_DEFAULT_SUBJECT =
|
|
|
93
93
|
export const HOST_CRM_WINBACK_DEFAULT_BODY =
|
|
94
94
|
"It's been a while since your last bash with us. We'd love to see you again.";
|
|
95
95
|
|
|
96
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* Post-event cron substitutes {{eventTitle}}, {{firstName}}, {{bashFeedUrl}},
|
|
98
|
+
* {{memoriesUrl}}.
|
|
99
|
+
*/
|
|
97
100
|
export const HOST_CRM_GROWTH_PACK_POST_EVENT_SUBJECT =
|
|
98
101
|
"Thank you for coming to {{eventTitle}}";
|
|
99
102
|
export const HOST_CRM_GROWTH_PACK_POST_EVENT_BODY =
|
|
100
|
-
"Hi {{firstName}},\n\nThanks so much for being part of {{eventTitle}} — it wouldn't have been the same without you.
|
|
103
|
+
"Hi {{firstName}},\n\nThanks so much for being part of {{eventTitle}} — it wouldn't have been the same without you. Got photos from the night? Save them to Memories, then post on BashFeed or drop them in the album:\n\nMemories: {{memoriesUrl}}\nBashFeed: {{bashFeedUrl}}\n\nHope to see you at the next one!";
|
|
104
|
+
|
|
105
|
+
/** Recurring email to last-minute buyers (paid within 48h of start). */
|
|
106
|
+
export const HOST_CRM_GROWTH_PACK_LATE_BUYER_PRESET_ID = "last_minute_buyer";
|
|
107
|
+
export const HOST_CRM_GROWTH_PACK_LATE_BUYER_NAME = "Late-buyer logistics nudge";
|
|
108
|
+
export const HOST_CRM_GROWTH_PACK_LATE_BUYER_SUBJECT =
|
|
109
|
+
"You're in — here's how to make door day smooth";
|
|
110
|
+
export const HOST_CRM_GROWTH_PACK_LATE_BUYER_BODY =
|
|
111
|
+
"Hi {{firstName}},\n\nGlad you're coming. Since you grabbed tickets close to showtime, leave a few extra minutes for parking and check-in — doors can get busy.\n\nSee you there!";
|
|
101
112
|
|
|
102
113
|
type GrowthPackAutomationShape = {
|
|
103
114
|
trigger: string;
|
|
@@ -128,6 +139,18 @@ export function hostCrmHasPostEventPackAutomation(
|
|
|
128
139
|
);
|
|
129
140
|
}
|
|
130
141
|
|
|
142
|
+
/** True when a Recurring Email + last_minute_buyer automation already exists. */
|
|
143
|
+
export function hostCrmHasLateBuyerPackAutomation(
|
|
144
|
+
automations: GrowthPackAutomationShape[]
|
|
145
|
+
): boolean {
|
|
146
|
+
return automations.some(
|
|
147
|
+
(a) =>
|
|
148
|
+
a.trigger === HostCrmAutomationTrigger.Recurring &&
|
|
149
|
+
a.channel === HostCrmAutomationChannel.Email &&
|
|
150
|
+
a.audiencePresetId === HOST_CRM_GROWTH_PACK_LATE_BUYER_PRESET_ID
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
131
154
|
export function hostCrmGrowthPackIsComplete(
|
|
132
155
|
automations: GrowthPackAutomationShape[]
|
|
133
156
|
): boolean {
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from "./competitionIdeas.js";
|
|
|
10
10
|
export * from "./sideQuestIdeas.js";
|
|
11
11
|
export * from "./sideQuestTypes.js";
|
|
12
12
|
export * from "./icebreakerPrompts.js";
|
|
13
|
+
export * from "./causeTags.js";
|
|
13
14
|
export * from "./competitionValidation.js";
|
|
14
15
|
export * from "./groupTicketUtils.js";
|
|
15
16
|
export * from "./guestPartySize.js";
|
|
@@ -406,6 +407,7 @@ export * from "./utils/flyerUtils.js";
|
|
|
406
407
|
export * from "./utils/promotionBlastUtils.js";
|
|
407
408
|
export * from "./utils/orgPromotionalPackageUtils.js";
|
|
408
409
|
export * from "./utils/ticketCheckInDisplay.js";
|
|
410
|
+
export * from "./utils/coatCheckUtils.js";
|
|
409
411
|
export * from "./utils/ticketTierSchedule.js";
|
|
410
412
|
export * from "./utils/lobFlyerUtils.js";
|
|
411
413
|
export * from "./utils/generalDateTimeUtils.js";
|
package/src/sms/smsTemplates.ts
CHANGED
|
@@ -10,6 +10,7 @@ export const SMS_TEMPLATE_KEYS = [
|
|
|
10
10
|
"EVENT_VENUE_CHANGED",
|
|
11
11
|
"EVENT_WAITLIST_APPROVED",
|
|
12
12
|
"TICKET_CONFIRMED",
|
|
13
|
+
"COAT_CHECK_CLAIM",
|
|
13
14
|
] as const;
|
|
14
15
|
|
|
15
16
|
export type SmsTemplateKey = (typeof SMS_TEMPLATE_KEYS)[number];
|
|
@@ -23,4 +24,9 @@ export type SmsTemplateContext = {
|
|
|
23
24
|
EVENT_VENUE_CHANGED: { eventTitle: string; shortUrl: string };
|
|
24
25
|
EVENT_WAITLIST_APPROVED: { eventTitle: string; shortUrl: string };
|
|
25
26
|
TICKET_CONFIRMED: { eventTitle: string; ticketCount: number; ticketsUrl?: string };
|
|
27
|
+
COAT_CHECK_CLAIM: {
|
|
28
|
+
eventTitle: string;
|
|
29
|
+
claimLabel: string;
|
|
30
|
+
ticketsUrl?: string;
|
|
31
|
+
};
|
|
26
32
|
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canTransitionCoatCheckStatus,
|
|
3
|
+
clampCoatCheckPurchaseQty,
|
|
4
|
+
coatCheckScanOutcomeForStatus,
|
|
5
|
+
encodeCoatCheckQrPayload,
|
|
6
|
+
parseCoatCheckQrPayload,
|
|
7
|
+
remainingCoatCheckSlots,
|
|
8
|
+
} from "../coatCheckUtils";
|
|
9
|
+
|
|
10
|
+
describe("coatCheckUtils", () => {
|
|
11
|
+
describe("encode/parse QR payload", () => {
|
|
12
|
+
it("round-trips claim QR fields", () => {
|
|
13
|
+
const raw = encodeCoatCheckQrPayload({
|
|
14
|
+
eventId: "evt1",
|
|
15
|
+
entitlementId: "ent1",
|
|
16
|
+
qrToken: "tok-abc",
|
|
17
|
+
});
|
|
18
|
+
const parsed = parseCoatCheckQrPayload(raw);
|
|
19
|
+
expect(parsed).toMatchObject({
|
|
20
|
+
type: "coat_check",
|
|
21
|
+
eventId: "evt1",
|
|
22
|
+
entitlementId: "ent1",
|
|
23
|
+
qrToken: "tok-abc",
|
|
24
|
+
});
|
|
25
|
+
expect(typeof parsed?.timestamp).toBe("number");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("rejects ticket-style payloads", () => {
|
|
29
|
+
expect(
|
|
30
|
+
parseCoatCheckQrPayload(
|
|
31
|
+
JSON.stringify({ eventId: "e", ticketId: "t", qrToken: "x" })
|
|
32
|
+
)
|
|
33
|
+
).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("rejects invalid JSON", () => {
|
|
37
|
+
expect(parseCoatCheckQrPayload("not-json")).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("state machine", () => {
|
|
42
|
+
it("allows Paid → CheckedIn → Claimed", () => {
|
|
43
|
+
expect(canTransitionCoatCheckStatus("Paid", "CheckedIn")).toBe(true);
|
|
44
|
+
expect(canTransitionCoatCheckStatus("CheckedIn", "Claimed")).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("rejects illegal transitions", () => {
|
|
48
|
+
expect(canTransitionCoatCheckStatus("Paid", "Claimed")).toBe(false);
|
|
49
|
+
expect(canTransitionCoatCheckStatus("Claimed", "CheckedIn")).toBe(false);
|
|
50
|
+
expect(canTransitionCoatCheckStatus("CheckedIn", "Paid")).toBe(false);
|
|
51
|
+
expect(canTransitionCoatCheckStatus("Paid", "Paid")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("allows refund/cancel from Paid or CheckedIn", () => {
|
|
55
|
+
expect(canTransitionCoatCheckStatus("Paid", "Refunded")).toBe(true);
|
|
56
|
+
expect(canTransitionCoatCheckStatus("CheckedIn", "Cancelled")).toBe(true);
|
|
57
|
+
expect(canTransitionCoatCheckStatus("Claimed", "Refunded")).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("maps status to scan outcomes", () => {
|
|
61
|
+
expect(coatCheckScanOutcomeForStatus("Paid")).toBe("ready_for_dropoff");
|
|
62
|
+
expect(coatCheckScanOutcomeForStatus("CheckedIn")).toBe("ready_for_claim");
|
|
63
|
+
expect(coatCheckScanOutcomeForStatus("Claimed")).toBe("already_claimed");
|
|
64
|
+
expect(coatCheckScanOutcomeForStatus("Refunded")).toBe(
|
|
65
|
+
"cancelled_or_refunded"
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("inventory helpers", () => {
|
|
71
|
+
it("treats null capacity as unlimited remaining", () => {
|
|
72
|
+
expect(
|
|
73
|
+
remainingCoatCheckSlots({ capacity: null, occupiedCount: 50 })
|
|
74
|
+
).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("computes remaining slots", () => {
|
|
78
|
+
expect(
|
|
79
|
+
remainingCoatCheckSlots({ capacity: 100, occupiedCount: 40 })
|
|
80
|
+
).toBe(60);
|
|
81
|
+
expect(
|
|
82
|
+
remainingCoatCheckSlots({ capacity: 10, occupiedCount: 12 })
|
|
83
|
+
).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("clamps purchase qty by max and remaining", () => {
|
|
87
|
+
expect(
|
|
88
|
+
clampCoatCheckPurchaseQty({
|
|
89
|
+
requested: 5,
|
|
90
|
+
maxPerPurchase: 2,
|
|
91
|
+
remainingSlots: 10,
|
|
92
|
+
})
|
|
93
|
+
).toBe(2);
|
|
94
|
+
expect(
|
|
95
|
+
clampCoatCheckPurchaseQty({
|
|
96
|
+
requested: 5,
|
|
97
|
+
maxPerPurchase: 10,
|
|
98
|
+
remainingSlots: 1,
|
|
99
|
+
})
|
|
100
|
+
).toBe(1);
|
|
101
|
+
expect(
|
|
102
|
+
clampCoatCheckPurchaseQty({
|
|
103
|
+
requested: -3,
|
|
104
|
+
maxPerPurchase: 2,
|
|
105
|
+
remainingSlots: null,
|
|
106
|
+
})
|
|
107
|
+
).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
COAT_CHECK_ENTITLEMENT_STATUSES,
|
|
3
|
+
COAT_CHECK_QR_TYPE,
|
|
4
|
+
COAT_CHECK_SCAN_OUTCOMES,
|
|
5
|
+
COAT_CHECK_SLOT_OCCUPYING_STATUSES,
|
|
6
|
+
type CoatCheckEntitlementStatusName,
|
|
7
|
+
type CoatCheckScanOutcome,
|
|
8
|
+
} from "../definitions.js";
|
|
9
|
+
|
|
10
|
+
export type CoatCheckQrPayload = {
|
|
11
|
+
type: typeof COAT_CHECK_QR_TYPE;
|
|
12
|
+
eventId: string;
|
|
13
|
+
entitlementId: string;
|
|
14
|
+
qrToken: string;
|
|
15
|
+
timestamp: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function encodeCoatCheckQrPayload(args: {
|
|
19
|
+
eventId: string;
|
|
20
|
+
entitlementId: string;
|
|
21
|
+
qrToken: string;
|
|
22
|
+
}): string {
|
|
23
|
+
const payload: CoatCheckQrPayload = {
|
|
24
|
+
type: COAT_CHECK_QR_TYPE,
|
|
25
|
+
eventId: args.eventId,
|
|
26
|
+
entitlementId: args.entitlementId,
|
|
27
|
+
qrToken: args.qrToken,
|
|
28
|
+
timestamp: Date.now(),
|
|
29
|
+
};
|
|
30
|
+
return JSON.stringify(payload);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseCoatCheckQrPayload(
|
|
34
|
+
raw: string
|
|
35
|
+
): CoatCheckQrPayload | null {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(raw) as Partial<CoatCheckQrPayload>;
|
|
38
|
+
if (
|
|
39
|
+
parsed?.type !== COAT_CHECK_QR_TYPE ||
|
|
40
|
+
typeof parsed.eventId !== "string" ||
|
|
41
|
+
typeof parsed.entitlementId !== "string" ||
|
|
42
|
+
typeof parsed.qrToken !== "string"
|
|
43
|
+
) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
type: COAT_CHECK_QR_TYPE,
|
|
48
|
+
eventId: parsed.eventId,
|
|
49
|
+
entitlementId: parsed.entitlementId,
|
|
50
|
+
qrToken: parsed.qrToken,
|
|
51
|
+
timestamp:
|
|
52
|
+
typeof parsed.timestamp === "number" ? parsed.timestamp : Date.now(),
|
|
53
|
+
};
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function coatCheckScanOutcomeForStatus(
|
|
60
|
+
status: CoatCheckEntitlementStatusName
|
|
61
|
+
): CoatCheckScanOutcome {
|
|
62
|
+
switch (status) {
|
|
63
|
+
case "Paid":
|
|
64
|
+
return "ready_for_dropoff";
|
|
65
|
+
case "CheckedIn":
|
|
66
|
+
return "ready_for_claim";
|
|
67
|
+
case "Claimed":
|
|
68
|
+
return "already_claimed";
|
|
69
|
+
case "Refunded":
|
|
70
|
+
case "Cancelled":
|
|
71
|
+
return "cancelled_or_refunded";
|
|
72
|
+
default:
|
|
73
|
+
return "invalid_or_expired";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function canTransitionCoatCheckStatus(
|
|
78
|
+
from: CoatCheckEntitlementStatusName,
|
|
79
|
+
to: CoatCheckEntitlementStatusName
|
|
80
|
+
): boolean {
|
|
81
|
+
if (from === to) return false;
|
|
82
|
+
if (from === "Paid" && to === "CheckedIn") return true;
|
|
83
|
+
if (from === "CheckedIn" && to === "Claimed") return true;
|
|
84
|
+
if (
|
|
85
|
+
(from === "Paid" || from === "CheckedIn") &&
|
|
86
|
+
(to === "Refunded" || to === "Cancelled")
|
|
87
|
+
) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function isCoatCheckSlotOccupyingStatus(
|
|
94
|
+
status: string
|
|
95
|
+
): status is CoatCheckEntitlementStatusName {
|
|
96
|
+
return (COAT_CHECK_SLOT_OCCUPYING_STATUSES as readonly string[]).includes(
|
|
97
|
+
status
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function isCoatCheckEntitlementStatus(
|
|
102
|
+
status: string
|
|
103
|
+
): status is CoatCheckEntitlementStatusName {
|
|
104
|
+
return (COAT_CHECK_ENTITLEMENT_STATUSES as readonly string[]).includes(status);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function assertCoatCheckScanOutcome(
|
|
108
|
+
outcome: string
|
|
109
|
+
): outcome is CoatCheckScanOutcome {
|
|
110
|
+
return (COAT_CHECK_SCAN_OUTCOMES as readonly string[]).includes(outcome);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Remaining slots; null capacity = unlimited (Infinity for math). */
|
|
114
|
+
export function remainingCoatCheckSlots(args: {
|
|
115
|
+
capacity: number | null | undefined;
|
|
116
|
+
occupiedCount: number;
|
|
117
|
+
}): number | null {
|
|
118
|
+
if (args.capacity == null) return null;
|
|
119
|
+
return Math.max(0, args.capacity - args.occupiedCount);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function clampCoatCheckPurchaseQty(args: {
|
|
123
|
+
requested: number;
|
|
124
|
+
maxPerPurchase: number;
|
|
125
|
+
remainingSlots: number | null;
|
|
126
|
+
}): number {
|
|
127
|
+
const maxPer = Math.max(1, args.maxPerPurchase || 1);
|
|
128
|
+
let qty = Math.max(0, Math.floor(args.requested));
|
|
129
|
+
qty = Math.min(qty, maxPer);
|
|
130
|
+
if (args.remainingSlots != null) {
|
|
131
|
+
qty = Math.min(qty, args.remainingSlots);
|
|
132
|
+
}
|
|
133
|
+
return qty;
|
|
134
|
+
}
|
package/src/utils/slugUtils.ts
CHANGED
|
@@ -53,10 +53,9 @@ export function getPublicBashDetailPath(
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* link works for people who aren't signed in yet.
|
|
56
|
+
* Path to Your Memories (private vault) for a bash — nested under bash detail
|
|
57
|
+
* (`/bash/{id}-{slug}/memories`). Requires sign-in + eligibility; not a public
|
|
58
|
+
* unauthenticated recap page.
|
|
60
59
|
*/
|
|
61
60
|
export function getPublicBashMemoriesPath(
|
|
62
61
|
bashEventId: string,
|