@bash-app/bash-common 30.386.0 → 30.391.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 (36) hide show
  1. package/dist/__tests__/scenePulse.test.d.ts +2 -0
  2. package/dist/__tests__/scenePulse.test.d.ts.map +1 -0
  3. package/dist/__tests__/scenePulse.test.js +52 -0
  4. package/dist/__tests__/scenePulse.test.js.map +1 -0
  5. package/dist/definitions.d.ts +5 -0
  6. package/dist/definitions.d.ts.map +1 -1
  7. package/dist/definitions.js.map +1 -1
  8. package/dist/extendedSchemas.d.ts +7 -0
  9. package/dist/extendedSchemas.d.ts.map +1 -1
  10. package/dist/extendedSchemas.js +8 -0
  11. package/dist/extendedSchemas.js.map +1 -1
  12. package/dist/index.d.ts +3 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +3 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/scenePulse.d.ts +66 -0
  17. package/dist/scenePulse.d.ts.map +1 -0
  18. package/dist/scenePulse.js +54 -0
  19. package/dist/scenePulse.js.map +1 -0
  20. package/dist/utils/service/__tests__/foodAndBeverageQuote.test.d.ts +2 -0
  21. package/dist/utils/service/__tests__/foodAndBeverageQuote.test.d.ts.map +1 -0
  22. package/dist/utils/service/__tests__/foodAndBeverageQuote.test.js +353 -0
  23. package/dist/utils/service/__tests__/foodAndBeverageQuote.test.js.map +1 -0
  24. package/dist/utils/service/foodAndBeverageQuote.d.ts +171 -0
  25. package/dist/utils/service/foodAndBeverageQuote.d.ts.map +1 -0
  26. package/dist/utils/service/foodAndBeverageQuote.js +467 -0
  27. package/dist/utils/service/foodAndBeverageQuote.js.map +1 -0
  28. package/package.json +1 -1
  29. package/prisma/schema.prisma +45 -0
  30. package/src/__tests__/scenePulse.test.ts +69 -0
  31. package/src/definitions.ts +5 -0
  32. package/src/extendedSchemas.ts +8 -0
  33. package/src/index.ts +6 -0
  34. package/src/scenePulse.ts +113 -0
  35. package/src/utils/service/__tests__/foodAndBeverageQuote.test.ts +502 -0
  36. package/src/utils/service/foodAndBeverageQuote.ts +613 -0
package/src/index.ts CHANGED
@@ -36,6 +36,7 @@ export * from "./accessScreening.js";
36
36
  export * from "./applicationFunnel.js";
37
37
  export * from "./hostCrmSegments.js";
38
38
  export * from "./hostCrmCrowdPulse.js";
39
+ export * from "./scenePulse.js";
39
40
  export * from "./hostCrmLastBashProof.js";
40
41
  export * from "./hostCrmInsights.js";
41
42
  export * from "./hostCrmAutomation.js";
@@ -107,6 +108,8 @@ export {
107
108
  BrandStrategistFormat,
108
109
  CPAsFormat,
109
110
  CateringFormat,
111
+ CelebrationCakeFormat,
112
+ GrazingTableFormat,
110
113
  CelebrityAppearanceFormat,
111
114
  CenterpiecesFormat,
112
115
  ChairsFormat,
@@ -189,6 +192,7 @@ export {
189
192
  NotificationPriority,
190
193
  NotificationType,
191
194
  Occupation,
195
+ OfficiantFormat,
192
196
  OfferClaimStatus,
193
197
  OfferDiscountType,
194
198
  OrganizationArtistInvitationStatus,
@@ -225,6 +229,7 @@ export {
225
229
  RecurringFrequency,
226
230
  RedemptionMethod,
227
231
  RentalEquipmentSubType,
232
+ RestroomTrailerFormat,
228
233
  ReportStatus,
229
234
  ReportingFormat,
230
235
  RewardReportStatus,
@@ -429,6 +434,7 @@ export * from "./utils/mathUtils.js";
429
434
  export * from "./utils/birthdayExperienceQuality.js";
430
435
  export * from "./utils/service/apiServiceBookingApiUtils.js";
431
436
  export * from "./utils/service/frontendServiceBookingUtils.js";
437
+ export * from "./utils/service/foodAndBeverageQuote.js";
432
438
  export * from "./utils/service/serviceBookingStatusUtils.js";
433
439
  export * from "./utils/service/serviceDBUtils.js";
434
440
  export * from "./utils/service/serviceRateUtils.js";
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Community Builder scene pulse — city / region / world aggregates.
3
+ * You (Crowd Pulse) stays host ops; these scopes are public scene energy + platform proof.
4
+ */
5
+
6
+ export const SCENE_PULSE_SCOPES = ["city", "region", "world"] as const;
7
+
8
+ export type ScenePulseScope = (typeof SCENE_PULSE_SCOPES)[number];
9
+
10
+ export type ScenePulseGeo = {
11
+ city: string | null;
12
+ state: string | null;
13
+ country: string | null;
14
+ /** Short label for copy ("Salt Lake City", "Utah", "World"). */
15
+ label: string;
16
+ timeZone: string;
17
+ };
18
+
19
+ export type ScenePulseEnergy = {
20
+ /** Public rooms currently live. */
21
+ liveNow: number;
22
+ /** Unique Going RSVPs + ticketed people on live + tonight + this weekend. */
23
+ going: number;
24
+ tonightBashes: number;
25
+ tonightGoing: number;
26
+ weekendBashes: number;
27
+ weekendGoing: number;
28
+ /** Unique people who RSVP'd Going or paid for a ticket in the last 7 days. */
29
+ newThisWeek: number;
30
+ /**
31
+ * Distinct live/tonight/weekend public bashes with a lineup slot or a
32
+ * host-spotlighted featured guest (names in the room — not CRM).
33
+ */
34
+ namesInTheRoom: number;
35
+ };
36
+
37
+ /** World-only cumulative credibility. Null on city/region. */
38
+ export type ScenePulseWorldCred = {
39
+ totalPeople: number;
40
+ bashesHosted: number;
41
+ ticketsSold: number;
42
+ gmvCents: number;
43
+ citiesRepresented: number;
44
+ regionsRepresented: number;
45
+ };
46
+
47
+ export type ScenePulse = {
48
+ scope: ScenePulseScope;
49
+ geo: ScenePulseGeo | null;
50
+ /** True when city (or region) can't be resolved from profile or hosted bashes. */
51
+ needsLocation: boolean;
52
+ energy: ScenePulseEnergy;
53
+ world: ScenePulseWorldCred | null;
54
+ };
55
+
56
+ /** Optional city/state used to pulse a scene other than the host's profile. */
57
+ export type ScenePulseGeoOverride = {
58
+ city: string | null;
59
+ state: string | null;
60
+ };
61
+
62
+ const MAX_GEO_CITY_CHARS = 80;
63
+ const MAX_GEO_STATE_CHARS = 40;
64
+
65
+ function normalizeGeoPart(raw: unknown, maxChars: number): string | null {
66
+ if (typeof raw !== "string") return null;
67
+ const trimmed = raw.replace(/\s+/g, " ").trim();
68
+ if (!trimmed) return null;
69
+ return trimmed.slice(0, maxChars);
70
+ }
71
+
72
+ /**
73
+ * Blank city+state → null (use profile). Otherwise a trimmed override.
74
+ */
75
+ export function normalizeScenePulseGeoOverride(input: {
76
+ city?: unknown;
77
+ state?: unknown;
78
+ }): ScenePulseGeoOverride | null {
79
+ const city = normalizeGeoPart(input.city, MAX_GEO_CITY_CHARS);
80
+ const state = normalizeGeoPart(input.state, MAX_GEO_STATE_CHARS);
81
+ if (!city && !state) return null;
82
+ return { city, state };
83
+ }
84
+
85
+ export function emptyScenePulseEnergy(): ScenePulseEnergy {
86
+ return {
87
+ liveNow: 0,
88
+ going: 0,
89
+ tonightBashes: 0,
90
+ tonightGoing: 0,
91
+ weekendBashes: 0,
92
+ weekendGoing: 0,
93
+ newThisWeek: 0,
94
+ namesInTheRoom: 0,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Missing / blank → city. Invalid string → null (caller should 400).
100
+ */
101
+ export function parseScenePulseScope(raw: unknown): ScenePulseScope | null {
102
+ if (raw == null || raw === "") return "city";
103
+ if (typeof raw !== "string") return null;
104
+ const normalized = raw.trim().toLowerCase();
105
+ if (
106
+ normalized === "city" ||
107
+ normalized === "region" ||
108
+ normalized === "world"
109
+ ) {
110
+ return normalized;
111
+ }
112
+ return null;
113
+ }
@@ -0,0 +1,502 @@
1
+ import {
2
+ CELEBRATION_CAKE_FLAVORS,
3
+ EVENT_SERVICE_FULFILLMENT_MODES,
4
+ celebrationCakeStartingPriceLabel,
5
+ encodeCakeOption,
6
+ filterCelebrationCakeFulfillmentModes,
7
+ filterFulfillmentModes,
8
+ foodAndBeverageLeadTimeLabel,
9
+ foodAndBeverageQuoteCents,
10
+ foodAndBeverageRateSummary,
11
+ getFoodAndBeverageMenuCategories,
12
+ isBelowGuestMinimum,
13
+ GRAZING_TABLE_STYLES,
14
+ encodeGrazingOption,
15
+ grazingTableStartingPriceLabel,
16
+ isCelebrationCakeEventService,
17
+ isCelebrationCakeFulfillmentMode,
18
+ isFoodAndBeverageEventService,
19
+ isFulfillmentMode,
20
+ isGrazingTableEventService,
21
+ isOfficiantEventService,
22
+ isPackagePricedFoodEventService,
23
+ isPerGuestFoodAndBeverageEventService,
24
+ packagePricedFoodStartingPriceLabel,
25
+ parseCakeOptions,
26
+ parseGrazingOptions,
27
+ quotedGuestCount,
28
+ serializeCakeOptions,
29
+ serializeGrazingOptions,
30
+ } from "../foodAndBeverageQuote.js";
31
+
32
+ describe("isFoodAndBeverageEventService", () => {
33
+ it("returns true for FoodAndBeverage eventServiceType", () => {
34
+ expect(
35
+ isFoodAndBeverageEventService({ eventServiceType: "FoodAndBeverage" })
36
+ ).toBe(true);
37
+ });
38
+
39
+ it("returns true for canonical subtype even without eventServiceType", () => {
40
+ expect(
41
+ isFoodAndBeverageEventService({ eventServiceSubType: "Caterer" })
42
+ ).toBe(true);
43
+ expect(
44
+ isFoodAndBeverageEventService({ eventServiceSubType: "DessertBar" })
45
+ ).toBe(true);
46
+ expect(
47
+ isFoodAndBeverageEventService({ eventServiceSubType: "CelebrationCake" })
48
+ ).toBe(true);
49
+ expect(
50
+ isFoodAndBeverageEventService({ eventServiceSubType: "GrazingTable" })
51
+ ).toBe(true);
52
+ });
53
+
54
+ it("returns true for legacy/alias subtypes", () => {
55
+ expect(
56
+ isFoodAndBeverageEventService({ eventServiceSubType: "PrivateChef" })
57
+ ).toBe(true);
58
+ expect(
59
+ isFoodAndBeverageEventService({ eventServiceSubType: "Bartender" })
60
+ ).toBe(true);
61
+ });
62
+
63
+ it("returns false for unrelated types", () => {
64
+ expect(
65
+ isFoodAndBeverageEventService({ eventServiceType: "EventPlanningAndManagement" })
66
+ ).toBe(false);
67
+ expect(
68
+ isFoodAndBeverageEventService({ eventServiceSubType: "EventPlanner" })
69
+ ).toBe(false);
70
+ });
71
+
72
+ it("returns false when both are null/undefined", () => {
73
+ expect(isFoodAndBeverageEventService({})).toBe(false);
74
+ expect(isFoodAndBeverageEventService({ eventServiceType: null, eventServiceSubType: null })).toBe(false);
75
+ });
76
+ });
77
+
78
+ describe("EVENT_SERVICE_FULFILLMENT_MODES", () => {
79
+ it("contains Pickup, Delivery, and FullService", () => {
80
+ expect(EVENT_SERVICE_FULFILLMENT_MODES).toContain("Pickup");
81
+ expect(EVENT_SERVICE_FULFILLMENT_MODES).toContain("Delivery");
82
+ expect(EVENT_SERVICE_FULFILLMENT_MODES).toContain("FullService");
83
+ expect(EVENT_SERVICE_FULFILLMENT_MODES).toHaveLength(3);
84
+ });
85
+ });
86
+
87
+ describe("getFoodAndBeverageMenuCategories", () => {
88
+ it("returns Caterer chips for Caterer subtype", () => {
89
+ const chips = getFoodAndBeverageMenuCategories("Caterer");
90
+ expect(chips).toContain("Salads");
91
+ expect(chips).toContain("Mains");
92
+ expect(chips).toContain("Sides");
93
+ });
94
+
95
+ it("returns DessertBar chips for DessertBar subtype", () => {
96
+ const chips = getFoodAndBeverageMenuCategories("DessertBar");
97
+ expect(chips).toContain("Cakes");
98
+ expect(chips).toContain("Cupcakes");
99
+ });
100
+
101
+ it("falls back to generic chips for unknown subtypes", () => {
102
+ const chips = getFoodAndBeverageMenuCategories("SomeUnknownType");
103
+ expect(chips.length).toBeGreaterThan(0);
104
+ expect(chips).toContain("Mains");
105
+ });
106
+
107
+ it("returns generic chips for null subtype", () => {
108
+ const chips = getFoodAndBeverageMenuCategories(null);
109
+ expect(chips.length).toBeGreaterThan(0);
110
+ });
111
+ });
112
+
113
+ describe("quotedGuestCount", () => {
114
+ it("returns headcount when no minimum is set", () => {
115
+ expect(quotedGuestCount(80)).toBe(80);
116
+ expect(quotedGuestCount(80, null)).toBe(80);
117
+ expect(quotedGuestCount(80, 0)).toBe(80);
118
+ });
119
+
120
+ it("returns headcount when above minimum", () => {
121
+ expect(quotedGuestCount(80, 25)).toBe(80);
122
+ });
123
+
124
+ it("floors to minimum when headcount is below", () => {
125
+ expect(quotedGuestCount(10, 25)).toBe(25);
126
+ expect(quotedGuestCount(0, 25)).toBe(25);
127
+ });
128
+
129
+ it("returns minimum exactly when headcount equals minimum", () => {
130
+ expect(quotedGuestCount(25, 25)).toBe(25);
131
+ });
132
+
133
+ it("never returns a negative count", () => {
134
+ expect(quotedGuestCount(-5)).toBe(0);
135
+ });
136
+ });
137
+
138
+ describe("foodAndBeverageQuoteCents", () => {
139
+ it("calculates basic per-guest price", () => {
140
+ // $18/guest × 100 guests = $1,800
141
+ expect(
142
+ foodAndBeverageQuoteCents({ baseRateCents: 1800, headcount: 100 })
143
+ ).toBe(180000);
144
+ });
145
+
146
+ it("floors headcount at minimumQuantity", () => {
147
+ // $18/guest × min 25 guests; host enters 10 → billed for 25
148
+ expect(
149
+ foodAndBeverageQuoteCents({
150
+ baseRateCents: 1800,
151
+ headcount: 10,
152
+ minimumQuantity: 25,
153
+ })
154
+ ).toBe(1800 * 25);
155
+ });
156
+
157
+ it("does not floor when headcount is above minimum", () => {
158
+ expect(
159
+ foodAndBeverageQuoteCents({
160
+ baseRateCents: 1800,
161
+ headcount: 80,
162
+ minimumQuantity: 25,
163
+ })
164
+ ).toBe(1800 * 80);
165
+ });
166
+
167
+ it("applies minimumChargeCents dollar floor after guest floor", () => {
168
+ // $8/guest × 25 guests (floored from 5) = $200. Min charge $500 → $500.
169
+ expect(
170
+ foodAndBeverageQuoteCents({
171
+ baseRateCents: 800,
172
+ headcount: 5,
173
+ minimumQuantity: 25,
174
+ minimumChargeCents: 50000,
175
+ })
176
+ ).toBe(50000);
177
+ });
178
+
179
+ it("dollar floor does not fire when rate total already exceeds it", () => {
180
+ // $18/guest × 50 guests = $900 > $500 min charge
181
+ expect(
182
+ foodAndBeverageQuoteCents({
183
+ baseRateCents: 1800,
184
+ headcount: 50,
185
+ minimumQuantity: 25,
186
+ minimumChargeCents: 50000,
187
+ })
188
+ ).toBe(1800 * 50);
189
+ });
190
+
191
+ it("returns 0 for zero baseRate", () => {
192
+ expect(
193
+ foodAndBeverageQuoteCents({ baseRateCents: 0, headcount: 100 })
194
+ ).toBe(0);
195
+ });
196
+
197
+ it("returns 0 for negative headcount", () => {
198
+ expect(
199
+ foodAndBeverageQuoteCents({ baseRateCents: 1800, headcount: -1 })
200
+ ).toBe(0);
201
+ });
202
+ });
203
+
204
+ describe("foodAndBeverageRateSummary", () => {
205
+ it("formats without a minimum", () => {
206
+ const result = foodAndBeverageRateSummary(1800);
207
+ expect(result).toBe("$18 / guest");
208
+ });
209
+
210
+ it("formats with a minimum", () => {
211
+ const result = foodAndBeverageRateSummary(1800, 25);
212
+ expect(result).toBe("$18 / guest · min 25 guests");
213
+ });
214
+
215
+ it("formats cents correctly", () => {
216
+ const result = foodAndBeverageRateSummary(1850);
217
+ expect(result).toBe("$18.50 / guest");
218
+ });
219
+ });
220
+
221
+ describe("foodAndBeverageLeadTimeLabel", () => {
222
+ it("returns null for falsy inputs", () => {
223
+ expect(foodAndBeverageLeadTimeLabel(null)).toBeNull();
224
+ expect(foodAndBeverageLeadTimeLabel(0)).toBeNull();
225
+ expect(foodAndBeverageLeadTimeLabel(undefined)).toBeNull();
226
+ });
227
+
228
+ it("formats days when not divisible by 7", () => {
229
+ expect(foodAndBeverageLeadTimeLabel(1)).toBe("Needs 1 day notice");
230
+ expect(foodAndBeverageLeadTimeLabel(5)).toBe("Needs 5 days notice");
231
+ expect(foodAndBeverageLeadTimeLabel(10)).toBe("Needs 10 days notice");
232
+ });
233
+
234
+ it("formats weeks when divisible by 7", () => {
235
+ expect(foodAndBeverageLeadTimeLabel(7)).toBe("Needs 1 week notice");
236
+ expect(foodAndBeverageLeadTimeLabel(14)).toBe("Needs 2 weeks notice");
237
+ });
238
+ });
239
+
240
+ describe("package-priced food detection", () => {
241
+ it("treats cake and grazing as package-priced, not per-guest", () => {
242
+ expect(
243
+ isPackagePricedFoodEventService({ eventServiceSubType: "CelebrationCake" })
244
+ ).toBe(true);
245
+ expect(
246
+ isPackagePricedFoodEventService({ eventServiceSubType: "GrazingTable" })
247
+ ).toBe(true);
248
+ expect(
249
+ isPackagePricedFoodEventService({ eventServiceSubType: "Caterer" })
250
+ ).toBe(false);
251
+ expect(isPackagePricedFoodEventService({})).toBe(false);
252
+ });
253
+
254
+ it("isPerGuestFoodAndBeverage is false for cake and grazing", () => {
255
+ expect(
256
+ isPerGuestFoodAndBeverageEventService({
257
+ eventServiceType: "FoodAndBeverage",
258
+ eventServiceSubType: "CelebrationCake",
259
+ })
260
+ ).toBe(false);
261
+ expect(
262
+ isPerGuestFoodAndBeverageEventService({
263
+ eventServiceType: "FoodAndBeverage",
264
+ eventServiceSubType: "GrazingTable",
265
+ })
266
+ ).toBe(false);
267
+ expect(
268
+ isPerGuestFoodAndBeverageEventService({
269
+ eventServiceType: "FoodAndBeverage",
270
+ eventServiceSubType: "Caterer",
271
+ })
272
+ ).toBe(true);
273
+ });
274
+
275
+ it("isGrazingTableEventService is true only for GrazingTable", () => {
276
+ expect(
277
+ isGrazingTableEventService({ eventServiceSubType: "GrazingTable" })
278
+ ).toBe(true);
279
+ expect(
280
+ isGrazingTableEventService({ eventServiceSubType: "CelebrationCake" })
281
+ ).toBe(false);
282
+ expect(isGrazingTableEventService({})).toBe(false);
283
+ });
284
+
285
+ it("isOfficiantEventService is true only for Officiant", () => {
286
+ expect(isOfficiantEventService({ eventServiceSubType: "Officiant" })).toBe(
287
+ true
288
+ );
289
+ expect(
290
+ isOfficiantEventService({ eventServiceSubType: "HostingSupportOther" })
291
+ ).toBe(false);
292
+ expect(isFoodAndBeverageEventService({ eventServiceSubType: "Officiant" })).toBe(
293
+ false
294
+ );
295
+ });
296
+ });
297
+
298
+ describe("isCelebrationCakeEventService", () => {
299
+ it("returns true only for CelebrationCake", () => {
300
+ expect(
301
+ isCelebrationCakeEventService({ eventServiceSubType: "CelebrationCake" })
302
+ ).toBe(true);
303
+ expect(
304
+ isCelebrationCakeEventService({ eventServiceSubType: "Caterer" })
305
+ ).toBe(false);
306
+ expect(
307
+ isCelebrationCakeEventService({ eventServiceSubType: "DessertBar" })
308
+ ).toBe(false);
309
+ expect(isCelebrationCakeEventService({})).toBe(false);
310
+ expect(isCelebrationCakeEventService({ eventServiceSubType: null })).toBe(
311
+ false
312
+ );
313
+ });
314
+ });
315
+
316
+ describe("parseCakeOptions", () => {
317
+ it("returns empty groups for null or empty input", () => {
318
+ expect(parseCakeOptions(null)).toEqual({
319
+ flavors: [],
320
+ frostings: [],
321
+ fillings: [],
322
+ dietary: [],
323
+ other: [],
324
+ });
325
+ expect(parseCakeOptions([])).toEqual({
326
+ flavors: [],
327
+ frostings: [],
328
+ fillings: [],
329
+ dietary: [],
330
+ other: [],
331
+ });
332
+ });
333
+
334
+ it("splits prefixed chips and keeps unprefixed leftovers", () => {
335
+ const parsed = parseCakeOptions([
336
+ "flavor:Red Velvet",
337
+ "frosting:Chocolate Buttercream",
338
+ "filling:Lemon Custard",
339
+ "dietary:Gluten-Free",
340
+ "Desserts",
341
+ ]);
342
+ expect(parsed.flavors).toEqual(["Red Velvet"]);
343
+ expect(parsed.frostings).toEqual(["Chocolate Buttercream"]);
344
+ expect(parsed.fillings).toEqual(["Lemon Custard"]);
345
+ expect(parsed.dietary).toEqual(["Gluten-Free"]);
346
+ expect(parsed.other).toEqual(["Desserts"]);
347
+ });
348
+
349
+ it("round-trips through serializeCakeOptions", () => {
350
+ const serialized = serializeCakeOptions({
351
+ flavors: ["Vanilla", "Chocolate"],
352
+ frostings: ["Fondant"],
353
+ fillings: [],
354
+ dietary: ["Vegan"],
355
+ });
356
+ expect(serialized).toEqual([
357
+ "flavor:Vanilla",
358
+ "flavor:Chocolate",
359
+ "frosting:Fondant",
360
+ "dietary:Vegan",
361
+ ]);
362
+ expect(parseCakeOptions(serialized)).toEqual({
363
+ flavors: ["Vanilla", "Chocolate"],
364
+ frostings: ["Fondant"],
365
+ fillings: [],
366
+ dietary: ["Vegan"],
367
+ other: [],
368
+ });
369
+ });
370
+
371
+ it("encodeCakeOption uses lowercase prefixes", () => {
372
+ expect(encodeCakeOption("flavor", "Red Velvet")).toBe("flavor:Red Velvet");
373
+ expect(CELEBRATION_CAKE_FLAVORS).toContain("Red Velvet");
374
+ });
375
+ });
376
+
377
+ describe("celebrationCakeStartingPriceLabel", () => {
378
+ it("returns null when there is no starting price", () => {
379
+ expect(celebrationCakeStartingPriceLabel(null)).toBeNull();
380
+ expect(celebrationCakeStartingPriceLabel(0)).toBeNull();
381
+ });
382
+
383
+ it("formats whole-dollar starting prices", () => {
384
+ expect(celebrationCakeStartingPriceLabel(35000)).toBe("Cakes start at $350");
385
+ });
386
+ });
387
+
388
+ describe("grazing options", () => {
389
+ it("returns empty groups for null or empty input", () => {
390
+ expect(parseGrazingOptions(null)).toEqual({
391
+ styles: [],
392
+ proteins: [],
393
+ dietary: [],
394
+ other: [],
395
+ });
396
+ expect(parseGrazingOptions([])).toEqual({
397
+ styles: [],
398
+ proteins: [],
399
+ dietary: [],
400
+ other: [],
401
+ });
402
+ });
403
+
404
+ it("splits prefixed chips and keeps unprefixed leftovers", () => {
405
+ const parsed = parseGrazingOptions([
406
+ "style:Classic Charcuterie",
407
+ "protein:Cured Meats",
408
+ "dietary:Gluten-Free",
409
+ "Mains",
410
+ ]);
411
+ expect(parsed.styles).toEqual(["Classic Charcuterie"]);
412
+ expect(parsed.proteins).toEqual(["Cured Meats"]);
413
+ expect(parsed.dietary).toEqual(["Gluten-Free"]);
414
+ expect(parsed.other).toEqual(["Mains"]);
415
+ });
416
+
417
+ it("round-trips through serializeGrazingOptions", () => {
418
+ const serialized = serializeGrazingOptions({
419
+ styles: ["Modern Grazing"],
420
+ proteins: ["Artisan Cheeses"],
421
+ dietary: ["Vegan"],
422
+ });
423
+ expect(serialized).toEqual([
424
+ "style:Modern Grazing",
425
+ "protein:Artisan Cheeses",
426
+ "dietary:Vegan",
427
+ ]);
428
+ expect(parseGrazingOptions(serialized)).toEqual({
429
+ styles: ["Modern Grazing"],
430
+ proteins: ["Artisan Cheeses"],
431
+ dietary: ["Vegan"],
432
+ other: [],
433
+ });
434
+ });
435
+
436
+ it("encodeGrazingOption uses lowercase prefixes", () => {
437
+ expect(encodeGrazingOption("style", "Seasonal")).toBe("style:Seasonal");
438
+ expect(GRAZING_TABLE_STYLES).toContain("Classic Charcuterie");
439
+ });
440
+ });
441
+
442
+ describe("grazing and package starting-price labels", () => {
443
+ it("formats grazing starting prices", () => {
444
+ expect(grazingTableStartingPriceLabel(null)).toBeNull();
445
+ expect(grazingTableStartingPriceLabel(17500)).toBe("Boards start at $175");
446
+ });
447
+
448
+ it("delegates packagePricedFoodStartingPriceLabel by subtype", () => {
449
+ expect(packagePricedFoodStartingPriceLabel("GrazingTable", 17500)).toBe(
450
+ "Boards start at $175"
451
+ );
452
+ expect(packagePricedFoodStartingPriceLabel("CelebrationCake", 35000)).toBe(
453
+ "Cakes start at $350"
454
+ );
455
+ expect(packagePricedFoodStartingPriceLabel("Caterer", 35000)).toBeNull();
456
+ });
457
+ });
458
+
459
+ describe("isBelowGuestMinimum", () => {
460
+ it("returns false when no minimum is set", () => {
461
+ expect(isBelowGuestMinimum(5)).toBe(false);
462
+ expect(isBelowGuestMinimum(5, null)).toBe(false);
463
+ expect(isBelowGuestMinimum(5, 0)).toBe(false);
464
+ });
465
+
466
+ it("returns true when below minimum", () => {
467
+ expect(isBelowGuestMinimum(10, 25)).toBe(true);
468
+ });
469
+
470
+ it("returns false when exactly at minimum", () => {
471
+ expect(isBelowGuestMinimum(25, 25)).toBe(false);
472
+ });
473
+
474
+ it("returns false when above minimum", () => {
475
+ expect(isBelowGuestMinimum(100, 25)).toBe(false);
476
+ });
477
+ });
478
+
479
+ describe("fulfillment mode filters", () => {
480
+ it("keeps known F&B modes and drops unknowns", () => {
481
+ expect(isFulfillmentMode("Pickup")).toBe(true);
482
+ expect(isFulfillmentMode("FullService")).toBe(true);
483
+ expect(isFulfillmentMode("Curbside")).toBe(false);
484
+ expect(
485
+ filterFulfillmentModes(["Pickup", "Curbside", "Delivery", "FullService"])
486
+ ).toEqual(["Pickup", "Delivery", "FullService"]);
487
+ expect(filterFulfillmentModes(null)).toEqual([]);
488
+ });
489
+
490
+ it("keeps cake pickup/delivery only", () => {
491
+ expect(isCelebrationCakeFulfillmentMode("Pickup")).toBe(true);
492
+ expect(isCelebrationCakeFulfillmentMode("FullService")).toBe(false);
493
+ expect(
494
+ filterCelebrationCakeFulfillmentModes([
495
+ "Pickup",
496
+ "FullService",
497
+ "Delivery",
498
+ ])
499
+ ).toEqual(["Pickup", "Delivery"]);
500
+ expect(filterCelebrationCakeFulfillmentModes(undefined)).toEqual([]);
501
+ });
502
+ });