@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.
Files changed (89) hide show
  1. package/dist/__tests__/serviceCheckoutPaymentMethods.test.d.ts +2 -0
  2. package/dist/__tests__/serviceCheckoutPaymentMethods.test.d.ts.map +1 -0
  3. package/dist/__tests__/serviceCheckoutPaymentMethods.test.js +41 -0
  4. package/dist/__tests__/serviceCheckoutPaymentMethods.test.js.map +1 -0
  5. package/dist/__tests__/ticketBnplPaymentMethods.bankTransfer.test.d.ts +2 -0
  6. package/dist/__tests__/ticketBnplPaymentMethods.bankTransfer.test.d.ts.map +1 -0
  7. package/dist/__tests__/ticketBnplPaymentMethods.bankTransfer.test.js +30 -0
  8. package/dist/__tests__/ticketBnplPaymentMethods.bankTransfer.test.js.map +1 -0
  9. package/dist/__tests__/ticketBnplPaymentMethods.cashapp.test.d.ts +2 -0
  10. package/dist/__tests__/ticketBnplPaymentMethods.cashapp.test.d.ts.map +1 -0
  11. package/dist/__tests__/ticketBnplPaymentMethods.cashapp.test.js +33 -0
  12. package/dist/__tests__/ticketBnplPaymentMethods.cashapp.test.js.map +1 -0
  13. package/dist/__tests__/ticketBnplPaymentMethods.test.d.ts +2 -0
  14. package/dist/__tests__/ticketBnplPaymentMethods.test.d.ts.map +1 -0
  15. package/dist/__tests__/ticketBnplPaymentMethods.test.js +46 -0
  16. package/dist/__tests__/ticketBnplPaymentMethods.test.js.map +1 -0
  17. package/dist/definitions.d.ts +7 -1
  18. package/dist/definitions.d.ts.map +1 -1
  19. package/dist/definitions.js.map +1 -1
  20. package/dist/index.d.ts +6 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +6 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/ticketBnplPaymentMethods.d.ts +61 -0
  25. package/dist/ticketBnplPaymentMethods.d.ts.map +1 -0
  26. package/dist/ticketBnplPaymentMethods.js +83 -0
  27. package/dist/ticketBnplPaymentMethods.js.map +1 -0
  28. package/dist/utils/__tests__/featuredDiscoveryGeo.test.d.ts +2 -0
  29. package/dist/utils/__tests__/featuredDiscoveryGeo.test.d.ts.map +1 -0
  30. package/dist/utils/__tests__/featuredDiscoveryGeo.test.js +98 -0
  31. package/dist/utils/__tests__/featuredDiscoveryGeo.test.js.map +1 -0
  32. package/dist/utils/__tests__/flyerUtils.test.js +7 -1
  33. package/dist/utils/__tests__/flyerUtils.test.js.map +1 -1
  34. package/dist/utils/__tests__/guestSurface.test.d.ts +2 -0
  35. package/dist/utils/__tests__/guestSurface.test.d.ts.map +1 -0
  36. package/dist/utils/__tests__/guestSurface.test.js +94 -0
  37. package/dist/utils/__tests__/guestSurface.test.js.map +1 -0
  38. package/dist/utils/__tests__/ticketTierSchedule.test.d.ts +2 -0
  39. package/dist/utils/__tests__/ticketTierSchedule.test.d.ts.map +1 -0
  40. package/dist/utils/__tests__/ticketTierSchedule.test.js +84 -0
  41. package/dist/utils/__tests__/ticketTierSchedule.test.js.map +1 -0
  42. package/dist/utils/featuredDiscoveryGeo.d.ts +30 -0
  43. package/dist/utils/featuredDiscoveryGeo.d.ts.map +1 -0
  44. package/dist/utils/featuredDiscoveryGeo.js +47 -0
  45. package/dist/utils/featuredDiscoveryGeo.js.map +1 -0
  46. package/dist/utils/flyerUtils.d.ts +5 -0
  47. package/dist/utils/flyerUtils.d.ts.map +1 -1
  48. package/dist/utils/flyerUtils.js +24 -0
  49. package/dist/utils/flyerUtils.js.map +1 -1
  50. package/dist/utils/guestSurface.d.ts +114 -0
  51. package/dist/utils/guestSurface.d.ts.map +1 -0
  52. package/dist/utils/guestSurface.js +164 -0
  53. package/dist/utils/guestSurface.js.map +1 -0
  54. package/dist/utils/promotionBlastUtils.d.ts +38 -0
  55. package/dist/utils/promotionBlastUtils.d.ts.map +1 -0
  56. package/dist/utils/promotionBlastUtils.js +88 -0
  57. package/dist/utils/promotionBlastUtils.js.map +1 -0
  58. package/dist/utils/service/__tests__/defaultOfferAmountUtils.test.d.ts +2 -0
  59. package/dist/utils/service/__tests__/defaultOfferAmountUtils.test.d.ts.map +1 -0
  60. package/dist/utils/service/__tests__/defaultOfferAmountUtils.test.js +65 -0
  61. package/dist/utils/service/__tests__/defaultOfferAmountUtils.test.js.map +1 -0
  62. package/dist/utils/service/defaultOfferAmountUtils.d.ts +32 -0
  63. package/dist/utils/service/defaultOfferAmountUtils.d.ts.map +1 -0
  64. package/dist/utils/service/defaultOfferAmountUtils.js +115 -0
  65. package/dist/utils/service/defaultOfferAmountUtils.js.map +1 -0
  66. package/dist/utils/ticketTierSchedule.d.ts +58 -0
  67. package/dist/utils/ticketTierSchedule.d.ts.map +1 -0
  68. package/dist/utils/ticketTierSchedule.js +279 -0
  69. package/dist/utils/ticketTierSchedule.js.map +1 -0
  70. package/package.json +5 -2
  71. package/prisma/schema.prisma +340 -1
  72. package/src/__tests__/serviceCheckoutPaymentMethods.test.ts +59 -0
  73. package/src/__tests__/ticketBnplPaymentMethods.bankTransfer.test.ts +42 -0
  74. package/src/__tests__/ticketBnplPaymentMethods.cashapp.test.ts +45 -0
  75. package/src/__tests__/ticketBnplPaymentMethods.test.ts +66 -0
  76. package/src/definitions.ts +7 -1
  77. package/src/index.ts +6 -0
  78. package/src/ticketBnplPaymentMethods.ts +177 -0
  79. package/src/utils/__tests__/featuredDiscoveryGeo.test.ts +120 -0
  80. package/src/utils/__tests__/flyerUtils.test.ts +10 -0
  81. package/src/utils/__tests__/guestSurface.test.ts +128 -0
  82. package/src/utils/__tests__/ticketTierSchedule.test.ts +131 -0
  83. package/src/utils/featuredDiscoveryGeo.ts +75 -0
  84. package/src/utils/flyerUtils.ts +33 -0
  85. package/src/utils/guestSurface.ts +315 -0
  86. package/src/utils/promotionBlastUtils.ts +127 -0
  87. package/src/utils/service/__tests__/defaultOfferAmountUtils.test.ts +102 -0
  88. package/src/utils/service/defaultOfferAmountUtils.ts +170 -0
  89. package/src/utils/ticketTierSchedule.ts +387 -0
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Deterministic default offer amount for a service + bash context.
3
+ * Keeps service cards, budget bar, SendOfferSheet prefill, and bulk offers aligned.
4
+ */
5
+ import {
6
+ computeOfferComparisonBands,
7
+ type OfferComparisonInput,
8
+ } from "./offerComparisonUtils.js";
9
+ import {
10
+ resolveUnifiedPricingBlock,
11
+ type UnifiedPricingSourceCandidates,
12
+ } from "./unifiedPricingResolver.js";
13
+ import { SERVICE_DAILY_RATE_HOURS_MIN } from "./serviceRateUtils.js";
14
+
15
+ export { SERVICE_DAILY_RATE_HOURS_MIN };
16
+
17
+ export interface LegacyGeneralRatesShape {
18
+ flatRateCents?: number | null;
19
+ hourlyRateCents?: number | null;
20
+ dailyRateCents?: number | null;
21
+ }
22
+
23
+ export type DefaultOfferAmountServiceShape = UnifiedPricingSourceCandidates & {
24
+ serviceRatesAssociation?: {
25
+ serviceGeneralRates?: LegacyGeneralRatesShape | null;
26
+ } | null;
27
+ };
28
+
29
+ export interface DefaultOfferAmountParams {
30
+ service: DefaultOfferAmountServiceShape;
31
+ durationHours: number;
32
+ expectedAttendees?: number | null;
33
+ }
34
+
35
+ /** Event length in hours; defaults to 1 when dates are missing or invalid. */
36
+ export function computeBashEventDurationHours(
37
+ startDateTime: Date | string | null | undefined,
38
+ endDateTime: Date | string | null | undefined
39
+ ): number {
40
+ if (!startDateTime || !endDateTime) return 1;
41
+ const start = new Date(startDateTime).getTime();
42
+ const end = new Date(endDateTime).getTime();
43
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return 1;
44
+ const hours = (end - start) / (1000 * 60 * 60);
45
+ return hours >= 0.25 ? hours : 1;
46
+ }
47
+
48
+ /** Legacy published-rate estimate (flat → daily if 8+ hrs → hourly × duration). */
49
+ export function calcLegacyRateEstimateCents(
50
+ rates: LegacyGeneralRatesShape | null | undefined,
51
+ durationHours: number
52
+ ): number {
53
+ if (!rates) return 0;
54
+ if ((rates.flatRateCents ?? 0) > 0) return rates.flatRateCents!;
55
+ const hours = durationHours >= 0.25 ? durationHours : 1;
56
+ if (
57
+ hours >= SERVICE_DAILY_RATE_HOURS_MIN &&
58
+ (rates.dailyRateCents ?? 0) > 0
59
+ ) {
60
+ return rates.dailyRateCents!;
61
+ }
62
+ if ((rates.hourlyRateCents ?? 0) > 0) {
63
+ return Math.round(rates.hourlyRateCents! * hours);
64
+ }
65
+ if ((rates.dailyRateCents ?? 0) > 0) return rates.dailyRateCents!;
66
+ return 0;
67
+ }
68
+
69
+ function unifiedModelEstimateCents(
70
+ pricingModel: string,
71
+ block: {
72
+ baseRateCents: number | null;
73
+ baseRateBashPoints: number | null;
74
+ minimumChargeCents: number | null;
75
+ minimumQuantity: number | null;
76
+ },
77
+ durationHours: number,
78
+ expectedAttendees: number | null | undefined
79
+ ): number {
80
+ const base = block.baseRateCents ?? 0;
81
+ const hours = durationHours >= 0.25 ? durationHours : 1;
82
+
83
+ switch (pricingModel) {
84
+ case "HOURLY":
85
+ return base > 0 ? Math.round(base * hours) : 0;
86
+ case "PER_DAY":
87
+ if (base <= 0) return 0;
88
+ if (hours >= SERVICE_DAILY_RATE_HOURS_MIN) return base;
89
+ return Math.round(base * (hours / SERVICE_DAILY_RATE_HOURS_MIN));
90
+ case "FLAT_FEE":
91
+ case "CUSTOM":
92
+ return base > 0 ? base : 0;
93
+ case "PER_ATTENDEE":
94
+ case "PER_PAID_ATTENDEE": {
95
+ const bands = computeOfferComparisonBands({
96
+ pricingModel,
97
+ baseRateCents: base,
98
+ minimumChargeCents: block.minimumChargeCents,
99
+ minimumQuantity: block.minimumQuantity,
100
+ expectedAttendees,
101
+ } satisfies OfferComparisonInput);
102
+ return bands.expectedTotalCents ?? 0;
103
+ }
104
+ case "PER_BASHPOINTS_ATTENDEE":
105
+ return 0;
106
+ case "TIERED": {
107
+ const bands = computeOfferComparisonBands({
108
+ pricingModel: "TIERED",
109
+ tieredExpectedTierCents: base > 0 ? base : null,
110
+ });
111
+ return bands.expectedTotalCents ?? 0;
112
+ }
113
+ default: {
114
+ const bands = computeOfferComparisonBands({
115
+ pricingModel,
116
+ baseRateCents: base,
117
+ flatRateCents: base > 0 ? base : null,
118
+ });
119
+ return bands.expectedTotalCents ?? 0;
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Returns the suggested opening offer in USD cents for this bash, or 0 when
126
+ * no computable rate exists (bulk send should skip those services).
127
+ */
128
+ export function getDefaultOfferAmountCents(
129
+ params: DefaultOfferAmountParams
130
+ ): number {
131
+ const { service, durationHours, expectedAttendees } = params;
132
+ const legacyRates = service.serviceRatesAssociation?.serviceGeneralRates;
133
+
134
+ const block = resolveUnifiedPricingBlock({
135
+ eventService: service.eventService,
136
+ vendor: service.vendor,
137
+ exhibitor: service.exhibitor,
138
+ sponsor: service.sponsor,
139
+ venue: service.venue,
140
+ entertainmentService: service.entertainmentService,
141
+ volunteerService: service.volunteerService,
142
+ legacyRate: null,
143
+ });
144
+
145
+ if (block.fromUnifiedBlock && block.pricingModel) {
146
+ const unified = unifiedModelEstimateCents(
147
+ block.pricingModel,
148
+ block,
149
+ durationHours,
150
+ expectedAttendees
151
+ );
152
+ if (unified > 0) return unified;
153
+ }
154
+
155
+ return calcLegacyRateEstimateCents(legacyRates, durationHours);
156
+ }
157
+
158
+ export function formatOfferAmountDollars(cents: number): string {
159
+ return `$${Math.round(cents / 100).toLocaleString("en-US")}`;
160
+ }
161
+
162
+ /** Host → provider bulk offers only when a positive USD default exists. */
163
+ export function isBulkHostOfferEligible(
164
+ amountCents: number,
165
+ isParticipationFeeService: boolean
166
+ ): boolean {
167
+ return !isParticipationFeeService && amountCents > 0;
168
+ }
169
+
170
+ export const BULK_OFFER_MAX_COUNT = 10;
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Ticket tier release / expiry / sold-out schedule resolver — shared by API + bash-app.
3
+ */
4
+
5
+ import type { TicketTierAvailability } from "../definitions.js";
6
+
7
+ export type PreReleaseDisplay = "Tease" | "Hidden";
8
+
9
+ export type PresaleAudience = "Public" | "Ambashador" | "BashInsider" | "Promoters";
10
+
11
+ export type TicketTierSchedulePhase =
12
+ | "HostHidden"
13
+ | "HiddenPreRelease"
14
+ | "TeasePreRelease"
15
+ | "Live"
16
+ | "Expired"
17
+ | "SoldOut";
18
+
19
+ export type TicketTierScheduleInput = {
20
+ availabilityType?: string | null;
21
+ availableAt?: Date | string | null;
22
+ expiresAt?: Date | string | null;
23
+ preReleaseDisplay?: string | null;
24
+ hidden?: boolean | null;
25
+ isManuallySoldOut?: boolean | null;
26
+ isWaitlistTier?: boolean | null;
27
+ availabilityTypeUnlock?: string | null;
28
+ availableWhenInterestReached?: number | null;
29
+ unlockWhenTicketsSold?: number | null;
30
+ presaleAudience?: string | null;
31
+ publicAvailableAt?: Date | string | null;
32
+ };
33
+
34
+ export type TicketTierScheduleContext = {
35
+ now?: Date;
36
+ availableInventory?: number;
37
+ ideaInterestCount?: number;
38
+ ticketsSoldCount?: number;
39
+ isViewerEngaged?: boolean;
40
+ isViewerAmbashador?: boolean;
41
+ isViewerBashInsider?: boolean;
42
+ isViewerPromoterForEvent?: boolean;
43
+ };
44
+
45
+ export type TicketTierScheduleState = {
46
+ phase: TicketTierSchedulePhase;
47
+ availableAt: string | null;
48
+ expiresAt: string | null;
49
+ canPurchase: boolean;
50
+ soldOut: boolean;
51
+ showWaitlistCta: boolean;
52
+ guestVisible: boolean;
53
+ availability: TicketTierAvailability;
54
+ hostPreviewLabel: string;
55
+ };
56
+
57
+ export type ScheduleValidationIssue = {
58
+ field: string;
59
+ message: string;
60
+ severity: "error" | "warn";
61
+ };
62
+
63
+ function toDate(value: Date | string | null | undefined): Date | null {
64
+ if (value == null) return null;
65
+ const d = value instanceof Date ? value : new Date(value);
66
+ return Number.isNaN(d.getTime()) ? null : d;
67
+ }
68
+
69
+ function toIso(value: Date | string | null | undefined): string | null {
70
+ const d = toDate(value);
71
+ return d ? d.toISOString() : null;
72
+ }
73
+
74
+ function normalizePreReleaseDisplay(value: string | null | undefined): PreReleaseDisplay {
75
+ return value === "Hidden" ? "Hidden" : "Tease";
76
+ }
77
+
78
+ function isOnDate(type: string | null | undefined): boolean {
79
+ return type === "OnDate";
80
+ }
81
+
82
+ function isPresaleAudienceEligible(
83
+ audience: string | null | undefined,
84
+ ctx: TicketTierScheduleContext
85
+ ): boolean {
86
+ switch (audience) {
87
+ case "Ambashador":
88
+ return ctx.isViewerAmbashador === true;
89
+ case "BashInsider":
90
+ return ctx.isViewerBashInsider === true;
91
+ case "Promoters":
92
+ return ctx.isViewerPromoterForEvent === true;
93
+ default:
94
+ return true;
95
+ }
96
+ }
97
+
98
+ function resolveUnlockAvailability(
99
+ tier: TicketTierScheduleInput,
100
+ ctx: TicketTierScheduleContext
101
+ ): TicketTierAvailability | null {
102
+ const type = tier.availabilityType ?? "Immediate";
103
+ if (type === "UnlockWhenInterestReached") {
104
+ const threshold = tier.availableWhenInterestReached ?? 0;
105
+ const count = ctx.ideaInterestCount ?? 0;
106
+ const available = count >= threshold;
107
+ return {
108
+ available,
109
+ reason: "UnlockWhenInterestReached",
110
+ interestCount: count,
111
+ interestThreshold: threshold,
112
+ };
113
+ }
114
+ if (type === "UnlockWhenTicketsSold") {
115
+ const threshold = tier.unlockWhenTicketsSold ?? 0;
116
+ const count = ctx.ticketsSoldCount ?? 0;
117
+ const available = count >= threshold;
118
+ return {
119
+ available,
120
+ reason: "UnlockWhenTicketsSold",
121
+ ticketsSoldCount: count,
122
+ ticketsSoldThreshold: threshold,
123
+ };
124
+ }
125
+ return null;
126
+ }
127
+
128
+ /**
129
+ * Resolve guest-visible phase and purchase eligibility for a ticket tier.
130
+ */
131
+ export function resolveTicketTierScheduleState(
132
+ tier: TicketTierScheduleInput,
133
+ ctx: TicketTierScheduleContext = {}
134
+ ): TicketTierScheduleState {
135
+ const now = ctx.now ?? new Date();
136
+ const availableAt = toDate(tier.availableAt);
137
+ const expiresAt = toDate(tier.expiresAt);
138
+ const publicAvailableAt = toDate(tier.publicAvailableAt);
139
+ const inventory = ctx.availableInventory;
140
+ const manualSoldOut = tier.isManuallySoldOut === true;
141
+ /** Only treat as inventory sold-out when callers supply a count — unknown ≠ zero. */
142
+ const soldOut =
143
+ manualSoldOut || (inventory !== undefined && inventory <= 0);
144
+ const preDisplay = normalizePreReleaseDisplay(tier.preReleaseDisplay);
145
+ const type = tier.availabilityType ?? "Immediate";
146
+
147
+ if (tier.hidden) {
148
+ return buildState({
149
+ phase: "HostHidden",
150
+ availableAt,
151
+ expiresAt,
152
+ canPurchase: false,
153
+ soldOut,
154
+ isWaitlistTier: tier.isWaitlistTier,
155
+ availability: { available: false },
156
+ hostPreviewLabel: "Hidden by host",
157
+ });
158
+ }
159
+
160
+ if (expiresAt && now >= expiresAt) {
161
+ return buildState({
162
+ phase: "Expired",
163
+ availableAt,
164
+ expiresAt,
165
+ canPurchase: false,
166
+ soldOut,
167
+ isWaitlistTier: tier.isWaitlistTier,
168
+ availability: { available: false },
169
+ hostPreviewLabel: "Expired",
170
+ });
171
+ }
172
+
173
+ const unlockAvail = resolveUnlockAvailability(tier, ctx);
174
+ if (unlockAvail && !unlockAvail.available) {
175
+ return buildState({
176
+ phase: type === "OnDate" && availableAt && now < availableAt
177
+ ? preDisplay === "Hidden" ? "HiddenPreRelease" : "TeasePreRelease"
178
+ : "TeasePreRelease",
179
+ availableAt,
180
+ expiresAt,
181
+ canPurchase: false,
182
+ soldOut,
183
+ isWaitlistTier: tier.isWaitlistTier,
184
+ availability: unlockAvail,
185
+ hostPreviewLabel: "Unlock pending",
186
+ });
187
+ }
188
+
189
+ if (isOnDate(type) && availableAt && now < availableAt) {
190
+ const phase: TicketTierSchedulePhase =
191
+ preDisplay === "Hidden" ? "HiddenPreRelease" : "TeasePreRelease";
192
+ return buildState({
193
+ phase,
194
+ availableAt,
195
+ expiresAt,
196
+ canPurchase: false,
197
+ soldOut,
198
+ isWaitlistTier: tier.isWaitlistTier,
199
+ availability: {
200
+ available: false,
201
+ reason: "OnDate",
202
+ availableAt: availableAt.toISOString(),
203
+ },
204
+ hostPreviewLabel: phase === "HiddenPreRelease" ? "Hidden until release" : "Tease + countdown",
205
+ });
206
+ }
207
+
208
+ const presaleAudience = tier.presaleAudience ?? "Public";
209
+ if (presaleAudience !== "Public") {
210
+ const publicOpensAt = publicAvailableAt ?? availableAt;
211
+ const presaleOpen = !availableAt || now >= availableAt;
212
+ const publicOpen = !publicOpensAt || now >= publicOpensAt;
213
+ const eligible = isPresaleAudienceEligible(presaleAudience, ctx);
214
+ if (!publicOpen && (!presaleOpen || !eligible)) {
215
+ return buildState({
216
+ phase: presaleOpen && !publicOpen ? "TeasePreRelease" : "HiddenPreRelease",
217
+ availableAt: publicOpensAt ?? availableAt,
218
+ expiresAt,
219
+ canPurchase: false,
220
+ soldOut,
221
+ isWaitlistTier: tier.isWaitlistTier,
222
+ availability: {
223
+ available: false,
224
+ reason: "OnDate",
225
+ availableAt: (publicOpensAt ?? availableAt)?.toISOString(),
226
+ },
227
+ hostPreviewLabel: `${presaleAudience} presale`,
228
+ });
229
+ }
230
+ if (presaleOpen && !publicOpen && !eligible) {
231
+ return buildState({
232
+ phase: "TeasePreRelease",
233
+ availableAt: publicOpensAt,
234
+ expiresAt,
235
+ canPurchase: false,
236
+ soldOut,
237
+ isWaitlistTier: tier.isWaitlistTier,
238
+ availability: {
239
+ available: false,
240
+ reason: "OnDate",
241
+ availableAt: publicOpensAt?.toISOString(),
242
+ },
243
+ hostPreviewLabel: "Public sale scheduled",
244
+ });
245
+ }
246
+ }
247
+
248
+ if (type === "EngagedUsersOnly" && ctx.isViewerEngaged === false) {
249
+ return buildState({
250
+ phase: soldOut ? "SoldOut" : "Live",
251
+ availableAt,
252
+ expiresAt,
253
+ canPurchase: false,
254
+ soldOut,
255
+ isWaitlistTier: tier.isWaitlistTier,
256
+ availability: {
257
+ available: false,
258
+ reason: "EngagedUsersOnly",
259
+ isViewerEngaged: false,
260
+ },
261
+ hostPreviewLabel: soldOut ? "Sold out" : "RSVP required",
262
+ });
263
+ }
264
+
265
+ if (soldOut) {
266
+ return buildState({
267
+ phase: "SoldOut",
268
+ availableAt,
269
+ expiresAt,
270
+ canPurchase: false,
271
+ soldOut: true,
272
+ isWaitlistTier: tier.isWaitlistTier,
273
+ availability: { available: false },
274
+ hostPreviewLabel: manualSoldOut ? "Sold out (manual)" : "Sold out",
275
+ });
276
+ }
277
+
278
+ const availability: TicketTierAvailability = unlockAvail ?? { available: true };
279
+ if (type === "EngagedUsersOnly") {
280
+ availability.reason = "EngagedUsersOnly";
281
+ availability.isViewerEngaged = ctx.isViewerEngaged;
282
+ }
283
+
284
+ return buildState({
285
+ phase: "Live",
286
+ availableAt,
287
+ expiresAt,
288
+ canPurchase: true,
289
+ soldOut: false,
290
+ isWaitlistTier: tier.isWaitlistTier,
291
+ availability,
292
+ hostPreviewLabel: "Live",
293
+ });
294
+ }
295
+
296
+ function buildState(args: {
297
+ phase: TicketTierSchedulePhase;
298
+ availableAt: Date | null;
299
+ expiresAt: Date | null;
300
+ canPurchase: boolean;
301
+ soldOut: boolean;
302
+ isWaitlistTier?: boolean | null;
303
+ availability: TicketTierAvailability;
304
+ hostPreviewLabel: string;
305
+ }): TicketTierScheduleState {
306
+ const guestVisible =
307
+ args.phase !== "HostHidden" &&
308
+ args.phase !== "HiddenPreRelease" &&
309
+ args.phase !== "Expired";
310
+ return {
311
+ phase: args.phase,
312
+ availableAt: toIso(args.availableAt),
313
+ expiresAt: toIso(args.expiresAt),
314
+ canPurchase: args.canPurchase,
315
+ soldOut: args.soldOut,
316
+ showWaitlistCta: args.soldOut && args.isWaitlistTier === true,
317
+ guestVisible,
318
+ availability: args.availability,
319
+ hostPreviewLabel: args.hostPreviewLabel,
320
+ };
321
+ }
322
+
323
+ export function validateTicketTierScheduleInput(
324
+ tier: TicketTierScheduleInput,
325
+ bashEvent?: { startDateTime?: Date | string | null }
326
+ ): ScheduleValidationIssue[] {
327
+ const issues: ScheduleValidationIssue[] = [];
328
+ const type = tier.availabilityType ?? "Immediate";
329
+ const availableAt = toDate(tier.availableAt);
330
+ const expiresAt = toDate(tier.expiresAt);
331
+ const start = bashEvent ? toDate(bashEvent.startDateTime) : null;
332
+
333
+ if (isOnDate(type) && !availableAt) {
334
+ issues.push({
335
+ field: "availableAt",
336
+ message: "Release date and time are required when scheduling a tier.",
337
+ severity: "error",
338
+ });
339
+ }
340
+
341
+ if (availableAt && expiresAt && expiresAt <= availableAt) {
342
+ issues.push({
343
+ field: "expiresAt",
344
+ message: "Expires must be after the release time.",
345
+ severity: "error",
346
+ });
347
+ }
348
+
349
+ if (expiresAt && expiresAt <= new Date()) {
350
+ issues.push({
351
+ field: "expiresAt",
352
+ message: "Expires must be in the future.",
353
+ severity: "error",
354
+ });
355
+ }
356
+
357
+ if (start && availableAt && availableAt > start) {
358
+ issues.push({
359
+ field: "availableAt",
360
+ message: "Release is after the event start — double-check the time.",
361
+ severity: "warn",
362
+ });
363
+ }
364
+
365
+ if (start && expiresAt && expiresAt > start) {
366
+ issues.push({
367
+ field: "expiresAt",
368
+ message: "Tier expires after the event starts — unusual but allowed.",
369
+ severity: "warn",
370
+ });
371
+ }
372
+
373
+ return issues;
374
+ }
375
+
376
+ export function scheduleValidationHasErrors(issues: ScheduleValidationIssue[]): boolean {
377
+ return issues.some((i) => i.severity === "error");
378
+ }
379
+
380
+ /** Whether tier should appear on guest detail ticket list (incl. tease). */
381
+ export function isTicketTierGuestListVisible(state: TicketTierScheduleState): boolean {
382
+ return (
383
+ state.guestVisible ||
384
+ state.phase === "TeasePreRelease" ||
385
+ state.phase === "SoldOut"
386
+ );
387
+ }