@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.
- package/dist/__tests__/scenePulse.test.d.ts +2 -0
- package/dist/__tests__/scenePulse.test.d.ts.map +1 -0
- package/dist/__tests__/scenePulse.test.js +52 -0
- package/dist/__tests__/scenePulse.test.js.map +1 -0
- package/dist/definitions.d.ts +5 -0
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js.map +1 -1
- package/dist/extendedSchemas.d.ts +7 -0
- package/dist/extendedSchemas.d.ts.map +1 -1
- package/dist/extendedSchemas.js +8 -0
- package/dist/extendedSchemas.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/scenePulse.d.ts +66 -0
- package/dist/scenePulse.d.ts.map +1 -0
- package/dist/scenePulse.js +54 -0
- package/dist/scenePulse.js.map +1 -0
- package/dist/utils/service/__tests__/foodAndBeverageQuote.test.d.ts +2 -0
- package/dist/utils/service/__tests__/foodAndBeverageQuote.test.d.ts.map +1 -0
- package/dist/utils/service/__tests__/foodAndBeverageQuote.test.js +353 -0
- package/dist/utils/service/__tests__/foodAndBeverageQuote.test.js.map +1 -0
- package/dist/utils/service/foodAndBeverageQuote.d.ts +171 -0
- package/dist/utils/service/foodAndBeverageQuote.d.ts.map +1 -0
- package/dist/utils/service/foodAndBeverageQuote.js +467 -0
- package/dist/utils/service/foodAndBeverageQuote.js.map +1 -0
- package/package.json +1 -1
- package/prisma/schema.prisma +45 -0
- package/src/__tests__/scenePulse.test.ts +69 -0
- package/src/definitions.ts +5 -0
- package/src/extendedSchemas.ts +8 -0
- package/src/index.ts +6 -0
- package/src/scenePulse.ts +113 -0
- package/src/utils/service/__tests__/foodAndBeverageQuote.test.ts +502 -0
- 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
|
+
});
|