@bash-app/bash-common 30.260.1 → 30.262.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__/ticketBnplPaymentMethods.cashapp.test.d.ts +2 -0
- package/dist/__tests__/ticketBnplPaymentMethods.cashapp.test.d.ts.map +1 -0
- package/dist/__tests__/ticketBnplPaymentMethods.cashapp.test.js +33 -0
- package/dist/__tests__/ticketBnplPaymentMethods.cashapp.test.js.map +1 -0
- package/dist/__tests__/ticketBnplPaymentMethods.test.d.ts +2 -0
- package/dist/__tests__/ticketBnplPaymentMethods.test.d.ts.map +1 -0
- package/dist/__tests__/ticketBnplPaymentMethods.test.js +46 -0
- package/dist/__tests__/ticketBnplPaymentMethods.test.js.map +1 -0
- package/dist/definitions.d.ts +6 -1
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/ticketBnplPaymentMethods.d.ts +32 -0
- package/dist/ticketBnplPaymentMethods.d.ts.map +1 -0
- package/dist/ticketBnplPaymentMethods.js +42 -0
- package/dist/ticketBnplPaymentMethods.js.map +1 -0
- package/dist/utils/__tests__/featuredDiscoveryGeo.test.d.ts +2 -0
- package/dist/utils/__tests__/featuredDiscoveryGeo.test.d.ts.map +1 -0
- package/dist/utils/__tests__/featuredDiscoveryGeo.test.js +98 -0
- package/dist/utils/__tests__/featuredDiscoveryGeo.test.js.map +1 -0
- package/dist/utils/__tests__/flyerUtils.test.js +7 -1
- package/dist/utils/__tests__/flyerUtils.test.js.map +1 -1
- package/dist/utils/__tests__/guestSurface.test.d.ts +2 -0
- package/dist/utils/__tests__/guestSurface.test.d.ts.map +1 -0
- package/dist/utils/__tests__/guestSurface.test.js +94 -0
- package/dist/utils/__tests__/guestSurface.test.js.map +1 -0
- package/dist/utils/__tests__/ticketTierSchedule.test.d.ts +2 -0
- package/dist/utils/__tests__/ticketTierSchedule.test.d.ts.map +1 -0
- package/dist/utils/__tests__/ticketTierSchedule.test.js +84 -0
- package/dist/utils/__tests__/ticketTierSchedule.test.js.map +1 -0
- package/dist/utils/featuredDiscoveryGeo.d.ts +30 -0
- package/dist/utils/featuredDiscoveryGeo.d.ts.map +1 -0
- package/dist/utils/featuredDiscoveryGeo.js +47 -0
- package/dist/utils/featuredDiscoveryGeo.js.map +1 -0
- package/dist/utils/flyerUtils.d.ts +5 -0
- package/dist/utils/flyerUtils.d.ts.map +1 -1
- package/dist/utils/flyerUtils.js +24 -0
- package/dist/utils/flyerUtils.js.map +1 -1
- package/dist/utils/guestSurface.d.ts +114 -0
- package/dist/utils/guestSurface.d.ts.map +1 -0
- package/dist/utils/guestSurface.js +164 -0
- package/dist/utils/guestSurface.js.map +1 -0
- package/dist/utils/promotionBlastUtils.d.ts +38 -0
- package/dist/utils/promotionBlastUtils.d.ts.map +1 -0
- package/dist/utils/promotionBlastUtils.js +88 -0
- package/dist/utils/promotionBlastUtils.js.map +1 -0
- package/dist/utils/service/__tests__/defaultOfferAmountUtils.test.d.ts +2 -0
- package/dist/utils/service/__tests__/defaultOfferAmountUtils.test.d.ts.map +1 -0
- package/dist/utils/service/__tests__/defaultOfferAmountUtils.test.js +65 -0
- package/dist/utils/service/__tests__/defaultOfferAmountUtils.test.js.map +1 -0
- package/dist/utils/service/defaultOfferAmountUtils.d.ts +32 -0
- package/dist/utils/service/defaultOfferAmountUtils.d.ts.map +1 -0
- package/dist/utils/service/defaultOfferAmountUtils.js +115 -0
- package/dist/utils/service/defaultOfferAmountUtils.js.map +1 -0
- package/dist/utils/ticketTierSchedule.d.ts +58 -0
- package/dist/utils/ticketTierSchedule.d.ts.map +1 -0
- package/dist/utils/ticketTierSchedule.js +279 -0
- package/dist/utils/ticketTierSchedule.js.map +1 -0
- package/package.json +5 -2
- package/prisma/schema.prisma +332 -1
- package/src/__tests__/ticketBnplPaymentMethods.cashapp.test.ts +45 -0
- package/src/__tests__/ticketBnplPaymentMethods.test.ts +66 -0
- package/src/definitions.ts +6 -1
- package/src/index.ts +6 -0
- package/src/ticketBnplPaymentMethods.ts +79 -0
- package/src/utils/__tests__/featuredDiscoveryGeo.test.ts +120 -0
- package/src/utils/__tests__/flyerUtils.test.ts +10 -0
- package/src/utils/__tests__/guestSurface.test.ts +128 -0
- package/src/utils/__tests__/ticketTierSchedule.test.ts +131 -0
- package/src/utils/featuredDiscoveryGeo.ts +75 -0
- package/src/utils/flyerUtils.ts +33 -0
- package/src/utils/guestSurface.ts +315 -0
- package/src/utils/promotionBlastUtils.ts +127 -0
- package/src/utils/service/__tests__/defaultOfferAmountUtils.test.ts +102 -0
- package/src/utils/service/defaultOfferAmountUtils.ts +170 -0
- package/src/utils/ticketTierSchedule.ts +387 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated guest visibility — shared policy resolver for API + bash-app.
|
|
3
|
+
* Three layers compose: event teaser policy, per-guest visibility, viewer roster depth.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { PublicTeaserUser } from "../extendedSchemas.js";
|
|
7
|
+
|
|
8
|
+
export type AttendeeDisplayMode =
|
|
9
|
+
| "All"
|
|
10
|
+
| "CountOnly"
|
|
11
|
+
| "NetworkOnly"
|
|
12
|
+
| "Hidden";
|
|
13
|
+
|
|
14
|
+
export type AttendeeVisibility =
|
|
15
|
+
| "Visible"
|
|
16
|
+
| "Hidden"
|
|
17
|
+
| "FeaturedPending"
|
|
18
|
+
| "FeaturedInvited";
|
|
19
|
+
|
|
20
|
+
export type GuestListDepth = "None" | "Partial" | "Full";
|
|
21
|
+
|
|
22
|
+
export type { PublicTeaserUser };
|
|
23
|
+
|
|
24
|
+
export type NetworkOverlapPayload = {
|
|
25
|
+
connections: PublicTeaserUser[];
|
|
26
|
+
contacts: PublicTeaserUser[];
|
|
27
|
+
customLists: Array<{
|
|
28
|
+
listId: string;
|
|
29
|
+
listName: string;
|
|
30
|
+
users: PublicTeaserUser[];
|
|
31
|
+
}>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type GuestSurfacePayload = {
|
|
35
|
+
count: { totalTickets: number; visibleAttendees: number };
|
|
36
|
+
preview: PublicTeaserUser[];
|
|
37
|
+
featured: PublicTeaserUser[];
|
|
38
|
+
featuredHeadline: string | null;
|
|
39
|
+
network: NetworkOverlapPayload | null;
|
|
40
|
+
upsell: { hasDeeperTiers: boolean; cheapestTierName?: string } | null;
|
|
41
|
+
roster?: {
|
|
42
|
+
users: PublicTeaserUser[];
|
|
43
|
+
totalVisible: number;
|
|
44
|
+
hasMore: boolean;
|
|
45
|
+
cursor?: string;
|
|
46
|
+
};
|
|
47
|
+
viewer: { rosterDepth: GuestListDepth; isHost: boolean };
|
|
48
|
+
policy: { effectiveMode: AttendeeDisplayMode };
|
|
49
|
+
/** Host-only: user IDs awaiting spotlight approval (guest-initiated) */
|
|
50
|
+
pendingFeaturedUserIds?: string[];
|
|
51
|
+
/** Host-only: user IDs with host-initiated featured invite pending accept */
|
|
52
|
+
pendingFeaturedInviteUserIds?: string[];
|
|
53
|
+
/** Viewer-only: pending featured invite for the authenticated viewer */
|
|
54
|
+
viewerFeaturedInvite?: {
|
|
55
|
+
bashEventId: string;
|
|
56
|
+
bashTitle: string;
|
|
57
|
+
hostName?: string;
|
|
58
|
+
} | null;
|
|
59
|
+
/** Host-only: per-guest visibility for roster management */
|
|
60
|
+
attendeeVisibilityByUserId?: Record<string, AttendeeVisibility>;
|
|
61
|
+
/** Event has public guest list enabled on bash detail */
|
|
62
|
+
attendeeListPublic?: boolean;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type AttendeeRosterMeta = {
|
|
66
|
+
depth: GuestListDepth;
|
|
67
|
+
totalVisible?: number;
|
|
68
|
+
hasMore?: boolean;
|
|
69
|
+
cursor?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type EventModeInput = {
|
|
73
|
+
attendeeDisplayMode?: string | null;
|
|
74
|
+
showAttendees?: boolean | null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type GuestSurfacePolicyInput = {
|
|
78
|
+
eventMode: AttendeeDisplayMode;
|
|
79
|
+
isHost: boolean;
|
|
80
|
+
isAuthenticated: boolean;
|
|
81
|
+
viewerRosterDepth: GuestListDepth;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type GuestSurfacePolicy = {
|
|
85
|
+
showCount: boolean;
|
|
86
|
+
showPreviewAvatars: boolean;
|
|
87
|
+
showFeatured: boolean;
|
|
88
|
+
showNetworkSlice: boolean;
|
|
89
|
+
rosterDepth: GuestListDepth;
|
|
90
|
+
previewLimit: number;
|
|
91
|
+
apiPreviewLimit: number;
|
|
92
|
+
effectiveMode: AttendeeDisplayMode;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const DEPTH_RANK: Record<GuestListDepth, number> = {
|
|
96
|
+
None: 0,
|
|
97
|
+
Partial: 1,
|
|
98
|
+
Full: 2,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Legacy showAttendees=false → Hidden; otherwise honour attendeeDisplayMode string. */
|
|
102
|
+
export function resolveEffectiveEventMode(
|
|
103
|
+
event: EventModeInput
|
|
104
|
+
): AttendeeDisplayMode {
|
|
105
|
+
const raw = event.attendeeDisplayMode;
|
|
106
|
+
if (raw === "CountOnly") return "CountOnly";
|
|
107
|
+
if (raw === "NetworkOnly") return "NetworkOnly";
|
|
108
|
+
if (raw === "Hidden") return "Hidden";
|
|
109
|
+
if (event.showAttendees === false) return "Hidden";
|
|
110
|
+
return "All";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function compareGuestListDepth(
|
|
114
|
+
a: GuestListDepth,
|
|
115
|
+
b: GuestListDepth
|
|
116
|
+
): number {
|
|
117
|
+
return DEPTH_RANK[a] - DEPTH_RANK[b];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function maxGuestListDepth(
|
|
121
|
+
...depths: GuestListDepth[]
|
|
122
|
+
): GuestListDepth {
|
|
123
|
+
if (depths.length === 0) return "None";
|
|
124
|
+
return depths.reduce((max, d) =>
|
|
125
|
+
compareGuestListDepth(d, max) > 0 ? d : max
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Cap viewer tier depth by event mode for non-hosts. */
|
|
130
|
+
export function capRosterDepthForEventMode(
|
|
131
|
+
viewerDepth: GuestListDepth,
|
|
132
|
+
eventMode: AttendeeDisplayMode,
|
|
133
|
+
isHost: boolean
|
|
134
|
+
): GuestListDepth {
|
|
135
|
+
if (isHost) return "Full";
|
|
136
|
+
if (eventMode === "Hidden") return "None";
|
|
137
|
+
if (eventMode === "NetworkOnly") return "None";
|
|
138
|
+
return viewerDepth;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Whether the viewer may fetch the full host-style roster endpoint. */
|
|
142
|
+
export function viewerMayFetchFullRoster(input: {
|
|
143
|
+
isHost: boolean;
|
|
144
|
+
viewerRosterDepth: GuestListDepth;
|
|
145
|
+
}): boolean {
|
|
146
|
+
if (input.isHost) return true;
|
|
147
|
+
return compareGuestListDepth(input.viewerRosterDepth, "Partial") >= 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Public bash-detail teaser policy (Layer 1 + viewer depth cap). */
|
|
151
|
+
export function resolveGuestSurfacePolicy(
|
|
152
|
+
input: GuestSurfacePolicyInput
|
|
153
|
+
): GuestSurfacePolicy {
|
|
154
|
+
const { eventMode, isHost, isAuthenticated, viewerRosterDepth } = input;
|
|
155
|
+
const effectiveDepth = capRosterDepthForEventMode(
|
|
156
|
+
viewerRosterDepth,
|
|
157
|
+
eventMode,
|
|
158
|
+
isHost
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
if (eventMode === "Hidden" && !isHost) {
|
|
162
|
+
return {
|
|
163
|
+
showCount: false,
|
|
164
|
+
showPreviewAvatars: false,
|
|
165
|
+
showFeatured: false,
|
|
166
|
+
showNetworkSlice: false,
|
|
167
|
+
rosterDepth: "None",
|
|
168
|
+
previewLimit: 4,
|
|
169
|
+
apiPreviewLimit: 10,
|
|
170
|
+
effectiveMode: eventMode,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (eventMode === "CountOnly" && !isHost) {
|
|
175
|
+
return {
|
|
176
|
+
showCount: true,
|
|
177
|
+
showPreviewAvatars: false,
|
|
178
|
+
showFeatured: false,
|
|
179
|
+
showNetworkSlice: isAuthenticated,
|
|
180
|
+
rosterDepth: effectiveDepth,
|
|
181
|
+
previewLimit: 4,
|
|
182
|
+
apiPreviewLimit: 10,
|
|
183
|
+
effectiveMode: eventMode,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const showCount = true;
|
|
188
|
+
const showPreviewAvatars =
|
|
189
|
+
eventMode === "All" && !isHost
|
|
190
|
+
? true
|
|
191
|
+
: eventMode === "All" && isHost
|
|
192
|
+
? true
|
|
193
|
+
: false;
|
|
194
|
+
const showFeatured =
|
|
195
|
+
(eventMode === "All" || isHost) && eventMode !== "CountOnly";
|
|
196
|
+
const showNetworkSlice =
|
|
197
|
+
isAuthenticated &&
|
|
198
|
+
(eventMode === "All" ||
|
|
199
|
+
eventMode === "CountOnly" ||
|
|
200
|
+
eventMode === "NetworkOnly" ||
|
|
201
|
+
isHost);
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
showCount,
|
|
205
|
+
showPreviewAvatars:
|
|
206
|
+
showPreviewAvatars &&
|
|
207
|
+
eventMode !== "CountOnly" &&
|
|
208
|
+
eventMode !== "NetworkOnly",
|
|
209
|
+
showFeatured: showFeatured && eventMode !== "NetworkOnly",
|
|
210
|
+
showNetworkSlice,
|
|
211
|
+
rosterDepth: effectiveDepth,
|
|
212
|
+
previewLimit: 4,
|
|
213
|
+
apiPreviewLimit: 10,
|
|
214
|
+
effectiveMode: eventMode,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export type PreviewAttendeeRow = {
|
|
219
|
+
userId: string;
|
|
220
|
+
paidOn?: Date | string | null;
|
|
221
|
+
checkedInAt?: Date | string | null;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/** Featured first, then most recent activity, then stable userId. */
|
|
225
|
+
export function orderPreviewAttendeeIds(
|
|
226
|
+
rows: PreviewAttendeeRow[],
|
|
227
|
+
featuredUserIds: string[],
|
|
228
|
+
limit: number
|
|
229
|
+
): string[] {
|
|
230
|
+
const featuredSet = new Set(featuredUserIds);
|
|
231
|
+
const byUser = new Map<string, PreviewAttendeeRow>();
|
|
232
|
+
for (const row of rows) {
|
|
233
|
+
if (!row.userId) continue;
|
|
234
|
+
const existing = byUser.get(row.userId);
|
|
235
|
+
if (!existing) {
|
|
236
|
+
byUser.set(row.userId, row);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const existingTs = activityTimestamp(existing);
|
|
240
|
+
const rowTs = activityTimestamp(row);
|
|
241
|
+
if (rowTs > existingTs) byUser.set(row.userId, row);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const featuredOrdered = featuredUserIds.filter((id) => byUser.has(id));
|
|
245
|
+
const rest = [...byUser.entries()]
|
|
246
|
+
.filter(([id]) => !featuredSet.has(id))
|
|
247
|
+
.sort(([, a], [, b]) => {
|
|
248
|
+
const diff = activityTimestamp(b) - activityTimestamp(a);
|
|
249
|
+
if (diff !== 0) return diff;
|
|
250
|
+
return a.userId.localeCompare(b.userId);
|
|
251
|
+
})
|
|
252
|
+
.map(([id]) => id);
|
|
253
|
+
|
|
254
|
+
return [...featuredOrdered, ...rest].slice(0, limit);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function activityTimestamp(row: PreviewAttendeeRow): number {
|
|
258
|
+
const paid =
|
|
259
|
+
row.paidOn != null ? new Date(row.paidOn as string | Date).getTime() : 0;
|
|
260
|
+
const checked =
|
|
261
|
+
row.checkedInAt != null
|
|
262
|
+
? new Date(row.checkedInAt as string | Date).getTime()
|
|
263
|
+
: 0;
|
|
264
|
+
return Math.max(paid, checked);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function filterHiddenVisibilityUserIds(
|
|
268
|
+
userIds: string[],
|
|
269
|
+
visibilityByUserId: Map<string, AttendeeVisibility>,
|
|
270
|
+
isHost: boolean
|
|
271
|
+
): string[] {
|
|
272
|
+
if (isHost) return userIds;
|
|
273
|
+
return userIds.filter((id) => {
|
|
274
|
+
const v = visibilityByUserId.get(id) ?? "Visible";
|
|
275
|
+
return v === "Visible" || v === "FeaturedPending";
|
|
276
|
+
// FeaturedInvited is hidden until the guest accepts
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function filterBlockedUserIds(
|
|
281
|
+
userIds: string[],
|
|
282
|
+
blockedIds: Set<string>
|
|
283
|
+
): string[] {
|
|
284
|
+
return userIds.filter((id) => !blockedIds.has(id));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function guestListDepthFromTierField(
|
|
288
|
+
value: string | null | undefined
|
|
289
|
+
): GuestListDepth {
|
|
290
|
+
if (value === "Partial" || value === "Full") return value;
|
|
291
|
+
return "None";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function computeUpsellFromTiers(
|
|
295
|
+
tiers: Array<{ guestListDepth?: string | null; title?: string | null; price?: number | null }>,
|
|
296
|
+
viewerDepth: GuestListDepth
|
|
297
|
+
): GuestSurfacePayload["upsell"] {
|
|
298
|
+
const deeper = tiers.filter(
|
|
299
|
+
(t) =>
|
|
300
|
+
compareGuestListDepth(
|
|
301
|
+
guestListDepthFromTierField(t.guestListDepth),
|
|
302
|
+
"None"
|
|
303
|
+
) > 0
|
|
304
|
+
);
|
|
305
|
+
if (deeper.length === 0 || compareGuestListDepth(viewerDepth, "Partial") >= 0) {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
const sorted = [...deeper].sort(
|
|
309
|
+
(a, b) => (a.price ?? 0) - (b.price ?? 0)
|
|
310
|
+
);
|
|
311
|
+
return {
|
|
312
|
+
hasDeeperTiers: true,
|
|
313
|
+
cheapestTierName: sorted[0]?.title ?? undefined,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promotion Blast — on-site partner promos (time-boxed + geofence).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type PromotionBlastPackageTierId = "starter" | "standard" | "premium";
|
|
6
|
+
|
|
7
|
+
export type PromotionBlastTriggerTypeId = "TIME_BOXED" | "GEOFENCE";
|
|
8
|
+
|
|
9
|
+
export interface PromotionBlastPackageTerms {
|
|
10
|
+
tierId: PromotionBlastPackageTierId;
|
|
11
|
+
maxBlasts: number;
|
|
12
|
+
maxPushRecipients?: number | null;
|
|
13
|
+
maxRadiusFeet: number;
|
|
14
|
+
allowedTriggerTypes: PromotionBlastTriggerTypeId[];
|
|
15
|
+
hostAccessFeeCents: number;
|
|
16
|
+
bashPlatformFeeCents: number;
|
|
17
|
+
templateId?: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Minutes before event start when partners may activate blasts. */
|
|
21
|
+
export const PROMOTION_BLAST_EVENT_WINDOW_BEFORE_MINUTES = 30;
|
|
22
|
+
|
|
23
|
+
/** Minutes after event end when partners may still activate. */
|
|
24
|
+
export const PROMOTION_BLAST_EVENT_WINDOW_AFTER_MINUTES = 30;
|
|
25
|
+
|
|
26
|
+
/** Max concurrently live partner promos per event. */
|
|
27
|
+
export const PROMOTION_BLAST_MAX_LIVE_PER_EVENT = 5;
|
|
28
|
+
|
|
29
|
+
/** Max promotional pushes per attendee per event (all partners). */
|
|
30
|
+
export const PROMOTION_BLAST_MAX_PUSHES_PER_USER_PER_EVENT = 3;
|
|
31
|
+
|
|
32
|
+
/** Min minutes between promo pushes to same user at same event. */
|
|
33
|
+
export const PROMOTION_BLAST_MIN_PUSH_INTERVAL_MINUTES = 15;
|
|
34
|
+
|
|
35
|
+
export const PROMOTION_BLAST_HEADLINE_MAX_LENGTH = 80;
|
|
36
|
+
export const PROMOTION_BLAST_BODY_MAX_LENGTH = 280;
|
|
37
|
+
export const PROMOTION_BLAST_CTA_MAX_LENGTH = 40;
|
|
38
|
+
|
|
39
|
+
export const PROMOTION_BLAST_DURATION_PRESETS_MINUTES = [15, 30, 60] as const;
|
|
40
|
+
export const PROMOTION_BLAST_RADIUS_PRESETS_FEET = [100, 200, 500] as const;
|
|
41
|
+
|
|
42
|
+
export const PROMOTION_BLAST_PACKAGES: Record<
|
|
43
|
+
PromotionBlastPackageTierId,
|
|
44
|
+
Omit<PromotionBlastPackageTerms, "hostAccessFeeCents" | "templateId">
|
|
45
|
+
> = {
|
|
46
|
+
starter: {
|
|
47
|
+
tierId: "starter",
|
|
48
|
+
maxBlasts: 1,
|
|
49
|
+
maxPushRecipients: null,
|
|
50
|
+
maxRadiusFeet: 200,
|
|
51
|
+
allowedTriggerTypes: ["TIME_BOXED"],
|
|
52
|
+
bashPlatformFeeCents: 500,
|
|
53
|
+
},
|
|
54
|
+
standard: {
|
|
55
|
+
tierId: "standard",
|
|
56
|
+
maxBlasts: 3,
|
|
57
|
+
maxPushRecipients: null,
|
|
58
|
+
maxRadiusFeet: 500,
|
|
59
|
+
allowedTriggerTypes: ["TIME_BOXED", "GEOFENCE"],
|
|
60
|
+
bashPlatformFeeCents: 1200,
|
|
61
|
+
},
|
|
62
|
+
premium: {
|
|
63
|
+
tierId: "premium",
|
|
64
|
+
maxBlasts: 999,
|
|
65
|
+
maxPushRecipients: null,
|
|
66
|
+
maxRadiusFeet: 500,
|
|
67
|
+
allowedTriggerTypes: ["TIME_BOXED", "GEOFENCE"],
|
|
68
|
+
bashPlatformFeeCents: 2500,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export function promotionBlastPackageForTier(
|
|
73
|
+
tierId: PromotionBlastPackageTierId,
|
|
74
|
+
hostAccessFeeCents: number,
|
|
75
|
+
templateId?: string | null
|
|
76
|
+
): PromotionBlastPackageTerms {
|
|
77
|
+
const base = PROMOTION_BLAST_PACKAGES[tierId];
|
|
78
|
+
return {
|
|
79
|
+
...base,
|
|
80
|
+
hostAccessFeeCents,
|
|
81
|
+
templateId: templateId ?? null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function promotionBlastPackageTotalCents(pkg: PromotionBlastPackageTerms): number {
|
|
86
|
+
return pkg.hostAccessFeeCents + pkg.bashPlatformFeeCents;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function promotionBlastPackageLabel(tierId: PromotionBlastPackageTierId): string {
|
|
90
|
+
switch (tierId) {
|
|
91
|
+
case "starter":
|
|
92
|
+
return "Starter — 1 time-boxed blast";
|
|
93
|
+
case "standard":
|
|
94
|
+
return "Standard — 3 blasts + geofence";
|
|
95
|
+
case "premium":
|
|
96
|
+
return "Premium — unlimited blasts + geofence";
|
|
97
|
+
default:
|
|
98
|
+
return tierId;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function isWithinPromotionBlastEventWindow(
|
|
103
|
+
eventStart: Date,
|
|
104
|
+
eventEnd: Date | null | undefined,
|
|
105
|
+
now: Date = new Date()
|
|
106
|
+
): boolean {
|
|
107
|
+
const windowStart = new Date(eventStart);
|
|
108
|
+
windowStart.setMinutes(windowStart.getMinutes() - PROMOTION_BLAST_EVENT_WINDOW_BEFORE_MINUTES);
|
|
109
|
+
const windowEnd = eventEnd
|
|
110
|
+
? new Date(eventEnd)
|
|
111
|
+
: new Date(eventStart.getTime() + 4 * 60 * 60 * 1000);
|
|
112
|
+
windowEnd.setMinutes(windowEnd.getMinutes() + PROMOTION_BLAST_EVENT_WINDOW_AFTER_MINUTES);
|
|
113
|
+
return now >= windowStart && now <= windowEnd;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function generatePromotionBlastConfirmationCode(): string {
|
|
117
|
+
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
118
|
+
let code = "";
|
|
119
|
+
for (let i = 0; i < 6; i++) {
|
|
120
|
+
code += chars[Math.floor(Math.random() * chars.length)];
|
|
121
|
+
}
|
|
122
|
+
return code;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function promotionBlastDeepLinkPath(bashEventId: string, blastId: string): string {
|
|
126
|
+
return `/bash/${encodeURIComponent(bashEventId)}?tab=promotions&blast=${encodeURIComponent(blastId)}`;
|
|
127
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
calcLegacyRateEstimateCents,
|
|
3
|
+
computeBashEventDurationHours,
|
|
4
|
+
getDefaultOfferAmountCents,
|
|
5
|
+
isBulkHostOfferEligible,
|
|
6
|
+
} from "../defaultOfferAmountUtils.js";
|
|
7
|
+
|
|
8
|
+
describe("calcLegacyRateEstimateCents", () => {
|
|
9
|
+
it("uses flat rate when set", () => {
|
|
10
|
+
expect(
|
|
11
|
+
calcLegacyRateEstimateCents({ flatRateCents: 50_000 }, 4)
|
|
12
|
+
).toBe(50_000);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("uses daily rate for 8+ hour events", () => {
|
|
16
|
+
expect(
|
|
17
|
+
calcLegacyRateEstimateCents(
|
|
18
|
+
{ hourlyRateCents: 15_000, dailyRateCents: 50_000 },
|
|
19
|
+
8
|
|
20
|
+
)
|
|
21
|
+
).toBe(50_000);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("uses hourly × duration for shorter events", () => {
|
|
25
|
+
expect(
|
|
26
|
+
calcLegacyRateEstimateCents(
|
|
27
|
+
{ hourlyRateCents: 15_000, dailyRateCents: 50_000 },
|
|
28
|
+
4
|
|
29
|
+
)
|
|
30
|
+
).toBe(60_000);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("computeBashEventDurationHours", () => {
|
|
35
|
+
it("returns hours between start and end", () => {
|
|
36
|
+
expect(
|
|
37
|
+
computeBashEventDurationHours(
|
|
38
|
+
"2026-06-01T18:00:00.000Z",
|
|
39
|
+
"2026-06-01T22:00:00.000Z"
|
|
40
|
+
)
|
|
41
|
+
).toBe(4);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("defaults to 1 when dates missing", () => {
|
|
45
|
+
expect(computeBashEventDurationHours(null, null)).toBe(1);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("getDefaultOfferAmountCents", () => {
|
|
50
|
+
it("prefers unified HOURLY model with duration", () => {
|
|
51
|
+
expect(
|
|
52
|
+
getDefaultOfferAmountCents({
|
|
53
|
+
service: {
|
|
54
|
+
entertainmentService: {
|
|
55
|
+
pricingModel: "HOURLY",
|
|
56
|
+
baseRateCents: 20_000,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
durationHours: 3,
|
|
60
|
+
})
|
|
61
|
+
).toBe(60_000);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("falls back to legacy general rates", () => {
|
|
65
|
+
expect(
|
|
66
|
+
getDefaultOfferAmountCents({
|
|
67
|
+
service: {
|
|
68
|
+
serviceRatesAssociation: {
|
|
69
|
+
serviceGeneralRates: { dailyRateCents: 75_000 },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
durationHours: 10,
|
|
73
|
+
})
|
|
74
|
+
).toBe(75_000);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("computes PER_ATTENDEE from expected guests", () => {
|
|
78
|
+
expect(
|
|
79
|
+
getDefaultOfferAmountCents({
|
|
80
|
+
service: {
|
|
81
|
+
eventService: {
|
|
82
|
+
pricingModel: "PER_ATTENDEE",
|
|
83
|
+
baseRateCents: 500,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
durationHours: 4,
|
|
87
|
+
expectedAttendees: 100,
|
|
88
|
+
})
|
|
89
|
+
).toBe(50_000);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("isBulkHostOfferEligible", () => {
|
|
94
|
+
it("rejects participation-fee services", () => {
|
|
95
|
+
expect(isBulkHostOfferEligible(50_000, true)).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("requires a positive amount", () => {
|
|
99
|
+
expect(isBulkHostOfferEligible(0, false)).toBe(false);
|
|
100
|
+
expect(isBulkHostOfferEligible(25_000, false)).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic default offer amount for a service + bash context.
|
|
3
|
+
* Keeps service cards, budget bar, SendOfferSheet prefill, and bulk offers aligned.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
computeOfferComparisonBands,
|
|
7
|
+
type OfferComparisonInput,
|
|
8
|
+
} from "./offerComparisonUtils.js";
|
|
9
|
+
import {
|
|
10
|
+
resolveUnifiedPricingBlock,
|
|
11
|
+
type UnifiedPricingSourceCandidates,
|
|
12
|
+
} from "./unifiedPricingResolver.js";
|
|
13
|
+
import { SERVICE_DAILY_RATE_HOURS_MIN } from "./serviceRateUtils.js";
|
|
14
|
+
|
|
15
|
+
export { SERVICE_DAILY_RATE_HOURS_MIN };
|
|
16
|
+
|
|
17
|
+
export interface LegacyGeneralRatesShape {
|
|
18
|
+
flatRateCents?: number | null;
|
|
19
|
+
hourlyRateCents?: number | null;
|
|
20
|
+
dailyRateCents?: number | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type DefaultOfferAmountServiceShape = UnifiedPricingSourceCandidates & {
|
|
24
|
+
serviceRatesAssociation?: {
|
|
25
|
+
serviceGeneralRates?: LegacyGeneralRatesShape | null;
|
|
26
|
+
} | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface DefaultOfferAmountParams {
|
|
30
|
+
service: DefaultOfferAmountServiceShape;
|
|
31
|
+
durationHours: number;
|
|
32
|
+
expectedAttendees?: number | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Event length in hours; defaults to 1 when dates are missing or invalid. */
|
|
36
|
+
export function computeBashEventDurationHours(
|
|
37
|
+
startDateTime: Date | string | null | undefined,
|
|
38
|
+
endDateTime: Date | string | null | undefined
|
|
39
|
+
): number {
|
|
40
|
+
if (!startDateTime || !endDateTime) return 1;
|
|
41
|
+
const start = new Date(startDateTime).getTime();
|
|
42
|
+
const end = new Date(endDateTime).getTime();
|
|
43
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) return 1;
|
|
44
|
+
const hours = (end - start) / (1000 * 60 * 60);
|
|
45
|
+
return hours >= 0.25 ? hours : 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Legacy published-rate estimate (flat → daily if 8+ hrs → hourly × duration). */
|
|
49
|
+
export function calcLegacyRateEstimateCents(
|
|
50
|
+
rates: LegacyGeneralRatesShape | null | undefined,
|
|
51
|
+
durationHours: number
|
|
52
|
+
): number {
|
|
53
|
+
if (!rates) return 0;
|
|
54
|
+
if ((rates.flatRateCents ?? 0) > 0) return rates.flatRateCents!;
|
|
55
|
+
const hours = durationHours >= 0.25 ? durationHours : 1;
|
|
56
|
+
if (
|
|
57
|
+
hours >= SERVICE_DAILY_RATE_HOURS_MIN &&
|
|
58
|
+
(rates.dailyRateCents ?? 0) > 0
|
|
59
|
+
) {
|
|
60
|
+
return rates.dailyRateCents!;
|
|
61
|
+
}
|
|
62
|
+
if ((rates.hourlyRateCents ?? 0) > 0) {
|
|
63
|
+
return Math.round(rates.hourlyRateCents! * hours);
|
|
64
|
+
}
|
|
65
|
+
if ((rates.dailyRateCents ?? 0) > 0) return rates.dailyRateCents!;
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function unifiedModelEstimateCents(
|
|
70
|
+
pricingModel: string,
|
|
71
|
+
block: {
|
|
72
|
+
baseRateCents: number | null;
|
|
73
|
+
baseRateBashPoints: number | null;
|
|
74
|
+
minimumChargeCents: number | null;
|
|
75
|
+
minimumQuantity: number | null;
|
|
76
|
+
},
|
|
77
|
+
durationHours: number,
|
|
78
|
+
expectedAttendees: number | null | undefined
|
|
79
|
+
): number {
|
|
80
|
+
const base = block.baseRateCents ?? 0;
|
|
81
|
+
const hours = durationHours >= 0.25 ? durationHours : 1;
|
|
82
|
+
|
|
83
|
+
switch (pricingModel) {
|
|
84
|
+
case "HOURLY":
|
|
85
|
+
return base > 0 ? Math.round(base * hours) : 0;
|
|
86
|
+
case "PER_DAY":
|
|
87
|
+
if (base <= 0) return 0;
|
|
88
|
+
if (hours >= SERVICE_DAILY_RATE_HOURS_MIN) return base;
|
|
89
|
+
return Math.round(base * (hours / SERVICE_DAILY_RATE_HOURS_MIN));
|
|
90
|
+
case "FLAT_FEE":
|
|
91
|
+
case "CUSTOM":
|
|
92
|
+
return base > 0 ? base : 0;
|
|
93
|
+
case "PER_ATTENDEE":
|
|
94
|
+
case "PER_PAID_ATTENDEE": {
|
|
95
|
+
const bands = computeOfferComparisonBands({
|
|
96
|
+
pricingModel,
|
|
97
|
+
baseRateCents: base,
|
|
98
|
+
minimumChargeCents: block.minimumChargeCents,
|
|
99
|
+
minimumQuantity: block.minimumQuantity,
|
|
100
|
+
expectedAttendees,
|
|
101
|
+
} satisfies OfferComparisonInput);
|
|
102
|
+
return bands.expectedTotalCents ?? 0;
|
|
103
|
+
}
|
|
104
|
+
case "PER_BASHPOINTS_ATTENDEE":
|
|
105
|
+
return 0;
|
|
106
|
+
case "TIERED": {
|
|
107
|
+
const bands = computeOfferComparisonBands({
|
|
108
|
+
pricingModel: "TIERED",
|
|
109
|
+
tieredExpectedTierCents: base > 0 ? base : null,
|
|
110
|
+
});
|
|
111
|
+
return bands.expectedTotalCents ?? 0;
|
|
112
|
+
}
|
|
113
|
+
default: {
|
|
114
|
+
const bands = computeOfferComparisonBands({
|
|
115
|
+
pricingModel,
|
|
116
|
+
baseRateCents: base,
|
|
117
|
+
flatRateCents: base > 0 ? base : null,
|
|
118
|
+
});
|
|
119
|
+
return bands.expectedTotalCents ?? 0;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Returns the suggested opening offer in USD cents for this bash, or 0 when
|
|
126
|
+
* no computable rate exists (bulk send should skip those services).
|
|
127
|
+
*/
|
|
128
|
+
export function getDefaultOfferAmountCents(
|
|
129
|
+
params: DefaultOfferAmountParams
|
|
130
|
+
): number {
|
|
131
|
+
const { service, durationHours, expectedAttendees } = params;
|
|
132
|
+
const legacyRates = service.serviceRatesAssociation?.serviceGeneralRates;
|
|
133
|
+
|
|
134
|
+
const block = resolveUnifiedPricingBlock({
|
|
135
|
+
eventService: service.eventService,
|
|
136
|
+
vendor: service.vendor,
|
|
137
|
+
exhibitor: service.exhibitor,
|
|
138
|
+
sponsor: service.sponsor,
|
|
139
|
+
venue: service.venue,
|
|
140
|
+
entertainmentService: service.entertainmentService,
|
|
141
|
+
volunteerService: service.volunteerService,
|
|
142
|
+
legacyRate: null,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (block.fromUnifiedBlock && block.pricingModel) {
|
|
146
|
+
const unified = unifiedModelEstimateCents(
|
|
147
|
+
block.pricingModel,
|
|
148
|
+
block,
|
|
149
|
+
durationHours,
|
|
150
|
+
expectedAttendees
|
|
151
|
+
);
|
|
152
|
+
if (unified > 0) return unified;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return calcLegacyRateEstimateCents(legacyRates, durationHours);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function formatOfferAmountDollars(cents: number): string {
|
|
159
|
+
return `$${Math.round(cents / 100).toLocaleString("en-US")}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Host → provider bulk offers only when a positive USD default exists. */
|
|
163
|
+
export function isBulkHostOfferEligible(
|
|
164
|
+
amountCents: number,
|
|
165
|
+
isParticipationFeeService: boolean
|
|
166
|
+
): boolean {
|
|
167
|
+
return !isParticipationFeeService && amountCents > 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const BULK_OFFER_MAX_COUNT = 10;
|