@bash-app/bash-common 30.386.0 → 30.393.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__/bashSectionTitleForBashType.test.js +5 -0
- package/dist/__tests__/bashSectionTitleForBashType.test.js.map +1 -1
- package/dist/__tests__/budgetApproval.test.d.ts +2 -0
- package/dist/__tests__/budgetApproval.test.d.ts.map +1 -0
- package/dist/__tests__/budgetApproval.test.js +109 -0
- package/dist/__tests__/budgetApproval.test.js.map +1 -0
- package/dist/__tests__/crewSocialCopy.test.d.ts +2 -0
- package/dist/__tests__/crewSocialCopy.test.d.ts.map +1 -0
- package/dist/__tests__/crewSocialCopy.test.js +21 -0
- package/dist/__tests__/crewSocialCopy.test.js.map +1 -0
- package/dist/__tests__/discoveryScope.test.d.ts +2 -0
- package/dist/__tests__/discoveryScope.test.d.ts.map +1 -0
- package/dist/__tests__/discoveryScope.test.js +40 -0
- package/dist/__tests__/discoveryScope.test.js.map +1 -0
- 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/__tests__/suggestedNight.test.d.ts +2 -0
- package/dist/__tests__/suggestedNight.test.d.ts.map +1 -0
- package/dist/__tests__/suggestedNight.test.js +10 -0
- package/dist/__tests__/suggestedNight.test.js.map +1 -0
- package/dist/__tests__/suggestionScore.test.d.ts +2 -0
- package/dist/__tests__/suggestionScore.test.d.ts.map +1 -0
- package/dist/__tests__/suggestionScore.test.js +68 -0
- package/dist/__tests__/suggestionScore.test.js.map +1 -0
- package/dist/__tests__/userTaste.test.d.ts +2 -0
- package/dist/__tests__/userTaste.test.d.ts.map +1 -0
- package/dist/__tests__/userTaste.test.js +21 -0
- package/dist/__tests__/userTaste.test.js.map +1 -0
- package/dist/budgetApproval.d.ts +126 -0
- package/dist/budgetApproval.d.ts.map +1 -0
- package/dist/budgetApproval.js +161 -0
- package/dist/budgetApproval.js.map +1 -0
- package/dist/crewSocialCopy.d.ts +16 -0
- package/dist/crewSocialCopy.d.ts.map +1 -0
- package/dist/crewSocialCopy.js +23 -0
- package/dist/crewSocialCopy.js.map +1 -0
- package/dist/definitions.d.ts +6 -0
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js +6 -0
- package/dist/definitions.js.map +1 -1
- package/dist/discoveryScope.d.ts +48 -0
- package/dist/discoveryScope.d.ts.map +1 -0
- package/dist/discoveryScope.js +114 -0
- package/dist/discoveryScope.js.map +1 -0
- 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/home.d.ts +6 -1
- package/dist/home.d.ts.map +1 -1
- package/dist/home.js +5 -1
- package/dist/home.js.map +1 -1
- package/dist/hostCrmCrowdPulse.d.ts +2 -0
- package/dist/hostCrmCrowdPulse.d.ts.map +1 -1
- package/dist/hostCrmCrowdPulse.js.map +1 -1
- package/dist/hostCrmSegments.d.ts +6 -0
- package/dist/hostCrmSegments.d.ts.map +1 -1
- package/dist/hostCrmSegments.js.map +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -2
- 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/sms/smsTemplates.d.ts +6 -1
- package/dist/sms/smsTemplates.d.ts.map +1 -1
- package/dist/sms/smsTemplates.js +1 -0
- package/dist/sms/smsTemplates.js.map +1 -1
- package/dist/suggestedNight.d.ts +12 -0
- package/dist/suggestedNight.d.ts.map +1 -0
- package/dist/suggestedNight.js +12 -0
- package/dist/suggestedNight.js.map +1 -0
- package/dist/suggestionScore.d.ts +42 -0
- package/dist/suggestionScore.d.ts.map +1 -0
- package/dist/suggestionScore.js +100 -0
- package/dist/suggestionScore.js.map +1 -0
- package/dist/userTaste.d.ts +40 -0
- package/dist/userTaste.d.ts.map +1 -0
- package/dist/userTaste.js +103 -0
- package/dist/userTaste.js.map +1 -0
- package/dist/utils/__tests__/featuredDiscoveryGeo.test.js +9 -1
- package/dist/utils/__tests__/featuredDiscoveryGeo.test.js.map +1 -1
- package/dist/utils/featuredDiscoveryGeo.d.ts +7 -0
- package/dist/utils/featuredDiscoveryGeo.d.ts.map +1 -1
- package/dist/utils/featuredDiscoveryGeo.js +11 -0
- package/dist/utils/featuredDiscoveryGeo.js.map +1 -1
- 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 +185 -53
- package/src/__tests__/bashSectionTitleForBashType.test.ts +10 -0
- package/src/__tests__/budgetApproval.test.ts +153 -0
- package/src/__tests__/crewSocialCopy.test.ts +25 -0
- package/src/__tests__/discoveryScope.test.ts +65 -0
- package/src/__tests__/scenePulse.test.ts +69 -0
- package/src/__tests__/suggestedNight.test.ts +11 -0
- package/src/__tests__/suggestionScore.test.ts +81 -0
- package/src/__tests__/userTaste.test.ts +33 -0
- package/src/budgetApproval.ts +303 -0
- package/src/crewSocialCopy.ts +37 -0
- package/src/definitions.ts +11 -0
- package/src/discoveryScope.ts +170 -0
- package/src/extendedSchemas.ts +8 -0
- package/src/home.ts +15 -0
- package/src/hostCrmCrowdPulse.ts +2 -0
- package/src/hostCrmSegments.ts +6 -0
- package/src/index.ts +18 -3
- package/src/scenePulse.ts +113 -0
- package/src/sms/smsTemplates.ts +6 -0
- package/src/suggestedNight.ts +20 -0
- package/src/suggestionScore.ts +140 -0
- package/src/userTaste.ts +152 -0
- package/src/utils/__tests__/featuredDiscoveryGeo.test.ts +13 -0
- package/src/utils/featuredDiscoveryGeo.ts +15 -0
- package/src/utils/service/__tests__/foodAndBeverageQuote.test.ts +502 -0
- package/src/utils/service/foodAndBeverageQuote.ts +613 -0
package/src/userTaste.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { BashEventType } from "@prisma/client";
|
|
2
|
+
|
|
3
|
+
import { isCauseTagId } from "./causeTags.js";
|
|
4
|
+
import {
|
|
5
|
+
DiscoveryScope,
|
|
6
|
+
DISCOVERY_SCOPE_VALUES,
|
|
7
|
+
isDiscoveryScope,
|
|
8
|
+
} from "./discoveryScope.js";
|
|
9
|
+
|
|
10
|
+
export const UserTasteKind = {
|
|
11
|
+
EventType: "EventType",
|
|
12
|
+
CauseTag: "CauseTag",
|
|
13
|
+
MusicGenre: "MusicGenre",
|
|
14
|
+
DiscoveryScope: "DiscoveryScope",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export type UserTasteKind =
|
|
18
|
+
(typeof UserTasteKind)[keyof typeof UserTasteKind];
|
|
19
|
+
|
|
20
|
+
export const UserTasteSource = {
|
|
21
|
+
Explicit: "Explicit",
|
|
22
|
+
Inferred: "Inferred",
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export type UserTasteSource =
|
|
26
|
+
(typeof UserTasteSource)[keyof typeof UserTasteSource];
|
|
27
|
+
|
|
28
|
+
const BASH_EVENT_TYPE_VALUES = new Set(
|
|
29
|
+
Object.values(BashEventType) as string[]
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
/** Mirrors Prisma MusicGenreType — kept local so validation works without extra imports. */
|
|
33
|
+
export const MUSIC_GENRE_TASTE_VALUES = [
|
|
34
|
+
"EDM",
|
|
35
|
+
"Electronic",
|
|
36
|
+
"House",
|
|
37
|
+
"Techno",
|
|
38
|
+
"Dubstep",
|
|
39
|
+
"Trance",
|
|
40
|
+
"Pop",
|
|
41
|
+
"Rock",
|
|
42
|
+
"HipHop",
|
|
43
|
+
"Country",
|
|
44
|
+
"RnB",
|
|
45
|
+
"Jazz",
|
|
46
|
+
"Blues",
|
|
47
|
+
"Indie",
|
|
48
|
+
"Alternative",
|
|
49
|
+
"Folk",
|
|
50
|
+
"Acoustic",
|
|
51
|
+
"Classical",
|
|
52
|
+
"Opera",
|
|
53
|
+
"World",
|
|
54
|
+
"Latin",
|
|
55
|
+
"Experimental",
|
|
56
|
+
"Fusion",
|
|
57
|
+
"Other",
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
export type MusicGenreTasteValue = (typeof MUSIC_GENRE_TASTE_VALUES)[number];
|
|
61
|
+
|
|
62
|
+
const MUSIC_GENRE_SET = new Set<string>(MUSIC_GENRE_TASTE_VALUES);
|
|
63
|
+
|
|
64
|
+
const CIVIC_EVENT_TYPES = new Set(["ChurchEvent", "SchoolEvent"]);
|
|
65
|
+
|
|
66
|
+
export function isUserTasteKind(value: unknown): value is UserTasteKind {
|
|
67
|
+
return (
|
|
68
|
+
value === UserTasteKind.EventType ||
|
|
69
|
+
value === UserTasteKind.CauseTag ||
|
|
70
|
+
value === UserTasteKind.MusicGenre ||
|
|
71
|
+
value === UserTasteKind.DiscoveryScope
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isValidTasteValue(kind: UserTasteKind, value: string): boolean {
|
|
76
|
+
if (!value.trim()) return false;
|
|
77
|
+
switch (kind) {
|
|
78
|
+
case UserTasteKind.EventType:
|
|
79
|
+
return BASH_EVENT_TYPE_VALUES.has(value);
|
|
80
|
+
case UserTasteKind.CauseTag:
|
|
81
|
+
return isCauseTagId(value);
|
|
82
|
+
case UserTasteKind.MusicGenre:
|
|
83
|
+
return MUSIC_GENRE_SET.has(value);
|
|
84
|
+
case UserTasteKind.DiscoveryScope:
|
|
85
|
+
return (
|
|
86
|
+
isDiscoveryScope(value) &&
|
|
87
|
+
value !== DiscoveryScope.Unspecified &&
|
|
88
|
+
(DISCOVERY_SCOPE_VALUES as readonly string[]).includes(value)
|
|
89
|
+
);
|
|
90
|
+
default:
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function isCivicEventType(eventType: string): boolean {
|
|
96
|
+
return CIVIC_EVENT_TYPES.has(eventType);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type SceneTasteChip = {
|
|
100
|
+
id: string;
|
|
101
|
+
label: string;
|
|
102
|
+
kind: UserTasteKind;
|
|
103
|
+
value: string;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** First-screen chips — no Networking. */
|
|
107
|
+
export const SCENE_TASTE_CHIPS: readonly SceneTasteChip[] = [
|
|
108
|
+
{ id: "festival", label: "Festivals", kind: UserTasteKind.EventType, value: "Festival" },
|
|
109
|
+
{ id: "concert", label: "Concerts", kind: UserTasteKind.EventType, value: "Concert" },
|
|
110
|
+
{ id: "comedy", label: "Comedy", kind: UserTasteKind.EventType, value: "ComedyShow" },
|
|
111
|
+
{ id: "rave", label: "Raves / nightlife", kind: UserTasteKind.EventType, value: "Rave" },
|
|
112
|
+
{ id: "fair", label: "Fairs", kind: UserTasteKind.EventType, value: "Fair" },
|
|
113
|
+
{ id: "sports", label: "Sports", kind: UserTasteKind.EventType, value: "SportsWatchParty" },
|
|
114
|
+
{ id: "pride", label: "Drag / Pride", kind: UserTasteKind.CauseTag, value: "lgbtq" },
|
|
115
|
+
{ id: "arts", label: "Arts & culture", kind: UserTasteKind.CauseTag, value: "arts_culture" },
|
|
116
|
+
{ id: "food", label: "Food", kind: UserTasteKind.EventType, value: "FestivalFood" },
|
|
117
|
+
{ id: "outdoor", label: "Outdoor", kind: UserTasteKind.EventType, value: "OutdoorActivity" },
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
export const TRAVEL_TASTE_CHIP: SceneTasteChip = {
|
|
121
|
+
id: "travel",
|
|
122
|
+
label: "I'll travel for the right night",
|
|
123
|
+
kind: UserTasteKind.DiscoveryScope,
|
|
124
|
+
value: DiscoveryScope.National,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const TRAVEL_TASTE_VALUES: readonly string[] = [
|
|
128
|
+
DiscoveryScope.National,
|
|
129
|
+
DiscoveryScope.International,
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
export type TastePreferenceInput = {
|
|
133
|
+
kind: UserTasteKind;
|
|
134
|
+
value: string;
|
|
135
|
+
weight?: number;
|
|
136
|
+
source?: UserTasteSource;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export function normalizeTasteWeight(weight: number | undefined): number {
|
|
140
|
+
if (weight === 2 || weight === -1 || weight === 1) return weight;
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function userHasTravelTaste(
|
|
145
|
+
rows: ReadonlyArray<{ kind: string; value: string }>
|
|
146
|
+
): boolean {
|
|
147
|
+
return rows.some(
|
|
148
|
+
(r) =>
|
|
149
|
+
r.kind === UserTasteKind.DiscoveryScope &&
|
|
150
|
+
TRAVEL_TASTE_VALUES.includes(r.value)
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { SUGGESTION_WEIGHTS } from "../../suggestionScore.js";
|
|
1
2
|
import {
|
|
3
|
+
applyFeaturedTasteMultiplier,
|
|
4
|
+
FEATURED_TASTE_MATCH_SCORE,
|
|
2
5
|
haversineMiles,
|
|
3
6
|
effectiveFeaturedForGeoScope,
|
|
4
7
|
sortPublicEventsForFeaturedDiscovery,
|
|
@@ -118,3 +121,13 @@ describe("sortPublicEventsForFeaturedDiscovery", () => {
|
|
|
118
121
|
]);
|
|
119
122
|
});
|
|
120
123
|
});
|
|
124
|
+
|
|
125
|
+
describe("applyFeaturedTasteMultiplier", () => {
|
|
126
|
+
it("stays smaller than crew-going weights", () => {
|
|
127
|
+
expect(applyFeaturedTasteMultiplier(10, false)).toBe(10);
|
|
128
|
+
expect(applyFeaturedTasteMultiplier(10, true)).toBe(10.5);
|
|
129
|
+
expect(FEATURED_TASTE_MATCH_SCORE).toBeLessThan(
|
|
130
|
+
SUGGESTION_WEIGHTS.connectionsGoing
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -73,3 +73,18 @@ export function sortPublicEventsForFeaturedDiscovery<
|
|
|
73
73
|
});
|
|
74
74
|
return withKeys.map((x) => x.e);
|
|
75
75
|
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Paid featuring × taste overlap. Must stay smaller than any crew-going
|
|
79
|
+
* boost (`SUGGESTION_WEIGHTS.connectionsGoing` / followed / attended host).
|
|
80
|
+
*/
|
|
81
|
+
export const FEATURED_TASTE_MATCH_MULTIPLIER = 1.05;
|
|
82
|
+
export const FEATURED_TASTE_MATCH_SCORE = 1;
|
|
83
|
+
|
|
84
|
+
export function applyFeaturedTasteMultiplier(
|
|
85
|
+
featuredScore: number,
|
|
86
|
+
tasteOverlaps: boolean
|
|
87
|
+
): number {
|
|
88
|
+
if (!tasteOverlaps) return featuredScore;
|
|
89
|
+
return featuredScore * FEATURED_TASTE_MATCH_MULTIPLIER;
|
|
90
|
+
}
|
|
@@ -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
|
+
});
|