@bash-app/bash-common 30.385.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 (56) 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 +19 -13
  6. package/dist/definitions.d.ts.map +1 -1
  7. package/dist/definitions.js +13 -12
  8. package/dist/definitions.js.map +1 -1
  9. package/dist/extendedSchemas.d.ts +22 -0
  10. package/dist/extendedSchemas.d.ts.map +1 -1
  11. package/dist/extendedSchemas.js +17 -0
  12. package/dist/extendedSchemas.js.map +1 -1
  13. package/dist/index.d.ts +3 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/scenePulse.d.ts +66 -0
  18. package/dist/scenePulse.d.ts.map +1 -0
  19. package/dist/scenePulse.js +54 -0
  20. package/dist/scenePulse.js.map +1 -0
  21. package/dist/utils/__tests__/paymentUtils.test.js +28 -1
  22. package/dist/utils/__tests__/paymentUtils.test.js.map +1 -1
  23. package/dist/utils/__tests__/serviceAvailabilityDisplay.test.js +56 -1
  24. package/dist/utils/__tests__/serviceAvailabilityDisplay.test.js.map +1 -1
  25. package/dist/utils/__tests__/slugUtils.test.js +5 -3
  26. package/dist/utils/__tests__/slugUtils.test.js.map +1 -1
  27. package/dist/utils/paymentUtils.d.ts +18 -1
  28. package/dist/utils/paymentUtils.d.ts.map +1 -1
  29. package/dist/utils/paymentUtils.js +29 -2
  30. package/dist/utils/paymentUtils.js.map +1 -1
  31. package/dist/utils/service/__tests__/foodAndBeverageQuote.test.d.ts +2 -0
  32. package/dist/utils/service/__tests__/foodAndBeverageQuote.test.d.ts.map +1 -0
  33. package/dist/utils/service/__tests__/foodAndBeverageQuote.test.js +353 -0
  34. package/dist/utils/service/__tests__/foodAndBeverageQuote.test.js.map +1 -0
  35. package/dist/utils/service/foodAndBeverageQuote.d.ts +171 -0
  36. package/dist/utils/service/foodAndBeverageQuote.d.ts.map +1 -0
  37. package/dist/utils/service/foodAndBeverageQuote.js +467 -0
  38. package/dist/utils/service/foodAndBeverageQuote.js.map +1 -0
  39. package/dist/utils/serviceAvailabilityDisplay.d.ts +28 -2
  40. package/dist/utils/serviceAvailabilityDisplay.d.ts.map +1 -1
  41. package/dist/utils/serviceAvailabilityDisplay.js +39 -0
  42. package/dist/utils/serviceAvailabilityDisplay.js.map +1 -1
  43. package/package.json +1 -1
  44. package/prisma/schema.prisma +95 -4
  45. package/src/__tests__/scenePulse.test.ts +69 -0
  46. package/src/definitions.ts +18 -12
  47. package/src/extendedSchemas.ts +23 -0
  48. package/src/index.ts +6 -0
  49. package/src/scenePulse.ts +113 -0
  50. package/src/utils/__tests__/paymentUtils.test.ts +36 -0
  51. package/src/utils/__tests__/serviceAvailabilityDisplay.test.ts +73 -0
  52. package/src/utils/__tests__/slugUtils.test.ts +5 -3
  53. package/src/utils/paymentUtils.ts +47 -2
  54. package/src/utils/service/__tests__/foodAndBeverageQuote.test.ts +502 -0
  55. package/src/utils/service/foodAndBeverageQuote.ts +613 -0
  56. package/src/utils/serviceAvailabilityDisplay.ts +82 -2
@@ -430,6 +430,32 @@ export function lowestGuestAllInPriceCents(
430
430
  return min;
431
431
  }
432
432
 
433
+ /** Max share of attributed ticket subtotal a host can assign to a performer (50%). */
434
+ export const MAX_TICKET_COMMISSION_BPS = 5000;
435
+
436
+ /**
437
+ * Floor cents owed to a promo-code performer from the discounted ticket subtotal.
438
+ * Guest price is unchanged; this amount is withheld from the host via application_fee.
439
+ */
440
+ export function ticketCommissionCents(
441
+ basisCents: number,
442
+ commissionBps: number | null | undefined
443
+ ): number {
444
+ if (basisCents <= 0) return 0;
445
+ const bps = Math.floor(Number(commissionBps) || 0);
446
+ if (bps <= 0) return 0;
447
+ const clamped = Math.min(MAX_TICKET_COMMISSION_BPS, bps);
448
+ return Math.floor((basisCents * clamped) / 10_000);
449
+ }
450
+
451
+ export function clampTicketCommissionBps(
452
+ commissionBps: number | null | undefined
453
+ ): number {
454
+ const bps = Math.floor(Number(commissionBps) || 0);
455
+ if (bps <= 0) return 0;
456
+ return Math.min(MAX_TICKET_COMMISSION_BPS, bps);
457
+ }
458
+
433
459
  /**
434
460
  * Compute the Stripe Connect `application_fee_amount` for a ticket-style charge,
435
461
  * along with its constituent parts.
@@ -441,8 +467,10 @@ export function lowestGuestAllInPriceCents(
441
467
  * - `bashPlatformTotalCents` and `guestPlatformLineCents` are both zero
442
468
  * - `totalChargedCents` collapses to the discounted subtotal (no guest-line fee added)
443
469
  * - `applicationFeeAmountCents` equals `stripeProcessingCents`
470
+ * plus optional `performerCommissionCents`
444
471
  *
445
- * Default (`false`) preserves existing math bit-exact for every current caller.
472
+ * Default (`false`) preserves existing math bit-exact for every current caller
473
+ * when `performerCommissionCents` is omitted or 0.
446
474
  */
447
475
  export function computeTicketApplicationFeeAmountCents(args: {
448
476
  discountedSubtotalCents: number;
@@ -456,6 +484,11 @@ export function computeTicketApplicationFeeAmountCents(args: {
456
484
  * NEVER from `BashEvent.nonProfit`, which is user-entered.
457
485
  */
458
486
  isVerifiedNonprofitHost?: boolean;
487
+ /**
488
+ * Performer / street-team cut withheld from the host (not added to the guest).
489
+ * Must be less than remaining charge after Stripe + Bash platform fees.
490
+ */
491
+ performerCommissionCents?: number;
459
492
  }): {
460
493
  totalChargedCents: number;
461
494
  /** Stripe's 2.9% + $0.30 pass-through; unchanged by the nonprofit waiver. */
@@ -467,15 +500,27 @@ export function computeTicketApplicationFeeAmountCents(args: {
467
500
  guestPlatformLineCents: number;
468
501
  /** Guest-visible Stripe processing line (0 when host absorbs). */
469
502
  guestProcessingLineCents: number;
503
+ /** Host-withheld performer cut included in application_fee. */
504
+ performerCommissionCents: number;
470
505
  } {
471
506
  const breakdown = computeGuestTicketCheckoutBreakdown(args);
507
+ const performerCommissionCents = Math.max(
508
+ 0,
509
+ Math.floor(args.performerCommissionCents ?? 0)
510
+ );
511
+ const maxWithhold = Math.max(
512
+ 0,
513
+ breakdown.totalDueCents - breakdown.applicationFeeAmountCents - 1
514
+ );
515
+ const withheld = Math.min(performerCommissionCents, maxWithhold);
472
516
  return {
473
517
  totalChargedCents: breakdown.totalDueCents,
474
518
  stripeProcessingCents: breakdown.stripeProcessingCents,
475
519
  bashPlatformTotalCents: breakdown.bashPlatformTotalCents,
476
- applicationFeeAmountCents: breakdown.applicationFeeAmountCents,
520
+ applicationFeeAmountCents: breakdown.applicationFeeAmountCents + withheld,
477
521
  guestPlatformLineCents: breakdown.guestPlatformLineCents,
478
522
  guestProcessingLineCents: breakdown.guestProcessingLineCents,
523
+ performerCommissionCents: withheld,
479
524
  };
480
525
  }
481
526
 
@@ -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
+ });