@bash-app/bash-common 30.383.0 → 30.386.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 +146 -5
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js +138 -5
- package/dist/definitions.js.map +1 -1
- package/dist/extendedSchemas.d.ts +174 -0
- package/dist/extendedSchemas.d.ts.map +1 -1
- package/dist/extendedSchemas.js +15 -0
- 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__/paymentUtils.test.js +28 -1
- package/dist/utils/__tests__/paymentUtils.test.js.map +1 -1
- 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__/serviceAvailabilityDisplay.test.js +56 -1
- package/dist/utils/__tests__/serviceAvailabilityDisplay.test.js.map +1 -1
- package/dist/utils/__tests__/slugUtils.test.js +31 -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/paymentUtils.d.ts +18 -1
- package/dist/utils/paymentUtils.d.ts.map +1 -1
- package/dist/utils/paymentUtils.js +29 -2
- package/dist/utils/paymentUtils.js.map +1 -1
- 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/serviceAvailabilityDisplay.d.ts +28 -2
- package/dist/utils/serviceAvailabilityDisplay.d.ts.map +1 -1
- package/dist/utils/serviceAvailabilityDisplay.js +39 -0
- package/dist/utils/serviceAvailabilityDisplay.js.map +1 -1
- 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 +465 -29
- 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 +182 -5
- package/src/extendedSchemas.ts +131 -0
- 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__/paymentUtils.test.ts +36 -0
- package/src/utils/__tests__/promoterCompetitionUtils.test.ts +128 -0
- package/src/utils/__tests__/serviceAvailabilityDisplay.test.ts +73 -0
- package/src/utils/__tests__/slugUtils.test.ts +46 -0
- package/src/utils/bashHostIdentity.ts +30 -0
- package/src/utils/paymentUtils.ts +47 -2
- package/src/utils/promoterCompetitionUtils.ts +235 -0
- package/src/utils/serviceAvailabilityDisplay.ts +82 -2
- package/src/utils/slugUtils.ts +11 -0
|
@@ -430,6 +430,32 @@ export function lowestGuestAllInPriceCents(
|
|
|
430
430
|
return min;
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
+
/** Max share of attributed ticket subtotal a host can assign to a performer (50%). */
|
|
434
|
+
export const MAX_TICKET_COMMISSION_BPS = 5000;
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Floor cents owed to a promo-code performer from the discounted ticket subtotal.
|
|
438
|
+
* Guest price is unchanged; this amount is withheld from the host via application_fee.
|
|
439
|
+
*/
|
|
440
|
+
export function ticketCommissionCents(
|
|
441
|
+
basisCents: number,
|
|
442
|
+
commissionBps: number | null | undefined
|
|
443
|
+
): number {
|
|
444
|
+
if (basisCents <= 0) return 0;
|
|
445
|
+
const bps = Math.floor(Number(commissionBps) || 0);
|
|
446
|
+
if (bps <= 0) return 0;
|
|
447
|
+
const clamped = Math.min(MAX_TICKET_COMMISSION_BPS, bps);
|
|
448
|
+
return Math.floor((basisCents * clamped) / 10_000);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function clampTicketCommissionBps(
|
|
452
|
+
commissionBps: number | null | undefined
|
|
453
|
+
): number {
|
|
454
|
+
const bps = Math.floor(Number(commissionBps) || 0);
|
|
455
|
+
if (bps <= 0) return 0;
|
|
456
|
+
return Math.min(MAX_TICKET_COMMISSION_BPS, bps);
|
|
457
|
+
}
|
|
458
|
+
|
|
433
459
|
/**
|
|
434
460
|
* Compute the Stripe Connect `application_fee_amount` for a ticket-style charge,
|
|
435
461
|
* along with its constituent parts.
|
|
@@ -441,8 +467,10 @@ export function lowestGuestAllInPriceCents(
|
|
|
441
467
|
* - `bashPlatformTotalCents` and `guestPlatformLineCents` are both zero
|
|
442
468
|
* - `totalChargedCents` collapses to the discounted subtotal (no guest-line fee added)
|
|
443
469
|
* - `applicationFeeAmountCents` equals `stripeProcessingCents`
|
|
470
|
+
* plus optional `performerCommissionCents`
|
|
444
471
|
*
|
|
445
|
-
* Default (`false`) preserves existing math bit-exact for every current caller
|
|
472
|
+
* Default (`false`) preserves existing math bit-exact for every current caller
|
|
473
|
+
* when `performerCommissionCents` is omitted or 0.
|
|
446
474
|
*/
|
|
447
475
|
export function computeTicketApplicationFeeAmountCents(args: {
|
|
448
476
|
discountedSubtotalCents: number;
|
|
@@ -456,6 +484,11 @@ export function computeTicketApplicationFeeAmountCents(args: {
|
|
|
456
484
|
* NEVER from `BashEvent.nonProfit`, which is user-entered.
|
|
457
485
|
*/
|
|
458
486
|
isVerifiedNonprofitHost?: boolean;
|
|
487
|
+
/**
|
|
488
|
+
* Performer / street-team cut withheld from the host (not added to the guest).
|
|
489
|
+
* Must be less than remaining charge after Stripe + Bash platform fees.
|
|
490
|
+
*/
|
|
491
|
+
performerCommissionCents?: number;
|
|
459
492
|
}): {
|
|
460
493
|
totalChargedCents: number;
|
|
461
494
|
/** Stripe's 2.9% + $0.30 pass-through; unchanged by the nonprofit waiver. */
|
|
@@ -467,15 +500,27 @@ export function computeTicketApplicationFeeAmountCents(args: {
|
|
|
467
500
|
guestPlatformLineCents: number;
|
|
468
501
|
/** Guest-visible Stripe processing line (0 when host absorbs). */
|
|
469
502
|
guestProcessingLineCents: number;
|
|
503
|
+
/** Host-withheld performer cut included in application_fee. */
|
|
504
|
+
performerCommissionCents: number;
|
|
470
505
|
} {
|
|
471
506
|
const breakdown = computeGuestTicketCheckoutBreakdown(args);
|
|
507
|
+
const performerCommissionCents = Math.max(
|
|
508
|
+
0,
|
|
509
|
+
Math.floor(args.performerCommissionCents ?? 0)
|
|
510
|
+
);
|
|
511
|
+
const maxWithhold = Math.max(
|
|
512
|
+
0,
|
|
513
|
+
breakdown.totalDueCents - breakdown.applicationFeeAmountCents - 1
|
|
514
|
+
);
|
|
515
|
+
const withheld = Math.min(performerCommissionCents, maxWithhold);
|
|
472
516
|
return {
|
|
473
517
|
totalChargedCents: breakdown.totalDueCents,
|
|
474
518
|
stripeProcessingCents: breakdown.stripeProcessingCents,
|
|
475
519
|
bashPlatformTotalCents: breakdown.bashPlatformTotalCents,
|
|
476
|
-
applicationFeeAmountCents: breakdown.applicationFeeAmountCents,
|
|
520
|
+
applicationFeeAmountCents: breakdown.applicationFeeAmountCents + withheld,
|
|
477
521
|
guestPlatformLineCents: breakdown.guestPlatformLineCents,
|
|
478
522
|
guestProcessingLineCents: breakdown.guestProcessingLineCents,
|
|
523
|
+
performerCommissionCents: withheld,
|
|
479
524
|
};
|
|
480
525
|
}
|
|
481
526
|
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promoter Competition — pure utility functions for the Street Team / Promoter
|
|
3
|
+
* Competition workstream.
|
|
4
|
+
*
|
|
5
|
+
* No side effects, no DB imports — safe to import in bash-app (browser) and in api.
|
|
6
|
+
*/
|
|
7
|
+
import { PromoterRewardMode, PromoterRewardType } from '../definitions.js';
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Shared input / output types
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
export interface PromoterMilestoneInput {
|
|
14
|
+
threshold?: number | null;
|
|
15
|
+
rank?: number | null;
|
|
16
|
+
rewardType: PromoterRewardType;
|
|
17
|
+
rewardAmountCents?: number | null;
|
|
18
|
+
rewardDescription?: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PromoterCompetitionEconomics {
|
|
22
|
+
grossPotentialRevenueCents: number;
|
|
23
|
+
maxCashRewardExposureCents: number;
|
|
24
|
+
/** 0–100 range; maxCashReward / grossRevenue × 100, rounded to 1 decimal. */
|
|
25
|
+
effectiveAcquisitionCostPct: number;
|
|
26
|
+
/** gross − maxCashReward */
|
|
27
|
+
estimatedNetRevenueCents: number;
|
|
28
|
+
rewardBreakdown: Array<{
|
|
29
|
+
/** e.g. "5 sales → $15" or "Top 1 promoter → VIP access" */
|
|
30
|
+
label: string;
|
|
31
|
+
rewardAmountCents: number | null;
|
|
32
|
+
}>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// formatPromoterRewardLabel
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Produces a single human-readable label for a promoter milestone.
|
|
41
|
+
*
|
|
42
|
+
* Examples:
|
|
43
|
+
* - threshold=5, CASH, rewardAmountCents=1500 → "5 sales → $15"
|
|
44
|
+
* - rank=1, TICKET, rewardDescription="VIP access" → "Top 1 promoter → VIP access"
|
|
45
|
+
* - threshold=10, TICKET, no description → "10 sales → TICKET"
|
|
46
|
+
*/
|
|
47
|
+
export function formatPromoterRewardLabel(milestone: {
|
|
48
|
+
threshold?: number | null;
|
|
49
|
+
rank?: number | null;
|
|
50
|
+
rewardType: PromoterRewardType;
|
|
51
|
+
rewardAmountCents?: number | null;
|
|
52
|
+
rewardDescription?: string | null;
|
|
53
|
+
}): string {
|
|
54
|
+
const isCashOrCredit =
|
|
55
|
+
milestone.rewardType === PromoterRewardType.CASH ||
|
|
56
|
+
milestone.rewardType === PromoterRewardType.CREDIT;
|
|
57
|
+
|
|
58
|
+
const rewardDisplay = isCashOrCredit && milestone.rewardAmountCents != null
|
|
59
|
+
? `$${(milestone.rewardAmountCents / 100).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
|
|
60
|
+
: milestone.rewardDescription ?? milestone.rewardType;
|
|
61
|
+
|
|
62
|
+
if (milestone.rank != null) {
|
|
63
|
+
const ordinal = milestone.rank === 1 ? 'Top promoter' : `Top ${milestone.rank} promoter`;
|
|
64
|
+
return `${ordinal} → ${rewardDisplay}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const threshold = milestone.threshold ?? '?';
|
|
68
|
+
return `${threshold} sales → ${rewardDisplay}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// computePromoterCompetitionEconomics
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Pure economics calculator for a PROMOTER competition.
|
|
77
|
+
*
|
|
78
|
+
* Lets hosts sanity-check their milestone structure before publishing:
|
|
79
|
+
* how much revenue could the event generate and what is the max cash
|
|
80
|
+
* payout exposure?
|
|
81
|
+
*
|
|
82
|
+
* Rank-based (rank != null) CASH/CREDIT milestones are always added to the
|
|
83
|
+
* max cash exposure regardless of rewardMode because they represent real
|
|
84
|
+
* monetary obligations (just settled at competition close, not per-sale).
|
|
85
|
+
*/
|
|
86
|
+
export function computePromoterCompetitionEconomics(params: {
|
|
87
|
+
ticketPriceCents: number;
|
|
88
|
+
salesGoal: number;
|
|
89
|
+
milestones: PromoterMilestoneInput[];
|
|
90
|
+
rewardMode: PromoterRewardMode;
|
|
91
|
+
}): PromoterCompetitionEconomics {
|
|
92
|
+
const { ticketPriceCents, salesGoal, milestones, rewardMode } = params;
|
|
93
|
+
|
|
94
|
+
const grossPotentialRevenueCents = ticketPriceCents * salesGoal;
|
|
95
|
+
|
|
96
|
+
// Partition milestones into threshold-based and rank-based.
|
|
97
|
+
const isCash = (m: PromoterMilestoneInput): boolean =>
|
|
98
|
+
m.rewardType === PromoterRewardType.CASH ||
|
|
99
|
+
m.rewardType === PromoterRewardType.CREDIT;
|
|
100
|
+
|
|
101
|
+
const thresholdCash = milestones.filter(
|
|
102
|
+
(m) => m.rank == null && m.threshold != null && isCash(m) && m.rewardAmountCents != null
|
|
103
|
+
);
|
|
104
|
+
const rankCash = milestones.filter(
|
|
105
|
+
(m) => m.rank != null && isCash(m) && m.rewardAmountCents != null
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
let thresholdExposure = 0;
|
|
109
|
+
if (rewardMode === PromoterRewardMode.TOTAL_AT_MILESTONE) {
|
|
110
|
+
// Only the highest single threshold reward is effective at once.
|
|
111
|
+
thresholdExposure = Math.max(0, ...thresholdCash.map((m) => m.rewardAmountCents ?? 0));
|
|
112
|
+
} else {
|
|
113
|
+
// ADDITIVE — every threshold reward stacks.
|
|
114
|
+
thresholdExposure = thresholdCash.reduce(
|
|
115
|
+
(sum, m) => sum + (m.rewardAmountCents ?? 0),
|
|
116
|
+
0
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Rank-based cash rewards always add on top (regardless of mode).
|
|
121
|
+
const rankExposure = rankCash.reduce(
|
|
122
|
+
(sum, m) => sum + (m.rewardAmountCents ?? 0),
|
|
123
|
+
0
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const maxCashRewardExposureCents = thresholdExposure + rankExposure;
|
|
127
|
+
|
|
128
|
+
const effectiveAcquisitionCostPct =
|
|
129
|
+
grossPotentialRevenueCents > 0
|
|
130
|
+
? Math.round((maxCashRewardExposureCents / grossPotentialRevenueCents) * 1000) / 10
|
|
131
|
+
: 0;
|
|
132
|
+
|
|
133
|
+
const estimatedNetRevenueCents = grossPotentialRevenueCents - maxCashRewardExposureCents;
|
|
134
|
+
|
|
135
|
+
// Build rewardBreakdown — threshold milestones sorted ascending, rank milestones last.
|
|
136
|
+
const sortedMilestones = [...milestones].sort((a, b) => {
|
|
137
|
+
const aIsRank = a.rank != null;
|
|
138
|
+
const bIsRank = b.rank != null;
|
|
139
|
+
if (aIsRank && !bIsRank) return 1;
|
|
140
|
+
if (!aIsRank && bIsRank) return -1;
|
|
141
|
+
if (!aIsRank && !bIsRank) {
|
|
142
|
+
return (a.threshold ?? Infinity) - (b.threshold ?? Infinity);
|
|
143
|
+
}
|
|
144
|
+
// Both rank-based — sort by rank ascending.
|
|
145
|
+
return (a.rank ?? Infinity) - (b.rank ?? Infinity);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const rewardBreakdown = sortedMilestones.map((m) => ({
|
|
149
|
+
label: formatPromoterRewardLabel(m),
|
|
150
|
+
rewardAmountCents: m.rewardAmountCents ?? null,
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
grossPotentialRevenueCents,
|
|
155
|
+
maxCashRewardExposureCents,
|
|
156
|
+
effectiveAcquisitionCostPct,
|
|
157
|
+
estimatedNetRevenueCents,
|
|
158
|
+
rewardBreakdown,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// suggestPromoterSalesGoal
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
const SALES_GOAL_SNAP_POINTS = [25, 50, 75, 100, 150, 200] as const;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Suggests a sales goal for the promoter competition wizard.
|
|
170
|
+
*
|
|
171
|
+
* Logic: take 40–60% of remaining capacity (capacity − ticketsSold), then snap
|
|
172
|
+
* to the nearest common benchmark in [25, 50, 75, 100, 150, 200]. Returns null
|
|
173
|
+
* when data is insufficient (null inputs or capacity ≤ 0).
|
|
174
|
+
*/
|
|
175
|
+
export function suggestPromoterSalesGoal(params: {
|
|
176
|
+
capacity: number | null;
|
|
177
|
+
ticketsSold: number | null;
|
|
178
|
+
}): number | null {
|
|
179
|
+
const { capacity, ticketsSold } = params;
|
|
180
|
+
if (capacity == null || capacity <= 0) return null;
|
|
181
|
+
|
|
182
|
+
const sold = ticketsSold ?? 0;
|
|
183
|
+
const remaining = Math.max(0, capacity - sold);
|
|
184
|
+
if (remaining === 0) return null;
|
|
185
|
+
|
|
186
|
+
// Target the midpoint of 40–60% of remaining.
|
|
187
|
+
const target = remaining * 0.5;
|
|
188
|
+
|
|
189
|
+
// Snap to the nearest benchmark.
|
|
190
|
+
let best: number = SALES_GOAL_SNAP_POINTS[0];
|
|
191
|
+
let bestDist = Math.abs(target - best);
|
|
192
|
+
|
|
193
|
+
for (const snap of SALES_GOAL_SNAP_POINTS) {
|
|
194
|
+
const dist = Math.abs(target - snap);
|
|
195
|
+
if (dist < bestDist) {
|
|
196
|
+
bestDist = dist;
|
|
197
|
+
best = snap;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return best;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// getPromoterReputationLabel
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Produces a human-readable promoter reputation label based on lifetime stats.
|
|
210
|
+
* Returns null if the promoter has no completed events yet.
|
|
211
|
+
*
|
|
212
|
+
* Priority order (first match wins):
|
|
213
|
+
* 1. Competition winner (rank=1 achieved)
|
|
214
|
+
* 2. 100+ lifetime tickets sold
|
|
215
|
+
* 3. Repeat collaborator (worked with 2+ organizers more than once)
|
|
216
|
+
* 4. Experienced promoter (3+ events promoted)
|
|
217
|
+
* 5. Top seller within a competition (any milestone reached)
|
|
218
|
+
*/
|
|
219
|
+
export function getPromoterReputationLabel(stats: {
|
|
220
|
+
lifetimeQualifiedTickets: number;
|
|
221
|
+
totalCompetitionsWon: number;
|
|
222
|
+
totalEventsPromoted: number;
|
|
223
|
+
repeatOrganizerCount: number;
|
|
224
|
+
totalMilestonesReached?: number;
|
|
225
|
+
}): string | null {
|
|
226
|
+
if (stats.totalEventsPromoted < 1) return null;
|
|
227
|
+
|
|
228
|
+
if (stats.totalCompetitionsWon >= 1) return 'Competition Winner';
|
|
229
|
+
if (stats.lifetimeQualifiedTickets >= 100) return '100+ Tickets Sold';
|
|
230
|
+
if (stats.repeatOrganizerCount >= 2) return 'Repeat Collaborator';
|
|
231
|
+
if (stats.totalEventsPromoted >= 3) return 'Experienced Promoter';
|
|
232
|
+
if ((stats.totalMilestonesReached ?? 0) >= 1) return 'Top Seller';
|
|
233
|
+
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
@@ -30,6 +30,23 @@ export interface AvailabilityBlockedRange {
|
|
|
30
30
|
end: Date | string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Minimum shape needed to decide whether a service can take a bash on a
|
|
35
|
+
* calendar day. One confirmed/approved booking occupies the whole day.
|
|
36
|
+
*/
|
|
37
|
+
export interface ServiceDayAvailabilityInput {
|
|
38
|
+
availableHours?: unknown;
|
|
39
|
+
serviceRatesAssociation?: {
|
|
40
|
+
serviceSpecialRates?: Array<{
|
|
41
|
+
isAvailable?: boolean | null;
|
|
42
|
+
startDate?: Date | string | null;
|
|
43
|
+
endDate?: Date | string | null;
|
|
44
|
+
}> | null;
|
|
45
|
+
} | null;
|
|
46
|
+
/** Occupied ranges from Confirmed/Approved bookings (public list). */
|
|
47
|
+
bookedRanges?: readonly AvailabilityBlockedRange[] | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
33
50
|
const WEEKDAY_NAMES = [
|
|
34
51
|
"Monday",
|
|
35
52
|
"Tuesday",
|
|
@@ -156,9 +173,72 @@ export function isAvailabilityDayOpen(
|
|
|
156
173
|
return openDays[name] !== false;
|
|
157
174
|
}
|
|
158
175
|
|
|
176
|
+
/** Owner-blocked ranges from `ServiceSpecialRates` where `isAvailable` is false. */
|
|
177
|
+
export function blockedRangesFromSpecialRates(
|
|
178
|
+
specialRates:
|
|
179
|
+
| NonNullable<
|
|
180
|
+
NonNullable<
|
|
181
|
+
ServiceDayAvailabilityInput["serviceRatesAssociation"]
|
|
182
|
+
>["serviceSpecialRates"]
|
|
183
|
+
>
|
|
184
|
+
| null
|
|
185
|
+
| undefined
|
|
186
|
+
): AvailabilityBlockedRange[] {
|
|
187
|
+
if (!specialRates) return [];
|
|
188
|
+
const ranges: AvailabilityBlockedRange[] = [];
|
|
189
|
+
for (const rate of specialRates) {
|
|
190
|
+
if (rate?.isAvailable === false && rate.startDate && rate.endDate) {
|
|
191
|
+
ranges.push({ start: rate.startDate, end: rate.endDate });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return ranges;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* True when the service can take a bash on `date`: weekly hours are open,
|
|
199
|
+
* the owner has not blocked the day, and no confirmed/approved booking
|
|
200
|
+
* already occupies it. Missing hours mean every weekday is treated as open.
|
|
201
|
+
*/
|
|
202
|
+
export function isServiceAvailableOnDate(
|
|
203
|
+
service: ServiceDayAvailabilityInput,
|
|
204
|
+
date: Date
|
|
205
|
+
): boolean {
|
|
206
|
+
const config = parseAvailableHours(service.availableHours);
|
|
207
|
+
if (!isAvailabilityDayOpen(config?.openDays, date)) return false;
|
|
208
|
+
if (
|
|
209
|
+
isDateBlocked(
|
|
210
|
+
blockedRangesFromSpecialRates(
|
|
211
|
+
service.serviceRatesAssociation?.serviceSpecialRates
|
|
212
|
+
),
|
|
213
|
+
date
|
|
214
|
+
)
|
|
215
|
+
) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
if (isDateBlocked(service.bookedRanges ?? [], date)) return false;
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function filterServicesAvailableOnDate<T extends ServiceDayAvailabilityInput>(
|
|
223
|
+
services: readonly T[],
|
|
224
|
+
date: Date
|
|
225
|
+
): T[] {
|
|
226
|
+
return services.filter((service) => isServiceAvailableOnDate(service, date));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function countServicesAvailableOnDate<
|
|
230
|
+
T extends ServiceDayAvailabilityInput,
|
|
231
|
+
>(services: readonly T[], date: Date): number {
|
|
232
|
+
let count = 0;
|
|
233
|
+
for (const service of services) {
|
|
234
|
+
if (isServiceAvailableOnDate(service, date)) count += 1;
|
|
235
|
+
}
|
|
236
|
+
return count;
|
|
237
|
+
}
|
|
238
|
+
|
|
159
239
|
/** Whether a date falls within any owner-blocked (unavailable) range. */
|
|
160
240
|
export function isDateBlocked(
|
|
161
|
-
blockedRanges: AvailabilityBlockedRange[],
|
|
241
|
+
blockedRanges: readonly AvailabilityBlockedRange[],
|
|
162
242
|
date: Date
|
|
163
243
|
): boolean {
|
|
164
244
|
const day = startOfLocalDay(date).getTime();
|
|
@@ -177,7 +257,7 @@ export function isDateBlocked(
|
|
|
177
257
|
*/
|
|
178
258
|
export function computeEarliestOpenDate(params: {
|
|
179
259
|
openDays?: Record<string, boolean> | null;
|
|
180
|
-
blockedRanges?: AvailabilityBlockedRange[] | null;
|
|
260
|
+
blockedRanges?: readonly AvailabilityBlockedRange[] | null;
|
|
181
261
|
fromDate?: Date;
|
|
182
262
|
maxLookaheadDays?: number;
|
|
183
263
|
}): Date | null {
|
package/src/utils/slugUtils.ts
CHANGED
|
@@ -3,7 +3,9 @@ import {
|
|
|
3
3
|
BASH_DETAIL_URL,
|
|
4
4
|
BASH_EVENT_WIZARD_HOW_MAJOR_STEP,
|
|
5
5
|
BASH_EVENT_WIZARD_HOW_SUBSTEP_PAYOUT,
|
|
6
|
+
BASH_EVENT_WIZARD_MAJOR,
|
|
6
7
|
BASH_EVENT_WIZARD_PATH_PREFIX,
|
|
8
|
+
BASH_EVENT_WIZARD_TEMPLATES_SUBSTEP,
|
|
7
9
|
} from '../definitions.js';
|
|
8
10
|
|
|
9
11
|
type SlugifyOptions = {
|
|
@@ -112,6 +114,15 @@ export function getBashEventWizardPayoutStepPath(bashEventId: string): string {
|
|
|
112
114
|
);
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
/** Create / resume at Getting Started (Templates). Defaults to the `new` draft. */
|
|
118
|
+
export function getBashEventWizardStartPath(bashEventId = "new"): string {
|
|
119
|
+
return getBashEventWizardStepPath(
|
|
120
|
+
bashEventId,
|
|
121
|
+
BASH_EVENT_WIZARD_MAJOR.TEMPLATES,
|
|
122
|
+
BASH_EVENT_WIZARD_TEMPLATES_SUBSTEP.START
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
115
126
|
export function parseBashUrlParams(param: string): { id: string; slug: string | null } | null {
|
|
116
127
|
// Expected format: "id-slug" where id is first part before first dash
|
|
117
128
|
const dashIndex = param.indexOf('-');
|