@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.
Files changed (107) hide show
  1. package/dist/__tests__/accessScreening.test.js +39 -1
  2. package/dist/__tests__/accessScreening.test.js.map +1 -1
  3. package/dist/__tests__/applicationFunnel.test.d.ts +2 -0
  4. package/dist/__tests__/applicationFunnel.test.d.ts.map +1 -0
  5. package/dist/__tests__/applicationFunnel.test.js +126 -0
  6. package/dist/__tests__/applicationFunnel.test.js.map +1 -0
  7. package/dist/__tests__/definitionsHelpers.test.js +20 -1
  8. package/dist/__tests__/definitionsHelpers.test.js.map +1 -1
  9. package/dist/__tests__/fundingMeter.test.d.ts +2 -0
  10. package/dist/__tests__/fundingMeter.test.d.ts.map +1 -0
  11. package/dist/__tests__/fundingMeter.test.js +129 -0
  12. package/dist/__tests__/fundingMeter.test.js.map +1 -0
  13. package/dist/__tests__/giveaway.test.d.ts +2 -0
  14. package/dist/__tests__/giveaway.test.d.ts.map +1 -0
  15. package/dist/__tests__/giveaway.test.js +65 -0
  16. package/dist/__tests__/giveaway.test.js.map +1 -0
  17. package/dist/__tests__/promoterCompetitionUtils.test.d.ts +2 -0
  18. package/dist/__tests__/promoterCompetitionUtils.test.d.ts.map +1 -0
  19. package/dist/__tests__/promoterCompetitionUtils.test.js +262 -0
  20. package/dist/__tests__/promoterCompetitionUtils.test.js.map +1 -0
  21. package/dist/accessScreening.d.ts +18 -3
  22. package/dist/accessScreening.d.ts.map +1 -1
  23. package/dist/accessScreening.js +26 -0
  24. package/dist/accessScreening.js.map +1 -1
  25. package/dist/applicationFunnel.d.ts +58 -0
  26. package/dist/applicationFunnel.d.ts.map +1 -0
  27. package/dist/applicationFunnel.js +147 -0
  28. package/dist/applicationFunnel.js.map +1 -0
  29. package/dist/definitions.d.ts +146 -5
  30. package/dist/definitions.d.ts.map +1 -1
  31. package/dist/definitions.js +138 -5
  32. package/dist/definitions.js.map +1 -1
  33. package/dist/extendedSchemas.d.ts +174 -0
  34. package/dist/extendedSchemas.d.ts.map +1 -1
  35. package/dist/extendedSchemas.js +15 -0
  36. package/dist/extendedSchemas.js.map +1 -1
  37. package/dist/fundingMeter.d.ts +66 -0
  38. package/dist/fundingMeter.d.ts.map +1 -0
  39. package/dist/fundingMeter.js +99 -0
  40. package/dist/fundingMeter.js.map +1 -0
  41. package/dist/giveaway.d.ts +71 -0
  42. package/dist/giveaway.d.ts.map +1 -0
  43. package/dist/giveaway.js +117 -0
  44. package/dist/giveaway.js.map +1 -0
  45. package/dist/index.d.ts +5 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +6 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/utils/__tests__/bashHostIdentity.test.d.ts +2 -0
  50. package/dist/utils/__tests__/bashHostIdentity.test.d.ts.map +1 -0
  51. package/dist/utils/__tests__/bashHostIdentity.test.js +39 -0
  52. package/dist/utils/__tests__/bashHostIdentity.test.js.map +1 -0
  53. package/dist/utils/__tests__/paymentUtils.test.js +28 -1
  54. package/dist/utils/__tests__/paymentUtils.test.js.map +1 -1
  55. package/dist/utils/__tests__/promoterCompetitionUtils.test.d.ts +2 -0
  56. package/dist/utils/__tests__/promoterCompetitionUtils.test.d.ts.map +1 -0
  57. package/dist/utils/__tests__/promoterCompetitionUtils.test.js +104 -0
  58. package/dist/utils/__tests__/promoterCompetitionUtils.test.js.map +1 -0
  59. package/dist/utils/__tests__/serviceAvailabilityDisplay.test.js +56 -1
  60. package/dist/utils/__tests__/serviceAvailabilityDisplay.test.js.map +1 -1
  61. package/dist/utils/__tests__/slugUtils.test.js +31 -2
  62. package/dist/utils/__tests__/slugUtils.test.js.map +1 -1
  63. package/dist/utils/bashHostIdentity.d.ts +18 -0
  64. package/dist/utils/bashHostIdentity.d.ts.map +1 -0
  65. package/dist/utils/bashHostIdentity.js +17 -0
  66. package/dist/utils/bashHostIdentity.js.map +1 -0
  67. package/dist/utils/paymentUtils.d.ts +18 -1
  68. package/dist/utils/paymentUtils.d.ts.map +1 -1
  69. package/dist/utils/paymentUtils.js +29 -2
  70. package/dist/utils/paymentUtils.js.map +1 -1
  71. package/dist/utils/promoterCompetitionUtils.d.ts +89 -0
  72. package/dist/utils/promoterCompetitionUtils.d.ts.map +1 -0
  73. package/dist/utils/promoterCompetitionUtils.js +158 -0
  74. package/dist/utils/promoterCompetitionUtils.js.map +1 -0
  75. package/dist/utils/serviceAvailabilityDisplay.d.ts +28 -2
  76. package/dist/utils/serviceAvailabilityDisplay.d.ts.map +1 -1
  77. package/dist/utils/serviceAvailabilityDisplay.js +39 -0
  78. package/dist/utils/serviceAvailabilityDisplay.js.map +1 -1
  79. package/dist/utils/slugUtils.d.ts +2 -0
  80. package/dist/utils/slugUtils.d.ts.map +1 -1
  81. package/dist/utils/slugUtils.js +5 -1
  82. package/dist/utils/slugUtils.js.map +1 -1
  83. package/package.json +1 -1
  84. package/prisma/schema.prisma +465 -29
  85. package/src/__tests__/accessScreening.test.ts +69 -1
  86. package/src/__tests__/applicationFunnel.test.ts +197 -0
  87. package/src/__tests__/definitionsHelpers.test.ts +24 -0
  88. package/src/__tests__/fundingMeter.test.ts +143 -0
  89. package/src/__tests__/giveaway.test.ts +111 -0
  90. package/src/__tests__/promoterCompetitionUtils.test.ts +334 -0
  91. package/src/accessScreening.ts +49 -0
  92. package/src/applicationFunnel.ts +225 -0
  93. package/src/definitions.ts +182 -5
  94. package/src/extendedSchemas.ts +131 -0
  95. package/src/fundingMeter.ts +188 -0
  96. package/src/giveaway.ts +182 -0
  97. package/src/index.ts +14 -0
  98. package/src/utils/__tests__/bashHostIdentity.test.ts +57 -0
  99. package/src/utils/__tests__/paymentUtils.test.ts +36 -0
  100. package/src/utils/__tests__/promoterCompetitionUtils.test.ts +128 -0
  101. package/src/utils/__tests__/serviceAvailabilityDisplay.test.ts +73 -0
  102. package/src/utils/__tests__/slugUtils.test.ts +46 -0
  103. package/src/utils/bashHostIdentity.ts +30 -0
  104. package/src/utils/paymentUtils.ts +47 -2
  105. package/src/utils/promoterCompetitionUtils.ts +235 -0
  106. package/src/utils/serviceAvailabilityDisplay.ts +82 -2
  107. 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 {
@@ -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('-');