@bash-app/bash-common 30.381.0 → 30.385.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__/accessScreening.test.js +39 -1
- package/dist/__tests__/accessScreening.test.js.map +1 -1
- package/dist/__tests__/applicationFunnel.test.d.ts +2 -0
- package/dist/__tests__/applicationFunnel.test.d.ts.map +1 -0
- package/dist/__tests__/applicationFunnel.test.js +126 -0
- package/dist/__tests__/applicationFunnel.test.js.map +1 -0
- package/dist/__tests__/definitionsHelpers.test.js +20 -1
- package/dist/__tests__/definitionsHelpers.test.js.map +1 -1
- package/dist/__tests__/fundingMeter.test.d.ts +2 -0
- package/dist/__tests__/fundingMeter.test.d.ts.map +1 -0
- package/dist/__tests__/fundingMeter.test.js +129 -0
- package/dist/__tests__/fundingMeter.test.js.map +1 -0
- package/dist/__tests__/giveaway.test.d.ts +2 -0
- package/dist/__tests__/giveaway.test.d.ts.map +1 -0
- package/dist/__tests__/giveaway.test.js +65 -0
- package/dist/__tests__/giveaway.test.js.map +1 -0
- package/dist/__tests__/promoterCompetitionUtils.test.d.ts +2 -0
- package/dist/__tests__/promoterCompetitionUtils.test.d.ts.map +1 -0
- package/dist/__tests__/promoterCompetitionUtils.test.js +262 -0
- package/dist/__tests__/promoterCompetitionUtils.test.js.map +1 -0
- package/dist/accessScreening.d.ts +18 -3
- package/dist/accessScreening.d.ts.map +1 -1
- package/dist/accessScreening.js +26 -0
- package/dist/accessScreening.js.map +1 -1
- package/dist/applicationFunnel.d.ts +58 -0
- package/dist/applicationFunnel.d.ts.map +1 -0
- package/dist/applicationFunnel.js +147 -0
- package/dist/applicationFunnel.js.map +1 -0
- package/dist/definitions.d.ts +145 -5
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js +137 -5
- package/dist/definitions.js.map +1 -1
- package/dist/extendedSchemas.d.ts +166 -1
- package/dist/extendedSchemas.d.ts.map +1 -1
- package/dist/extendedSchemas.js +7 -1
- package/dist/extendedSchemas.js.map +1 -1
- package/dist/fundingMeter.d.ts +66 -0
- package/dist/fundingMeter.d.ts.map +1 -0
- package/dist/fundingMeter.js +99 -0
- package/dist/fundingMeter.js.map +1 -0
- package/dist/giveaway.d.ts +71 -0
- package/dist/giveaway.d.ts.map +1 -0
- package/dist/giveaway.js +117 -0
- package/dist/giveaway.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/__tests__/bashHostIdentity.test.d.ts +2 -0
- package/dist/utils/__tests__/bashHostIdentity.test.d.ts.map +1 -0
- package/dist/utils/__tests__/bashHostIdentity.test.js +39 -0
- package/dist/utils/__tests__/bashHostIdentity.test.js.map +1 -0
- package/dist/utils/__tests__/promoterCompetitionUtils.test.d.ts +2 -0
- package/dist/utils/__tests__/promoterCompetitionUtils.test.d.ts.map +1 -0
- package/dist/utils/__tests__/promoterCompetitionUtils.test.js +104 -0
- package/dist/utils/__tests__/promoterCompetitionUtils.test.js.map +1 -0
- package/dist/utils/__tests__/slugUtils.test.js +29 -2
- package/dist/utils/__tests__/slugUtils.test.js.map +1 -1
- package/dist/utils/bashHostIdentity.d.ts +18 -0
- package/dist/utils/bashHostIdentity.d.ts.map +1 -0
- package/dist/utils/bashHostIdentity.js +17 -0
- package/dist/utils/bashHostIdentity.js.map +1 -0
- package/dist/utils/promoterCompetitionUtils.d.ts +89 -0
- package/dist/utils/promoterCompetitionUtils.d.ts.map +1 -0
- package/dist/utils/promoterCompetitionUtils.js +158 -0
- package/dist/utils/promoterCompetitionUtils.js.map +1 -0
- package/dist/utils/slugUtils.d.ts +2 -0
- package/dist/utils/slugUtils.d.ts.map +1 -1
- package/dist/utils/slugUtils.js +5 -1
- package/dist/utils/slugUtils.js.map +1 -1
- package/package.json +1 -1
- package/prisma/schema.prisma +415 -25
- package/src/__tests__/accessScreening.test.ts +69 -1
- package/src/__tests__/applicationFunnel.test.ts +197 -0
- package/src/__tests__/definitionsHelpers.test.ts +24 -0
- package/src/__tests__/fundingMeter.test.ts +143 -0
- package/src/__tests__/giveaway.test.ts +111 -0
- package/src/__tests__/promoterCompetitionUtils.test.ts +334 -0
- package/src/accessScreening.ts +49 -0
- package/src/applicationFunnel.ts +225 -0
- package/src/definitions.ts +181 -5
- package/src/extendedSchemas.ts +127 -1
- package/src/fundingMeter.ts +188 -0
- package/src/giveaway.ts +182 -0
- package/src/index.ts +14 -0
- package/src/utils/__tests__/bashHostIdentity.test.ts +57 -0
- package/src/utils/__tests__/promoterCompetitionUtils.test.ts +128 -0
- package/src/utils/__tests__/slugUtils.test.ts +44 -0
- package/src/utils/bashHostIdentity.ts +30 -0
- package/src/utils/promoterCompetitionUtils.ts +235 -0
- package/src/utils/slugUtils.ts +11 -0
package/src/giveaway.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event giveaways — shared types, attendee identity, and guest-facing copy.
|
|
3
|
+
* Qualifying does not guarantee inventory (first-come among eligible people).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const GIVEAWAY_ELIGIBILITY = [
|
|
7
|
+
"AnyoneGoing",
|
|
8
|
+
"TicketHolders",
|
|
9
|
+
"CheckedIn",
|
|
10
|
+
"CompetitionParticipants",
|
|
11
|
+
"SideQuestCompleters",
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
export type GiveawayEligibility = (typeof GIVEAWAY_ELIGIBILITY)[number];
|
|
15
|
+
|
|
16
|
+
export const GIVEAWAY_ATTENDEE_PREFIX = {
|
|
17
|
+
ticket: "ticket:",
|
|
18
|
+
rsvp: "rsvp:",
|
|
19
|
+
guest: "guest:",
|
|
20
|
+
user: "user:",
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
export type GiveawayAttendeeResolution = {
|
|
24
|
+
attendeeId: string;
|
|
25
|
+
userId?: string;
|
|
26
|
+
ticketId?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type PublicGiveaway = {
|
|
30
|
+
id: string;
|
|
31
|
+
bashEventId: string;
|
|
32
|
+
name: string;
|
|
33
|
+
description: string | null;
|
|
34
|
+
imageUrl: string | null;
|
|
35
|
+
quantityTotal: number;
|
|
36
|
+
quantityRemaining: number;
|
|
37
|
+
displayOrder: number;
|
|
38
|
+
sponsorLabel: string | null;
|
|
39
|
+
eligibility: GiveawayEligibility;
|
|
40
|
+
competitionId: string | null;
|
|
41
|
+
sideQuestId: string | null;
|
|
42
|
+
pickupNote: string | null;
|
|
43
|
+
qualifyCopy: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type GiveawayScanPayload = {
|
|
47
|
+
eventId: string;
|
|
48
|
+
ticketId: string;
|
|
49
|
+
userId?: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function isGiveawayEligibility(value: unknown): value is GiveawayEligibility {
|
|
53
|
+
return (
|
|
54
|
+
typeof value === "string" &&
|
|
55
|
+
(GIVEAWAY_ELIGIBILITY as readonly string[]).includes(value)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildGiveawayAttendeeIdFromTicket(ticketId: string): string {
|
|
60
|
+
return `${GIVEAWAY_ATTENDEE_PREFIX.ticket}${ticketId}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildGiveawayAttendeeIdFromRsvp(rsvpId: string): string {
|
|
64
|
+
return `${GIVEAWAY_ATTENDEE_PREFIX.rsvp}${rsvpId}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function buildGiveawayAttendeeIdFromGuest(guestIdentityId: string): string {
|
|
68
|
+
return `${GIVEAWAY_ATTENDEE_PREFIX.guest}${guestIdentityId}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildGiveawayAttendeeIdFromUser(userId: string): string {
|
|
72
|
+
return `${GIVEAWAY_ATTENDEE_PREFIX.user}${userId}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Parse door QR JSON. Accepts ticket payloads with eventId + ticketId (userId optional). */
|
|
76
|
+
export function resolveGiveawayAttendeeFromScan(
|
|
77
|
+
bashEventId: string,
|
|
78
|
+
scanPayload: string
|
|
79
|
+
): GiveawayAttendeeResolution | null {
|
|
80
|
+
const trimmed = scanPayload.trim();
|
|
81
|
+
if (!trimmed.startsWith("{")) return null;
|
|
82
|
+
try {
|
|
83
|
+
const data = JSON.parse(trimmed) as Record<string, unknown>;
|
|
84
|
+
const eventId = typeof data.eventId === "string" ? data.eventId.trim() : "";
|
|
85
|
+
const ticketId = typeof data.ticketId === "string" ? data.ticketId.trim() : "";
|
|
86
|
+
if (!eventId || !ticketId || eventId !== bashEventId) return null;
|
|
87
|
+
const userId =
|
|
88
|
+
typeof data.userId === "string" && data.userId.trim()
|
|
89
|
+
? data.userId.trim()
|
|
90
|
+
: undefined;
|
|
91
|
+
return {
|
|
92
|
+
attendeeId: buildGiveawayAttendeeIdFromTicket(ticketId),
|
|
93
|
+
ticketId,
|
|
94
|
+
...(userId ? { userId } : {}),
|
|
95
|
+
};
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function giveawayQualifyCopy(input: {
|
|
102
|
+
eligibility: GiveawayEligibility;
|
|
103
|
+
competitionName?: string | null;
|
|
104
|
+
sideQuestTitle?: string | null;
|
|
105
|
+
}): string {
|
|
106
|
+
switch (input.eligibility) {
|
|
107
|
+
case "AnyoneGoing":
|
|
108
|
+
return "Anyone going to this bash can qualify";
|
|
109
|
+
case "TicketHolders":
|
|
110
|
+
return "Ticket holders qualify";
|
|
111
|
+
case "CheckedIn":
|
|
112
|
+
return "Checked-in attendees qualify";
|
|
113
|
+
case "CompetitionParticipants":
|
|
114
|
+
return input.competitionName
|
|
115
|
+
? `Enter ${input.competitionName} to qualify`
|
|
116
|
+
: "Enter the contest to qualify";
|
|
117
|
+
case "SideQuestCompleters":
|
|
118
|
+
return input.sideQuestTitle
|
|
119
|
+
? `Complete ${input.sideQuestTitle} to qualify`
|
|
120
|
+
: "Complete the side quest to qualify";
|
|
121
|
+
default:
|
|
122
|
+
return "Qualifying attendees can receive one while supplies last";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function giveawayRemainingCopy(quantityRemaining: number): string {
|
|
127
|
+
if (quantityRemaining <= 0) return "All distributed";
|
|
128
|
+
return `${quantityRemaining} remaining`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const GIVEAWAY_WHILE_SUPPLIES_LAST =
|
|
132
|
+
"Available to qualifying attendees while supplies last." as const;
|
|
133
|
+
|
|
134
|
+
export const VALID_GIVEAWAY_TICKET_STATUSES = [
|
|
135
|
+
"Pending",
|
|
136
|
+
"CheckedIn",
|
|
137
|
+
"Attended",
|
|
138
|
+
"P2PConfirmed",
|
|
139
|
+
] as const;
|
|
140
|
+
|
|
141
|
+
export function ticketCountsAsGiveawayTicket(status: string): boolean {
|
|
142
|
+
return (VALID_GIVEAWAY_TICKET_STATUSES as readonly string[]).includes(status);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function ticketCountsAsCheckedIn(ticket: {
|
|
146
|
+
status: string;
|
|
147
|
+
checkedInAt?: Date | string | null;
|
|
148
|
+
}): boolean {
|
|
149
|
+
if (ticket.status === "CheckedIn" || ticket.status === "Attended") return true;
|
|
150
|
+
return Boolean(ticket.checkedInAt);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function normalizeGiveawayEmail(email: string | null | undefined): string {
|
|
154
|
+
return (email ?? "").trim().toLowerCase();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function matchesCompetitionParticipant(input: {
|
|
158
|
+
userId?: string | null;
|
|
159
|
+
email?: string | null;
|
|
160
|
+
participants: Array<{
|
|
161
|
+
status: string;
|
|
162
|
+
userId?: string | null;
|
|
163
|
+
email?: string | null;
|
|
164
|
+
}>;
|
|
165
|
+
}): boolean {
|
|
166
|
+
const userId = input.userId?.trim() || null;
|
|
167
|
+
const email = normalizeGiveawayEmail(input.email);
|
|
168
|
+
return input.participants.some((p) => {
|
|
169
|
+
if (p.status !== "ACCEPTED") return false;
|
|
170
|
+
if (userId && p.userId && p.userId === userId) return true;
|
|
171
|
+
const participantEmail = normalizeGiveawayEmail(p.email);
|
|
172
|
+
return Boolean(email && participantEmail && email === participantEmail);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Active outstanding inventory — not historical "ever distributed". */
|
|
177
|
+
export function giveawayActiveDistributedCount(
|
|
178
|
+
quantityTotal: number,
|
|
179
|
+
quantityRemaining: number
|
|
180
|
+
): number {
|
|
181
|
+
return Math.max(0, quantityTotal - quantityRemaining);
|
|
182
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export * from "./definitions.js";
|
|
2
|
+
export * from "./fundingMeter.js";
|
|
3
|
+
export * from "./giveaway.js";
|
|
2
4
|
export * from "./requestAuthUser.js";
|
|
3
5
|
export * from "./sms/smsTemplates.js";
|
|
4
6
|
export * from "./legalTemplates.js";
|
|
@@ -31,6 +33,7 @@ export * from "./venueMatch.js";
|
|
|
31
33
|
export * from "./entertainmentNotableVenues.js";
|
|
32
34
|
export * from "./productLaunch.js";
|
|
33
35
|
export * from "./accessScreening.js";
|
|
36
|
+
export * from "./applicationFunnel.js";
|
|
34
37
|
export * from "./hostCrmSegments.js";
|
|
35
38
|
export * from "./hostCrmCrowdPulse.js";
|
|
36
39
|
export * from "./hostCrmLastBashProof.js";
|
|
@@ -397,6 +400,7 @@ export * from "./utils/service/cancellationPolicyRefundResolver.js";
|
|
|
397
400
|
export * from "./utils/venueLoyalty/eligibilityUtils.js";
|
|
398
401
|
export * from "./utils/venueLoyalty/redemptionCodeUtils.js";
|
|
399
402
|
// Venue utils moved to paymentUtils.ts for better organization
|
|
403
|
+
export * from "./utils/bashHostIdentity.js";
|
|
400
404
|
export * from "./utils/slugUtils.js";
|
|
401
405
|
export * from "./utils/sortUtils.js";
|
|
402
406
|
export * from "./utils/stringUtils.js";
|
|
@@ -446,6 +450,16 @@ export type {
|
|
|
446
450
|
MakeRequired, Override, RemoveCommonProperties, UnionFromArray
|
|
447
451
|
} from "./utils/typeUtils.js";
|
|
448
452
|
|
|
453
|
+
// Promoter Competition utilities (pure — safe to import in bash-app and api)
|
|
454
|
+
export {
|
|
455
|
+
computePromoterCompetitionEconomics,
|
|
456
|
+
formatPromoterRewardLabel,
|
|
457
|
+
getPromoterReputationLabel,
|
|
458
|
+
suggestPromoterSalesGoal,
|
|
459
|
+
type PromoterMilestoneInput,
|
|
460
|
+
type PromoterCompetitionEconomics,
|
|
461
|
+
} from "./utils/promoterCompetitionUtils.js";
|
|
462
|
+
|
|
449
463
|
// Event Templates
|
|
450
464
|
export * from "./templates/eventTemplates.js";
|
|
451
465
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import {
|
|
3
|
+
getBashHostAvatarUrl,
|
|
4
|
+
getBashHostDisplayName,
|
|
5
|
+
} from "../bashHostIdentity.js";
|
|
6
|
+
|
|
7
|
+
describe("getBashHostDisplayName", () => {
|
|
8
|
+
it("prefers the hosted service name", () => {
|
|
9
|
+
expect(
|
|
10
|
+
getBashHostDisplayName({
|
|
11
|
+
hostedByService: { serviceName: " DJ Night " },
|
|
12
|
+
organization: { name: "Funraising" },
|
|
13
|
+
creator: { givenName: "Ada", familyName: "Host" },
|
|
14
|
+
})
|
|
15
|
+
).toBe("DJ Night");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("uses the org name when not hosted as a service", () => {
|
|
19
|
+
expect(
|
|
20
|
+
getBashHostDisplayName({
|
|
21
|
+
organization: { name: "Funraising" },
|
|
22
|
+
creator: { givenName: "Ada", familyName: "Host" },
|
|
23
|
+
})
|
|
24
|
+
).toBe("Funraising");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("falls back to the person when Self", () => {
|
|
28
|
+
expect(
|
|
29
|
+
getBashHostDisplayName({
|
|
30
|
+
creator: { givenName: "Ada", familyName: "Host" },
|
|
31
|
+
})
|
|
32
|
+
).toBe("Ada Host");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("getBashHostAvatarUrl", () => {
|
|
37
|
+
it("prefers the service cover photo", () => {
|
|
38
|
+
expect(
|
|
39
|
+
getBashHostAvatarUrl({
|
|
40
|
+
hostedByService: { coverPhoto: "https://cdn.example/svc.jpg" },
|
|
41
|
+
organization: { logoUrl: "https://cdn.example/org.png" },
|
|
42
|
+
})
|
|
43
|
+
).toBe("https://cdn.example/svc.jpg");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("uses the org logo when there is no service cover", () => {
|
|
47
|
+
expect(
|
|
48
|
+
getBashHostAvatarUrl({
|
|
49
|
+
organization: { logoUrl: "https://cdn.example/org.png" },
|
|
50
|
+
})
|
|
51
|
+
).toBe("https://cdn.example/org.png");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns null for Self so the caller can use the person photo", () => {
|
|
55
|
+
expect(getBashHostAvatarUrl({})).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, test } from "@jest/globals";
|
|
2
|
+
import { PromoterRewardMode, PromoterRewardType } from "../../definitions.js";
|
|
3
|
+
import {
|
|
4
|
+
computePromoterCompetitionEconomics,
|
|
5
|
+
formatPromoterRewardLabel,
|
|
6
|
+
suggestPromoterSalesGoal,
|
|
7
|
+
} from "../promoterCompetitionUtils.js";
|
|
8
|
+
|
|
9
|
+
describe("formatPromoterRewardLabel", () => {
|
|
10
|
+
test("formats a cash threshold as sales → dollars", () => {
|
|
11
|
+
expect(
|
|
12
|
+
formatPromoterRewardLabel({
|
|
13
|
+
threshold: 5,
|
|
14
|
+
rewardType: PromoterRewardType.CASH,
|
|
15
|
+
rewardAmountCents: 1500,
|
|
16
|
+
})
|
|
17
|
+
).toBe("5 sales → $15");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("uses Top promoter for rank 1 and description for non-cash", () => {
|
|
21
|
+
expect(
|
|
22
|
+
formatPromoterRewardLabel({
|
|
23
|
+
rank: 1,
|
|
24
|
+
rewardType: PromoterRewardType.TICKET,
|
|
25
|
+
rewardDescription: "VIP access",
|
|
26
|
+
})
|
|
27
|
+
).toBe("Top promoter → VIP access");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("uses Top N promoter for rank > 1 and the reward type when there is no description", () => {
|
|
31
|
+
expect(
|
|
32
|
+
formatPromoterRewardLabel({
|
|
33
|
+
rank: 3,
|
|
34
|
+
rewardType: PromoterRewardType.TABLE,
|
|
35
|
+
})
|
|
36
|
+
).toBe("Top 3 promoter → TABLE");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("uses ? sales when threshold is missing", () => {
|
|
40
|
+
expect(
|
|
41
|
+
formatPromoterRewardLabel({
|
|
42
|
+
rewardType: PromoterRewardType.CREDIT,
|
|
43
|
+
rewardAmountCents: 2000,
|
|
44
|
+
})
|
|
45
|
+
).toBe("? sales → $20");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("computePromoterCompetitionEconomics", () => {
|
|
50
|
+
const cash5 = {
|
|
51
|
+
threshold: 5,
|
|
52
|
+
rewardType: PromoterRewardType.CASH,
|
|
53
|
+
rewardAmountCents: 1000,
|
|
54
|
+
};
|
|
55
|
+
const cash10 = {
|
|
56
|
+
threshold: 10,
|
|
57
|
+
rewardType: PromoterRewardType.CASH,
|
|
58
|
+
rewardAmountCents: 4000,
|
|
59
|
+
};
|
|
60
|
+
const rankCash = {
|
|
61
|
+
rank: 1,
|
|
62
|
+
rewardType: PromoterRewardType.CASH,
|
|
63
|
+
rewardAmountCents: 5000,
|
|
64
|
+
};
|
|
65
|
+
const ticket = {
|
|
66
|
+
threshold: 3,
|
|
67
|
+
rewardType: PromoterRewardType.TICKET,
|
|
68
|
+
rewardDescription: "GA ticket",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
test("TOTAL_AT_MILESTONE uses the highest threshold cash plus all rank cash", () => {
|
|
72
|
+
const result = computePromoterCompetitionEconomics({
|
|
73
|
+
ticketPriceCents: 2000,
|
|
74
|
+
salesGoal: 50,
|
|
75
|
+
milestones: [cash5, cash10, rankCash, ticket],
|
|
76
|
+
rewardMode: PromoterRewardMode.TOTAL_AT_MILESTONE,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(result.grossPotentialRevenueCents).toBe(100000);
|
|
80
|
+
expect(result.maxCashRewardExposureCents).toBe(9000);
|
|
81
|
+
expect(result.effectiveAcquisitionCostPct).toBe(9);
|
|
82
|
+
expect(result.estimatedNetRevenueCents).toBe(91000);
|
|
83
|
+
expect(result.rewardBreakdown.map((row) => row.label)).toEqual([
|
|
84
|
+
"3 sales → GA ticket",
|
|
85
|
+
"5 sales → $10",
|
|
86
|
+
"10 sales → $40",
|
|
87
|
+
"Top promoter → $50",
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("ADDITIVE stacks every threshold cash and still adds rank cash", () => {
|
|
92
|
+
const result = computePromoterCompetitionEconomics({
|
|
93
|
+
ticketPriceCents: 2000,
|
|
94
|
+
salesGoal: 50,
|
|
95
|
+
milestones: [cash5, cash10, rankCash],
|
|
96
|
+
rewardMode: PromoterRewardMode.ADDITIVE,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(result.maxCashRewardExposureCents).toBe(10000);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("returns 0% acquisition cost when there is no gross revenue", () => {
|
|
103
|
+
const result = computePromoterCompetitionEconomics({
|
|
104
|
+
ticketPriceCents: 0,
|
|
105
|
+
salesGoal: 0,
|
|
106
|
+
milestones: [cash5],
|
|
107
|
+
rewardMode: PromoterRewardMode.ADDITIVE,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(result.effectiveAcquisitionCostPct).toBe(0);
|
|
111
|
+
expect(result.maxCashRewardExposureCents).toBe(1000);
|
|
112
|
+
expect(result.estimatedNetRevenueCents).toBe(-1000);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("suggestPromoterSalesGoal", () => {
|
|
117
|
+
test("returns null when capacity is missing, zero, or already sold out", () => {
|
|
118
|
+
expect(suggestPromoterSalesGoal({ capacity: null, ticketsSold: 0 })).toBeNull();
|
|
119
|
+
expect(suggestPromoterSalesGoal({ capacity: 0, ticketsSold: 0 })).toBeNull();
|
|
120
|
+
expect(suggestPromoterSalesGoal({ capacity: 40, ticketsSold: 40 })).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("treats null ticketsSold as 0 and snaps 50% remaining to a benchmark", () => {
|
|
124
|
+
expect(suggestPromoterSalesGoal({ capacity: 100, ticketsSold: null })).toBe(50);
|
|
125
|
+
expect(suggestPromoterSalesGoal({ capacity: 40, ticketsSold: 0 })).toBe(25);
|
|
126
|
+
expect(suggestPromoterSalesGoal({ capacity: 400, ticketsSold: 0 })).toBe(200);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -2,8 +2,12 @@ import { describe, expect, test } from "@jest/globals";
|
|
|
2
2
|
import {
|
|
3
3
|
BASH_DETAIL_URL,
|
|
4
4
|
BASH_EVENT_WIZARD_HOW_MAJOR_STEP,
|
|
5
|
+
BASH_EVENT_WIZARD_HOW_SUBSTEP,
|
|
5
6
|
BASH_EVENT_WIZARD_HOW_SUBSTEP_PAYOUT,
|
|
7
|
+
BASH_EVENT_WIZARD_MAJOR,
|
|
6
8
|
BASH_EVENT_WIZARD_PATH_PREFIX,
|
|
9
|
+
BASH_EVENT_WIZARD_TEMPLATES_SUBSTEP,
|
|
10
|
+
BASH_EVENT_WIZARD_WHAT_SUBSTEP,
|
|
7
11
|
} from "../../definitions.js";
|
|
8
12
|
import {
|
|
9
13
|
generateSlug,
|
|
@@ -13,6 +17,7 @@ import {
|
|
|
13
17
|
getPublicBashMemoriesPath,
|
|
14
18
|
getBashEventWizardStepPath,
|
|
15
19
|
getBashEventWizardPayoutStepPath,
|
|
20
|
+
getBashEventWizardStartPath,
|
|
16
21
|
parseBashUrlParams,
|
|
17
22
|
validateSlug,
|
|
18
23
|
} from "../slugUtils.js";
|
|
@@ -108,6 +113,45 @@ describe("getBashEventWizardPayoutStepPath", () => {
|
|
|
108
113
|
`${BASH_EVENT_WIZARD_PATH_PREFIX}/evt-1/${BASH_EVENT_WIZARD_HOW_MAJOR_STEP}/${BASH_EVENT_WIZARD_HOW_SUBSTEP_PAYOUT}`
|
|
109
114
|
);
|
|
110
115
|
});
|
|
116
|
+
|
|
117
|
+
test("How substep indices match host-flow order", () => {
|
|
118
|
+
expect(BASH_EVENT_WIZARD_HOW_SUBSTEP.PRICE).toBe(0);
|
|
119
|
+
expect(BASH_EVENT_WIZARD_HOW_SUBSTEP.APPLICATION).toBe(1);
|
|
120
|
+
expect(BASH_EVENT_WIZARD_HOW_SUBSTEP.PROMO_CODES).toBe(2);
|
|
121
|
+
expect(BASH_EVENT_WIZARD_HOW_SUBSTEP.TASKS).toBe(8);
|
|
122
|
+
expect(BASH_EVENT_WIZARD_HOW_SUBSTEP.PAYOUT).toBe(9);
|
|
123
|
+
expect(BASH_EVENT_WIZARD_HOW_SUBSTEP_PAYOUT).toBe(
|
|
124
|
+
BASH_EVENT_WIZARD_HOW_SUBSTEP.PAYOUT
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("major and What substep indices stay pinned", () => {
|
|
129
|
+
expect(BASH_EVENT_WIZARD_MAJOR.TEMPLATES).toBe(0);
|
|
130
|
+
expect(BASH_EVENT_WIZARD_MAJOR.WHO).toBe(2);
|
|
131
|
+
expect(BASH_EVENT_WIZARD_MAJOR.WHAT).toBe(3);
|
|
132
|
+
expect(BASH_EVENT_WIZARD_MAJOR.HOW).toBe(7);
|
|
133
|
+
expect(BASH_EVENT_WIZARD_MAJOR.FINISH).toBe(8);
|
|
134
|
+
expect(BASH_EVENT_WIZARD_WHAT_SUBSTEP.TITLE).toBe(0);
|
|
135
|
+
expect(BASH_EVENT_WIZARD_WHAT_SUBSTEP.TYPE).toBe(
|
|
136
|
+
BASH_EVENT_WIZARD_WHAT_SUBSTEP.TITLE
|
|
137
|
+
);
|
|
138
|
+
expect(BASH_EVENT_WIZARD_WHAT_SUBSTEP.VIBE_DRESS).toBe(1);
|
|
139
|
+
expect(BASH_EVENT_WIZARD_WHAT_SUBSTEP.SCHEDULE).toBe(2);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("getBashEventWizardStartPath", () => {
|
|
144
|
+
test("defaults to the new draft at Templates", () => {
|
|
145
|
+
expect(getBashEventWizardStartPath()).toBe(
|
|
146
|
+
`${BASH_EVENT_WIZARD_PATH_PREFIX}/new/${BASH_EVENT_WIZARD_MAJOR.TEMPLATES}/${BASH_EVENT_WIZARD_TEMPLATES_SUBSTEP.START}`
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("uses an event id when provided", () => {
|
|
151
|
+
expect(getBashEventWizardStartPath("evt-1")).toBe(
|
|
152
|
+
`${BASH_EVENT_WIZARD_PATH_PREFIX}/evt-1/${BASH_EVENT_WIZARD_MAJOR.TEMPLATES}/${BASH_EVENT_WIZARD_TEMPLATES_SUBSTEP.START}`
|
|
153
|
+
);
|
|
154
|
+
});
|
|
111
155
|
});
|
|
112
156
|
|
|
113
157
|
describe("parseBashUrlParams", () => {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { getUserName } from "./apiUtils.js";
|
|
2
|
+
|
|
3
|
+
export type BashHostIdentityInput = {
|
|
4
|
+
hostedByService?: { serviceName?: string | null; coverPhoto?: string | null } | null;
|
|
5
|
+
organization?: { name?: string | null; logoUrl?: string | null } | null;
|
|
6
|
+
/** `null` is treated the same as `undefined` (no creator info). DB relations are nullable. */
|
|
7
|
+
creator?: Parameters<typeof getUserName>[0] | null;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function trimmedOrNull(value: string | null | undefined): string | null {
|
|
11
|
+
const trimmed = value?.trim();
|
|
12
|
+
return trimmed ? trimmed : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Public host name: service, then org, then the person. */
|
|
16
|
+
export function getBashHostDisplayName(input: BashHostIdentityInput): string {
|
|
17
|
+
return (
|
|
18
|
+
trimmedOrNull(input.hostedByService?.serviceName) ??
|
|
19
|
+
trimmedOrNull(input.organization?.name) ??
|
|
20
|
+
getUserName(input.creator ?? undefined)
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Service cover or org logo when Host as is not Self. Caller falls back to the person. */
|
|
25
|
+
export function getBashHostAvatarUrl(input: BashHostIdentityInput): string | null {
|
|
26
|
+
return (
|
|
27
|
+
trimmedOrNull(input.hostedByService?.coverPhoto) ??
|
|
28
|
+
trimmedOrNull(input.organization?.logoUrl)
|
|
29
|
+
);
|
|
30
|
+
}
|