@bash-app/bash-common 30.331.0 → 30.334.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.
Files changed (59) hide show
  1. package/dist/__tests__/causeTags.test.d.ts +2 -0
  2. package/dist/__tests__/causeTags.test.d.ts.map +1 -0
  3. package/dist/__tests__/causeTags.test.js +94 -0
  4. package/dist/__tests__/causeTags.test.js.map +1 -0
  5. package/dist/__tests__/definitionsHelpers.test.js +6 -2
  6. package/dist/__tests__/definitionsHelpers.test.js.map +1 -1
  7. package/dist/__tests__/hostCrmGrowthPack.test.js +12 -2
  8. package/dist/__tests__/hostCrmGrowthPack.test.js.map +1 -1
  9. package/dist/__tests__/hostPromoter.test.d.ts +2 -0
  10. package/dist/__tests__/hostPromoter.test.d.ts.map +1 -0
  11. package/dist/__tests__/hostPromoter.test.js +79 -0
  12. package/dist/__tests__/hostPromoter.test.js.map +1 -0
  13. package/dist/causeTags.d.ts +42 -0
  14. package/dist/causeTags.d.ts.map +1 -0
  15. package/dist/causeTags.js +173 -0
  16. package/dist/causeTags.js.map +1 -0
  17. package/dist/definitions.d.ts +38 -5
  18. package/dist/definitions.d.ts.map +1 -1
  19. package/dist/definitions.js +47 -7
  20. package/dist/definitions.js.map +1 -1
  21. package/dist/hostCrmAutomation.d.ts +12 -2
  22. package/dist/hostCrmAutomation.d.ts.map +1 -1
  23. package/dist/hostCrmAutomation.js +16 -2
  24. package/dist/hostCrmAutomation.js.map +1 -1
  25. package/dist/index.d.ts +2 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/sms/smsTemplates.d.ts +6 -1
  30. package/dist/sms/smsTemplates.d.ts.map +1 -1
  31. package/dist/sms/smsTemplates.js +1 -0
  32. package/dist/sms/smsTemplates.js.map +1 -1
  33. package/dist/utils/__tests__/coatCheckUtils.test.d.ts +2 -0
  34. package/dist/utils/__tests__/coatCheckUtils.test.d.ts.map +1 -0
  35. package/dist/utils/__tests__/coatCheckUtils.test.js +76 -0
  36. package/dist/utils/__tests__/coatCheckUtils.test.js.map +1 -0
  37. package/dist/utils/coatCheckUtils.d.ts +30 -0
  38. package/dist/utils/coatCheckUtils.d.ts.map +1 -0
  39. package/dist/utils/coatCheckUtils.js +85 -0
  40. package/dist/utils/coatCheckUtils.js.map +1 -0
  41. package/dist/utils/slugUtils.d.ts +3 -4
  42. package/dist/utils/slugUtils.d.ts.map +1 -1
  43. package/dist/utils/slugUtils.js +3 -4
  44. package/dist/utils/slugUtils.js.map +1 -1
  45. package/package.json +1 -1
  46. package/prisma/COMPREHENSIVE-MIGRATION-README.md +1 -1
  47. package/prisma/schema.prisma +431 -3
  48. package/src/__tests__/causeTags.test.ts +143 -0
  49. package/src/__tests__/definitionsHelpers.test.ts +11 -1
  50. package/src/__tests__/hostCrmGrowthPack.test.ts +14 -1
  51. package/src/__tests__/hostPromoter.test.ts +133 -0
  52. package/src/causeTags.ts +243 -0
  53. package/src/definitions.ts +77 -14
  54. package/src/hostCrmAutomation.ts +25 -2
  55. package/src/index.ts +2 -0
  56. package/src/sms/smsTemplates.ts +6 -0
  57. package/src/utils/__tests__/coatCheckUtils.test.ts +110 -0
  58. package/src/utils/coatCheckUtils.ts +134 -0
  59. package/src/utils/slugUtils.ts +3 -4
@@ -11,6 +11,7 @@ import {
11
11
  TICKET_PURCHASE_TYPE_COMP,
12
12
  TICKET_PURCHASE_TYPE_PAY_AT_DOOR,
13
13
  COMPLEMENTARY_SERVICE_TYPE,
14
+ complementaryServiceTypeFor,
14
15
  ServiceTypes,
15
16
  } from "../definitions.js";
16
17
 
@@ -145,7 +146,16 @@ describe("definitions helpers", () => {
145
146
  ServiceTypes.Vendors
146
147
  );
147
148
  expect(
148
- COMPLEMENTARY_SERVICE_TYPE[ServiceTypes.EntertainmentServices]
149
+ ServiceTypes.EntertainmentServices in COMPLEMENTARY_SERVICE_TYPE
150
+ ).toBe(false);
151
+ });
152
+
153
+ test("complementaryServiceTypeFor returns undefined for unsupported types", () => {
154
+ expect(complementaryServiceTypeFor(ServiceTypes.Vendors)).toBe(
155
+ ServiceTypes.EventServices
156
+ );
157
+ expect(
158
+ complementaryServiceTypeFor(ServiceTypes.EntertainmentServices)
149
159
  ).toBeUndefined();
150
160
  });
151
161
  });
@@ -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 and post-event pack rows", () => {
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
  });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * hostPromoter — kind/subtype mapping, hireable guards, deep links, influencer tiers.
3
+ */
4
+ import { describe, expect, test } from "@jest/globals";
5
+ import {
6
+ HOST_PROMOTER_KIND,
7
+ buildHireStreetTeamServiceDeepLink,
8
+ eventServiceSubTypeFromHostPromoterKind,
9
+ hostPromoterKindFromEventServiceSubType,
10
+ isHostPromoterHireableKind,
11
+ isHostPromoterInfluencerTier,
12
+ isHostPromoterSyncServiceSubType,
13
+ } from "../hostPromoter.js";
14
+
15
+ describe("hostPromoterKindFromEventServiceSubType", () => {
16
+ test("maps known EventService subtypes to host promoter kinds", () => {
17
+ expect(hostPromoterKindFromEventServiceSubType("Promoter")).toBe(
18
+ HOST_PROMOTER_KIND.EventPromoter
19
+ );
20
+ expect(hostPromoterKindFromEventServiceSubType("Influencer")).toBe(
21
+ HOST_PROMOTER_KIND.Influencer
22
+ );
23
+ expect(hostPromoterKindFromEventServiceSubType("ContentCreator")).toBe(
24
+ HOST_PROMOTER_KIND.ContentCreator
25
+ );
26
+ expect(hostPromoterKindFromEventServiceSubType("Model")).toBe(
27
+ HOST_PROMOTER_KIND.Model
28
+ );
29
+ expect(hostPromoterKindFromEventServiceSubType("BrandAmbassador")).toBe(
30
+ HOST_PROMOTER_KIND.BrandAmbassador
31
+ );
32
+ });
33
+
34
+ test("returns null for unknown or empty subtypes", () => {
35
+ expect(hostPromoterKindFromEventServiceSubType("DJ")).toBeNull();
36
+ expect(hostPromoterKindFromEventServiceSubType(null)).toBeNull();
37
+ expect(hostPromoterKindFromEventServiceSubType(undefined)).toBeNull();
38
+ });
39
+ });
40
+
41
+ describe("eventServiceSubTypeFromHostPromoterKind", () => {
42
+ test("maps hireable kinds back to EventService subtypes", () => {
43
+ expect(
44
+ eventServiceSubTypeFromHostPromoterKind(HOST_PROMOTER_KIND.EventPromoter)
45
+ ).toBe("Promoter");
46
+ expect(
47
+ eventServiceSubTypeFromHostPromoterKind(HOST_PROMOTER_KIND.Influencer)
48
+ ).toBe("Influencer");
49
+ expect(
50
+ eventServiceSubTypeFromHostPromoterKind(HOST_PROMOTER_KIND.ContentCreator)
51
+ ).toBe("ContentCreator");
52
+ expect(
53
+ eventServiceSubTypeFromHostPromoterKind(HOST_PROMOTER_KIND.Model)
54
+ ).toBe("Model");
55
+ expect(
56
+ eventServiceSubTypeFromHostPromoterKind(
57
+ HOST_PROMOTER_KIND.BrandAmbassador
58
+ )
59
+ ).toBe("BrandAmbassador");
60
+ });
61
+
62
+ test("PromoCode and unknown kinds have no hireable subtype", () => {
63
+ expect(
64
+ eventServiceSubTypeFromHostPromoterKind(HOST_PROMOTER_KIND.PromoCode)
65
+ ).toBeNull();
66
+ expect(eventServiceSubTypeFromHostPromoterKind("Nope")).toBeNull();
67
+ expect(eventServiceSubTypeFromHostPromoterKind(null)).toBeNull();
68
+ });
69
+ });
70
+
71
+ describe("isHostPromoterHireableKind / isHostPromoterSyncServiceSubType", () => {
72
+ test("hireable kinds exclude PromoCode", () => {
73
+ expect(isHostPromoterHireableKind(HOST_PROMOTER_KIND.Influencer)).toBe(
74
+ true
75
+ );
76
+ expect(isHostPromoterHireableKind(HOST_PROMOTER_KIND.PromoCode)).toBe(
77
+ false
78
+ );
79
+ expect(isHostPromoterHireableKind(null)).toBe(false);
80
+ expect(isHostPromoterHireableKind("")).toBe(false);
81
+ });
82
+
83
+ test("sync subtypes match marketplace EventService values", () => {
84
+ expect(isHostPromoterSyncServiceSubType("Promoter")).toBe(true);
85
+ expect(isHostPromoterSyncServiceSubType("PromoCode")).toBe(false);
86
+ expect(isHostPromoterSyncServiceSubType(undefined)).toBe(false);
87
+ });
88
+ });
89
+
90
+ describe("buildHireStreetTeamServiceDeepLink", () => {
91
+ test("builds service detail path without bash query", () => {
92
+ expect(
93
+ buildHireStreetTeamServiceDeepLink({
94
+ serviceId: "svc-1",
95
+ eventServiceId: "es-1",
96
+ })
97
+ ).toBe("/services/svc-1/EventServices/es-1");
98
+ });
99
+
100
+ test("appends encoded bashEventId when provided", () => {
101
+ expect(
102
+ buildHireStreetTeamServiceDeepLink({
103
+ serviceId: "svc-1",
104
+ eventServiceId: "es-1",
105
+ bashEventId: "bash/with spaces",
106
+ })
107
+ ).toBe(
108
+ "/services/svc-1/EventServices/es-1?bashEventId=bash%2Fwith%20spaces"
109
+ );
110
+ });
111
+
112
+ test("omits query when bashEventId is null", () => {
113
+ expect(
114
+ buildHireStreetTeamServiceDeepLink({
115
+ serviceId: "svc-1",
116
+ eventServiceId: "es-1",
117
+ bashEventId: null,
118
+ })
119
+ ).toBe("/services/svc-1/EventServices/es-1");
120
+ });
121
+ });
122
+
123
+ describe("isHostPromoterInfluencerTier", () => {
124
+ test("accepts known audience tiers", () => {
125
+ expect(isHostPromoterInfluencerTier("Nano")).toBe(true);
126
+ expect(isHostPromoterInfluencerTier("Mega")).toBe(true);
127
+ });
128
+
129
+ test("rejects empty and unknown values", () => {
130
+ expect(isHostPromoterInfluencerTier(null)).toBe(false);
131
+ expect(isHostPromoterInfluencerTier("Huge")).toBe(false);
132
+ });
133
+ });
@@ -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
+ }
@@ -42,31 +42,48 @@ import type { UtmFields } from "./utmAttribution.js";
42
42
  /**
43
43
  * Prisma's `index-browser` bundle (used by Vite in bash-app) omits some enums as runtime
44
44
  * values — importing them from `@prisma/client` yields `undefined` and crashes on first
45
- * property access. These mirrors match the Prisma schema / full client enum strings.
46
- */
47
- /**
45
+ * property access. Incomplete Jest `@prisma/client` mocks hit the same landmine.
46
+ * These mirrors match the Prisma schema / full client enum strings.
47
+ *
48
48
  * "Also list as [complementary type]" — a one-time identity-field prefill copy
49
49
  * (see Service.complementaryOfServiceId), not an ongoing synced link. Scoped to
50
50
  * the Vendors <-> EventServices pairing only (e.g. a food truck that vends
51
51
  * publicly at festivals and is also paid a flat fee at private weddings).
52
52
  */
53
- export const COMPLEMENTARY_SERVICE_TYPE: Partial<
54
- Record<ServiceTypes, ServiceTypes>
55
- > = {
56
- [ServiceTypes.Vendors]: ServiceTypes.EventServices,
57
- [ServiceTypes.EventServices]: ServiceTypes.Vendors,
58
- };
53
+ export const COMPLEMENTARY_SERVICE_TYPE = {
54
+ Vendors: "EventServices",
55
+ EventServices: "Vendors",
56
+ } as const satisfies Partial<Record<ServiceTypes, ServiceTypes>>;
57
+
58
+ export type ComplementaryServiceSourceType = keyof typeof COMPLEMENTARY_SERVICE_TYPE;
59
+
60
+ export function isComplementaryServiceSourceType(
61
+ serviceType: ServiceTypes
62
+ ): serviceType is ComplementaryServiceSourceType {
63
+ // String compares — avoid ServiceTypes.* enum access (undefined in Vite's
64
+ // @prisma/client index-browser bundle).
65
+ return serviceType === "Vendors" || serviceType === "EventServices";
66
+ }
67
+
68
+ /** Vendors ↔ EventServices only; other types return undefined. */
69
+ export function complementaryServiceTypeFor(
70
+ serviceType: ServiceTypes
71
+ ): ServiceTypes | undefined {
72
+ if (!isComplementaryServiceSourceType(serviceType)) {
73
+ return undefined;
74
+ }
75
+ return COMPLEMENTARY_SERVICE_TYPE[serviceType];
76
+ }
59
77
 
60
78
  /**
61
79
  * Human-readable label for a ServiceTypes value — the raw enum ("EventServices")
62
80
  * is not fit for display copy. Only covers the values COMPLEMENTARY_SERVICE_TYPE
63
81
  * can produce; extend as more callers need display labels for other types.
64
82
  */
65
- export const SERVICE_TYPE_DISPLAY_LABEL: Partial<Record<ServiceTypes, string>> =
66
- {
67
- [ServiceTypes.Vendors]: "Vendor",
68
- [ServiceTypes.EventServices]: "Event Service",
69
- };
83
+ export const SERVICE_TYPE_DISPLAY_LABEL = {
84
+ Vendors: "Vendor",
85
+ EventServices: "Event Service",
86
+ } as const satisfies Partial<Record<ServiceTypes, string>>;
70
87
 
71
88
  /** Picks "a"/"an" for the given word, e.g. for "a Vendor" vs "an Event Service". */
72
89
  export function articleFor(word: string): "a" | "an" {
@@ -212,6 +229,45 @@ export const CHECKOUT_PURPOSE_DOOR_SQUARE_SALE = "door_square_sale" as const;
212
229
  /** Walk-up / PAD door sale via Square Terminal (card present). */
213
230
  export const CHECKOUT_PURPOSE_DOOR_SQUARE_TERMINAL_SALE =
214
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"];
215
271
 
216
272
  /** Which processor the door app uses for card capture (host preferred + ready). */
217
273
  export type DoorPaymentRail = "STRIPE" | "SQUARE";
@@ -1021,6 +1077,13 @@ export interface StripeCreateBashEventTicketsCheckoutSessionArgs extends UtmFiel
1021
1077
 
1022
1078
  /** EventGroup id when purchasing a group package for the crew */
1023
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;
1024
1087
  }
1025
1088
 
1026
1089
  export interface StripeCreateBashEventDonationCheckoutSessionArgs extends UtmFields {
@@ -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
- /** Post-event cron substitutes {{eventTitle}}, {{firstName}}, {{bashFeedUrl}}. */
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. Hope to see you at the next one!\n\n{{bashFeedUrl}}";
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";
@@ -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
  };