@bash-app/bash-common 30.260.1 → 30.263.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__/serviceCheckoutPaymentMethods.test.d.ts +2 -0
- package/dist/__tests__/serviceCheckoutPaymentMethods.test.d.ts.map +1 -0
- package/dist/__tests__/serviceCheckoutPaymentMethods.test.js +41 -0
- package/dist/__tests__/serviceCheckoutPaymentMethods.test.js.map +1 -0
- package/dist/__tests__/ticketBnplPaymentMethods.bankTransfer.test.d.ts +2 -0
- package/dist/__tests__/ticketBnplPaymentMethods.bankTransfer.test.d.ts.map +1 -0
- package/dist/__tests__/ticketBnplPaymentMethods.bankTransfer.test.js +30 -0
- package/dist/__tests__/ticketBnplPaymentMethods.bankTransfer.test.js.map +1 -0
- 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 +7 -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 +61 -0
- package/dist/ticketBnplPaymentMethods.d.ts.map +1 -0
- package/dist/ticketBnplPaymentMethods.js +83 -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 +340 -1
- package/src/__tests__/serviceCheckoutPaymentMethods.test.ts +59 -0
- package/src/__tests__/ticketBnplPaymentMethods.bankTransfer.test.ts +42 -0
- package/src/__tests__/ticketBnplPaymentMethods.cashapp.test.ts +45 -0
- package/src/__tests__/ticketBnplPaymentMethods.test.ts +66 -0
- package/src/definitions.ts +7 -1
- package/src/index.ts +6 -0
- package/src/ticketBnplPaymentMethods.ts +177 -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,75 @@
|
|
|
1
|
+
/** Default featured-discovery radius when the client omits `featuredRadiusMiles`. */
|
|
2
|
+
export const DEFAULT_FEATURED_RADIUS_MILES = 200;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Haversine distance in miles between two WGS84 points.
|
|
6
|
+
*/
|
|
7
|
+
export function haversineMiles(
|
|
8
|
+
lat1: number,
|
|
9
|
+
lng1: number,
|
|
10
|
+
lat2: number,
|
|
11
|
+
lng2: number
|
|
12
|
+
): number {
|
|
13
|
+
const R = 3958.8;
|
|
14
|
+
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
|
15
|
+
const dLng = ((lng2 - lng1) * Math.PI) / 180;
|
|
16
|
+
const a =
|
|
17
|
+
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
18
|
+
Math.cos((lat1 * Math.PI) / 180) *
|
|
19
|
+
Math.cos((lat2 * Math.PI) / 180) *
|
|
20
|
+
Math.sin(dLng / 2) *
|
|
21
|
+
Math.sin(dLng / 2);
|
|
22
|
+
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
23
|
+
return R * c;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ViewerGeoScope {
|
|
27
|
+
lat: number;
|
|
28
|
+
lng: number;
|
|
29
|
+
radiusMiles: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function effectiveFeaturedForGeoScope(
|
|
33
|
+
isFeatured: boolean,
|
|
34
|
+
coordinates: Array<{ lat: number | null; lng: number | null }> | null | undefined,
|
|
35
|
+
viewer: ViewerGeoScope | undefined
|
|
36
|
+
): boolean {
|
|
37
|
+
if (!isFeatured) return false;
|
|
38
|
+
if (!viewer) return true;
|
|
39
|
+
const first = coordinates?.find((c) => c.lat != null && c.lng != null);
|
|
40
|
+
if (!first || first.lat == null || first.lng == null) return false;
|
|
41
|
+
const d = haversineMiles(first.lat, first.lng, viewer.lat, viewer.lng);
|
|
42
|
+
return d <= viewer.radiusMiles;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type EventWithFeaturedAndCoords = {
|
|
46
|
+
id: string;
|
|
47
|
+
isFeatured: boolean | null;
|
|
48
|
+
featuredUntil?: Date | null;
|
|
49
|
+
startDateTime: Date | string | null;
|
|
50
|
+
coordinates?: Array<{ lat: number | null; lng: number | null }> | null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Sort: effective featured (in geo scope if viewer set) first, then by startDateTime.
|
|
55
|
+
*/
|
|
56
|
+
export function sortPublicEventsForFeaturedDiscovery<
|
|
57
|
+
T extends EventWithFeaturedAndCoords,
|
|
58
|
+
>(events: T[], viewer: ViewerGeoScope | undefined): T[] {
|
|
59
|
+
const withKeys = events.map((e) => {
|
|
60
|
+
const eff = effectiveFeaturedForGeoScope(
|
|
61
|
+
!!e.isFeatured,
|
|
62
|
+
e.coordinates,
|
|
63
|
+
viewer
|
|
64
|
+
);
|
|
65
|
+
const start = e.startDateTime
|
|
66
|
+
? new Date(e.startDateTime as Date | string).getTime()
|
|
67
|
+
: 0;
|
|
68
|
+
return { e, eff, start };
|
|
69
|
+
});
|
|
70
|
+
withKeys.sort((a, b) => {
|
|
71
|
+
if (a.eff !== b.eff) return (b.eff ? 1 : 0) - (a.eff ? 1 : 0);
|
|
72
|
+
return a.start - b.start;
|
|
73
|
+
});
|
|
74
|
+
return withKeys.map((x) => x.e);
|
|
75
|
+
}
|
package/src/utils/flyerUtils.ts
CHANGED
|
@@ -158,6 +158,39 @@ export function computeHostTotalAfterSponsors(
|
|
|
158
158
|
return Math.max(0, printPostageCostCents - sponsorRevenueCents);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/** Default slot price in cents for Featured vs Supporting flyer slots. */
|
|
162
|
+
export function flyerSponsorSlotPriceCents(
|
|
163
|
+
slot: "FEATURED" | "SUPPORTING_1" | "SUPPORTING_2",
|
|
164
|
+
requestedPrice?: number
|
|
165
|
+
): number {
|
|
166
|
+
if (requestedPrice != null) return requestedPrice;
|
|
167
|
+
return slot === "FEATURED"
|
|
168
|
+
? FLYER_SPONSOR_DEFAULT_FEATURED_CENTS
|
|
169
|
+
: FLYER_SPONSOR_DEFAULT_SUPPORTING_CENTS;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Host revenue share for a flyer sponsor slot. */
|
|
173
|
+
export function flyerSponsorSlotHostPct(
|
|
174
|
+
slot: "FEATURED" | "SUPPORTING_1" | "SUPPORTING_2"
|
|
175
|
+
): number {
|
|
176
|
+
return slot === "FEATURED" ? 0.6 : 0.65;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function flyerSponsorSlotLabel(
|
|
180
|
+
slot: "FEATURED" | "SUPPORTING_1" | "SUPPORTING_2"
|
|
181
|
+
): string {
|
|
182
|
+
switch (slot) {
|
|
183
|
+
case "FEATURED":
|
|
184
|
+
return "Featured";
|
|
185
|
+
case "SUPPORTING_1":
|
|
186
|
+
return "Supporting 1";
|
|
187
|
+
case "SUPPORTING_2":
|
|
188
|
+
return "Supporting 2";
|
|
189
|
+
default:
|
|
190
|
+
return slot;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
161
194
|
const EARTH_RADIUS_MI = 3958.8;
|
|
162
195
|
|
|
163
196
|
/** Haversine distance in miles between two WGS84 points. */
|
|
@@ -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
|
+
});
|