@bash-app/bash-common 30.260.1 → 30.262.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 (79) hide show
  1. package/dist/__tests__/ticketBnplPaymentMethods.cashapp.test.d.ts +2 -0
  2. package/dist/__tests__/ticketBnplPaymentMethods.cashapp.test.d.ts.map +1 -0
  3. package/dist/__tests__/ticketBnplPaymentMethods.cashapp.test.js +33 -0
  4. package/dist/__tests__/ticketBnplPaymentMethods.cashapp.test.js.map +1 -0
  5. package/dist/__tests__/ticketBnplPaymentMethods.test.d.ts +2 -0
  6. package/dist/__tests__/ticketBnplPaymentMethods.test.d.ts.map +1 -0
  7. package/dist/__tests__/ticketBnplPaymentMethods.test.js +46 -0
  8. package/dist/__tests__/ticketBnplPaymentMethods.test.js.map +1 -0
  9. package/dist/definitions.d.ts +6 -1
  10. package/dist/definitions.d.ts.map +1 -1
  11. package/dist/definitions.js.map +1 -1
  12. package/dist/index.d.ts +6 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +6 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/ticketBnplPaymentMethods.d.ts +32 -0
  17. package/dist/ticketBnplPaymentMethods.d.ts.map +1 -0
  18. package/dist/ticketBnplPaymentMethods.js +42 -0
  19. package/dist/ticketBnplPaymentMethods.js.map +1 -0
  20. package/dist/utils/__tests__/featuredDiscoveryGeo.test.d.ts +2 -0
  21. package/dist/utils/__tests__/featuredDiscoveryGeo.test.d.ts.map +1 -0
  22. package/dist/utils/__tests__/featuredDiscoveryGeo.test.js +98 -0
  23. package/dist/utils/__tests__/featuredDiscoveryGeo.test.js.map +1 -0
  24. package/dist/utils/__tests__/flyerUtils.test.js +7 -1
  25. package/dist/utils/__tests__/flyerUtils.test.js.map +1 -1
  26. package/dist/utils/__tests__/guestSurface.test.d.ts +2 -0
  27. package/dist/utils/__tests__/guestSurface.test.d.ts.map +1 -0
  28. package/dist/utils/__tests__/guestSurface.test.js +94 -0
  29. package/dist/utils/__tests__/guestSurface.test.js.map +1 -0
  30. package/dist/utils/__tests__/ticketTierSchedule.test.d.ts +2 -0
  31. package/dist/utils/__tests__/ticketTierSchedule.test.d.ts.map +1 -0
  32. package/dist/utils/__tests__/ticketTierSchedule.test.js +84 -0
  33. package/dist/utils/__tests__/ticketTierSchedule.test.js.map +1 -0
  34. package/dist/utils/featuredDiscoveryGeo.d.ts +30 -0
  35. package/dist/utils/featuredDiscoveryGeo.d.ts.map +1 -0
  36. package/dist/utils/featuredDiscoveryGeo.js +47 -0
  37. package/dist/utils/featuredDiscoveryGeo.js.map +1 -0
  38. package/dist/utils/flyerUtils.d.ts +5 -0
  39. package/dist/utils/flyerUtils.d.ts.map +1 -1
  40. package/dist/utils/flyerUtils.js +24 -0
  41. package/dist/utils/flyerUtils.js.map +1 -1
  42. package/dist/utils/guestSurface.d.ts +114 -0
  43. package/dist/utils/guestSurface.d.ts.map +1 -0
  44. package/dist/utils/guestSurface.js +164 -0
  45. package/dist/utils/guestSurface.js.map +1 -0
  46. package/dist/utils/promotionBlastUtils.d.ts +38 -0
  47. package/dist/utils/promotionBlastUtils.d.ts.map +1 -0
  48. package/dist/utils/promotionBlastUtils.js +88 -0
  49. package/dist/utils/promotionBlastUtils.js.map +1 -0
  50. package/dist/utils/service/__tests__/defaultOfferAmountUtils.test.d.ts +2 -0
  51. package/dist/utils/service/__tests__/defaultOfferAmountUtils.test.d.ts.map +1 -0
  52. package/dist/utils/service/__tests__/defaultOfferAmountUtils.test.js +65 -0
  53. package/dist/utils/service/__tests__/defaultOfferAmountUtils.test.js.map +1 -0
  54. package/dist/utils/service/defaultOfferAmountUtils.d.ts +32 -0
  55. package/dist/utils/service/defaultOfferAmountUtils.d.ts.map +1 -0
  56. package/dist/utils/service/defaultOfferAmountUtils.js +115 -0
  57. package/dist/utils/service/defaultOfferAmountUtils.js.map +1 -0
  58. package/dist/utils/ticketTierSchedule.d.ts +58 -0
  59. package/dist/utils/ticketTierSchedule.d.ts.map +1 -0
  60. package/dist/utils/ticketTierSchedule.js +279 -0
  61. package/dist/utils/ticketTierSchedule.js.map +1 -0
  62. package/package.json +5 -2
  63. package/prisma/schema.prisma +332 -1
  64. package/src/__tests__/ticketBnplPaymentMethods.cashapp.test.ts +45 -0
  65. package/src/__tests__/ticketBnplPaymentMethods.test.ts +66 -0
  66. package/src/definitions.ts +6 -1
  67. package/src/index.ts +6 -0
  68. package/src/ticketBnplPaymentMethods.ts +79 -0
  69. package/src/utils/__tests__/featuredDiscoveryGeo.test.ts +120 -0
  70. package/src/utils/__tests__/flyerUtils.test.ts +10 -0
  71. package/src/utils/__tests__/guestSurface.test.ts +128 -0
  72. package/src/utils/__tests__/ticketTierSchedule.test.ts +131 -0
  73. package/src/utils/featuredDiscoveryGeo.ts +75 -0
  74. package/src/utils/flyerUtils.ts +33 -0
  75. package/src/utils/guestSurface.ts +315 -0
  76. package/src/utils/promotionBlastUtils.ts +127 -0
  77. package/src/utils/service/__tests__/defaultOfferAmountUtils.test.ts +102 -0
  78. package/src/utils/service/defaultOfferAmountUtils.ts +170 -0
  79. package/src/utils/ticketTierSchedule.ts +387 -0
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Stripe Checkout BNPL (Klarna, Afterpay/Clearpay) for paid ticket orders.
3
+ * Pure functions — safe for SSR/tests; no IO.
4
+ *
5
+ * @see ADR-011, api/docs/STRIPE_PAYMENT_LINKS_INSTALLMENTS.md
6
+ */
7
+
8
+ export type TicketCheckoutPaymentMethodType =
9
+ | "card"
10
+ | "link"
11
+ | "klarna"
12
+ | "afterpay_clearpay"
13
+ | "cashapp";
14
+
15
+ /** USD floors/ceilings aligned with typical Stripe BNPL eligibility (conservative). */
16
+ export const TICKET_BNPL_MIN_CENTS_USD = 3_500; // $35
17
+ export const TICKET_BNPL_MAX_CENTS_USD = 4_000_00; // $4,000 (Afterpay US Pay in 4 band)
18
+
19
+ export interface TicketBnplPaymentMethodInput {
20
+ /** Per-event host toggle (`BashEvent.installmentsEnabled`). */
21
+ installmentsEnabled: boolean;
22
+ /** Per-event host toggle (`BashEvent.cashAppEnabled`). */
23
+ cashAppEnabled?: boolean;
24
+ /** Buyer total charged at checkout (tickets + guest-visible fees), in cents. */
25
+ totalChargedCents: number;
26
+ /** ISO currency lowercase, e.g. "usd". */
27
+ currency: string;
28
+ /** Platform kill switch (API env `TICKET_BNPL_ENABLED`). Default true in tests. */
29
+ bnplGloballyEnabled?: boolean;
30
+ /** Platform kill switch (API env `TICKET_CASHAPP_ENABLED`). Default true in tests. */
31
+ cashAppGloballyEnabled?: boolean;
32
+ }
33
+
34
+ /**
35
+ * Resolve `payment_method_types` for Stripe Checkout ticket sessions.
36
+ * BNPL is offered only when the host enabled installments and the order is in range.
37
+ */
38
+ export function resolveTicketCheckoutPaymentMethodTypes(
39
+ input: TicketBnplPaymentMethodInput
40
+ ): TicketCheckoutPaymentMethodType[] {
41
+ const base: TicketCheckoutPaymentMethodType[] = ["card", "link"];
42
+ const methods = new Set<TicketCheckoutPaymentMethodType>(base);
43
+
44
+ const globallyBnplOn = input.bnplGloballyEnabled !== false;
45
+ const globallyCashAppOn = input.cashAppGloballyEnabled !== false;
46
+ const isUsd = input.currency.toLowerCase() === "usd";
47
+ const cents = input.totalChargedCents;
48
+
49
+ if (
50
+ globallyBnplOn &&
51
+ input.installmentsEnabled &&
52
+ isUsd &&
53
+ cents >= TICKET_BNPL_MIN_CENTS_USD &&
54
+ cents <= TICKET_BNPL_MAX_CENTS_USD
55
+ ) {
56
+ methods.add("klarna");
57
+ methods.add("afterpay_clearpay");
58
+ }
59
+
60
+ if (globallyCashAppOn && input.cashAppEnabled && isUsd && cents > 0) {
61
+ methods.add("cashapp");
62
+ }
63
+
64
+ return [...methods];
65
+ }
66
+
67
+ export function ticketCheckoutOffersBnpl(
68
+ input: TicketBnplPaymentMethodInput
69
+ ): boolean {
70
+ const methods = resolveTicketCheckoutPaymentMethodTypes(input);
71
+ return methods.includes("klarna");
72
+ }
73
+
74
+ export function ticketCheckoutOffersCashApp(
75
+ input: TicketBnplPaymentMethodInput
76
+ ): boolean {
77
+ const methods = resolveTicketCheckoutPaymentMethodTypes(input);
78
+ return methods.includes("cashapp");
79
+ }
@@ -0,0 +1,120 @@
1
+ import {
2
+ haversineMiles,
3
+ effectiveFeaturedForGeoScope,
4
+ sortPublicEventsForFeaturedDiscovery,
5
+ } from "../featuredDiscoveryGeo.js";
6
+
7
+ describe("haversineMiles", () => {
8
+ it("returns zero for identical coordinates", () => {
9
+ expect(haversineMiles(40.7128, -74.006, 40.7128, -74.006)).toBeCloseTo(0, 5);
10
+ });
11
+
12
+ it("computes a plausible distance between NYC and Philadelphia", () => {
13
+ const miles = haversineMiles(40.7128, -74.006, 39.9526, -75.1652);
14
+ expect(miles).toBeGreaterThan(75);
15
+ expect(miles).toBeLessThan(100);
16
+ });
17
+ });
18
+
19
+ describe("effectiveFeaturedForGeoScope", () => {
20
+ const viewer = { lat: 40.7128, lng: -74.006, radiusMiles: 10 };
21
+
22
+ it("returns false when event is not featured", () => {
23
+ expect(effectiveFeaturedForGeoScope(false, [{ lat: 40.71, lng: -74.0 }], viewer)).toBe(false);
24
+ });
25
+
26
+ it("returns true for featured events when no viewer geo is provided", () => {
27
+ expect(effectiveFeaturedForGeoScope(true, [{ lat: 40.71, lng: -74.0 }], undefined)).toBe(true);
28
+ });
29
+
30
+ it("returns false when featured event has no coordinates", () => {
31
+ expect(effectiveFeaturedForGeoScope(true, [{ lat: null, lng: null }], viewer)).toBe(false);
32
+ expect(effectiveFeaturedForGeoScope(true, [], viewer)).toBe(false);
33
+ });
34
+
35
+ it("returns true when featured event is within viewer radius", () => {
36
+ expect(
37
+ effectiveFeaturedForGeoScope(true, [{ lat: 40.72, lng: -74.01 }], viewer)
38
+ ).toBe(true);
39
+ });
40
+
41
+ it("returns false when featured event is outside viewer radius", () => {
42
+ expect(
43
+ effectiveFeaturedForGeoScope(true, [{ lat: 34.0522, lng: -118.2437 }], viewer)
44
+ ).toBe(false);
45
+ });
46
+ });
47
+
48
+ describe("sortPublicEventsForFeaturedDiscovery", () => {
49
+ const viewer = { lat: 40.7128, lng: -74.006, radiusMiles: 10 };
50
+
51
+ it("sorts effective featured events before non-featured", () => {
52
+ const events = [
53
+ {
54
+ id: "b",
55
+ isFeatured: false,
56
+ featuredUntil: null,
57
+ startDateTime: "2026-06-02T00:00:00Z",
58
+ coordinates: [{ lat: 40.72, lng: -74.01 }],
59
+ },
60
+ {
61
+ id: "a",
62
+ isFeatured: true,
63
+ featuredUntil: null,
64
+ startDateTime: "2026-06-01T00:00:00Z",
65
+ coordinates: [{ lat: 40.72, lng: -74.01 }],
66
+ },
67
+ ];
68
+
69
+ const sorted = sortPublicEventsForFeaturedDiscovery(events, viewer);
70
+ expect(sorted.map((e) => e.id)).toEqual(["a", "b"]);
71
+ });
72
+
73
+ it("sorts by startDateTime within the same featured bucket", () => {
74
+ const events = [
75
+ {
76
+ id: "late",
77
+ isFeatured: true,
78
+ featuredUntil: null,
79
+ startDateTime: "2026-06-03T00:00:00Z",
80
+ coordinates: [{ lat: 40.72, lng: -74.01 }],
81
+ },
82
+ {
83
+ id: "early",
84
+ isFeatured: true,
85
+ featuredUntil: null,
86
+ startDateTime: "2026-06-01T00:00:00Z",
87
+ coordinates: [{ lat: 40.72, lng: -74.01 }],
88
+ },
89
+ ];
90
+
91
+ expect(sortPublicEventsForFeaturedDiscovery(events, viewer).map((e) => e.id)).toEqual([
92
+ "early",
93
+ "late",
94
+ ]);
95
+ });
96
+
97
+ it("treats out-of-scope featured events as non-featured for ordering", () => {
98
+ const events = [
99
+ {
100
+ id: "far-featured",
101
+ isFeatured: true,
102
+ featuredUntil: null,
103
+ startDateTime: "2026-06-01T00:00:00Z",
104
+ coordinates: [{ lat: 34.05, lng: -118.24 }],
105
+ },
106
+ {
107
+ id: "local-regular",
108
+ isFeatured: false,
109
+ featuredUntil: null,
110
+ startDateTime: "2026-06-02T00:00:00Z",
111
+ coordinates: [{ lat: 40.72, lng: -74.01 }],
112
+ },
113
+ ];
114
+
115
+ expect(sortPublicEventsForFeaturedDiscovery(events, viewer).map((e) => e.id)).toEqual([
116
+ "far-featured",
117
+ "local-regular",
118
+ ]);
119
+ });
120
+ });
@@ -18,6 +18,9 @@ import {
18
18
  FLYER_SPONSOR_LOGO_ALT_MAX_LENGTH,
19
19
  FLYER_SPONSOR_LOGO_MAX_BYTES,
20
20
  validateFlyerSponsorCreative,
21
+ flyerSponsorSlotPriceCents,
22
+ flyerSponsorSlotHostPct,
23
+ flyerSponsorSlotLabel,
21
24
  } from "../flyerUtils.js";
22
25
 
23
26
  describe("flyerUtils", () => {
@@ -89,6 +92,13 @@ describe("flyerUtils", () => {
89
92
  ]);
90
93
  });
91
94
 
95
+ it("flyerSponsorSlotPriceCents and host pct follow slot type", () => {
96
+ expect(flyerSponsorSlotPriceCents("FEATURED")).toBe(10000);
97
+ expect(flyerSponsorSlotPriceCents("SUPPORTING_1")).toBe(5000);
98
+ expect(flyerSponsorSlotHostPct("FEATURED")).toBe(0.6);
99
+ expect(flyerSponsorSlotLabel("FEATURED")).toBe("Featured");
100
+ });
101
+
92
102
  it("validates sponsor creative text limits and trims fields before checking", () => {
93
103
  expect(
94
104
  validateFlyerSponsorCreative({
@@ -0,0 +1,128 @@
1
+ import {
2
+ capRosterDepthForEventMode,
3
+ filterHiddenVisibilityUserIds,
4
+ orderPreviewAttendeeIds,
5
+ resolveEffectiveEventMode,
6
+ resolveGuestSurfacePolicy,
7
+ viewerMayFetchFullRoster,
8
+ maxGuestListDepth,
9
+ } from "../guestSurface.js";
10
+
11
+ describe("resolveEffectiveEventMode", () => {
12
+ it("maps legacy showAttendees false to Hidden", () => {
13
+ expect(resolveEffectiveEventMode({ showAttendees: false })).toBe("Hidden");
14
+ });
15
+
16
+ it("honours CountOnly", () => {
17
+ expect(
18
+ resolveEffectiveEventMode({ attendeeDisplayMode: "CountOnly" })
19
+ ).toBe("CountOnly");
20
+ });
21
+ });
22
+
23
+ describe("resolveGuestSurfacePolicy", () => {
24
+ it("Hidden mode hides everything for public viewers", () => {
25
+ const policy = resolveGuestSurfacePolicy({
26
+ eventMode: "Hidden",
27
+ isHost: false,
28
+ isAuthenticated: true,
29
+ viewerRosterDepth: "Full",
30
+ });
31
+ expect(policy.showCount).toBe(false);
32
+ expect(policy.showPreviewAvatars).toBe(false);
33
+ });
34
+
35
+ it("CountOnly shows count but not avatars", () => {
36
+ const policy = resolveGuestSurfacePolicy({
37
+ eventMode: "CountOnly",
38
+ isHost: false,
39
+ isAuthenticated: false,
40
+ viewerRosterDepth: "None",
41
+ });
42
+ expect(policy.showCount).toBe(true);
43
+ expect(policy.showPreviewAvatars).toBe(false);
44
+ });
45
+
46
+ it("All mode shows curated preview", () => {
47
+ const policy = resolveGuestSurfacePolicy({
48
+ eventMode: "All",
49
+ isHost: false,
50
+ isAuthenticated: true,
51
+ viewerRosterDepth: "None",
52
+ });
53
+ expect(policy.showPreviewAvatars).toBe(true);
54
+ expect(policy.showNetworkSlice).toBe(true);
55
+ });
56
+ });
57
+
58
+ describe("viewerMayFetchFullRoster", () => {
59
+ it("denies non-host with None depth", () => {
60
+ expect(
61
+ viewerMayFetchFullRoster({ isHost: false, viewerRosterDepth: "None" })
62
+ ).toBe(false);
63
+ });
64
+
65
+ it("allows host", () => {
66
+ expect(
67
+ viewerMayFetchFullRoster({ isHost: true, viewerRosterDepth: "None" })
68
+ ).toBe(true);
69
+ });
70
+
71
+ it("allows VIP Partial depth", () => {
72
+ expect(
73
+ viewerMayFetchFullRoster({ isHost: false, viewerRosterDepth: "Partial" })
74
+ ).toBe(true);
75
+ });
76
+ });
77
+
78
+ describe("orderPreviewAttendeeIds", () => {
79
+ it("puts featured first then by recency", () => {
80
+ const ids = orderPreviewAttendeeIds(
81
+ [
82
+ { userId: "a", paidOn: "2026-01-01" },
83
+ { userId: "b", paidOn: "2026-06-01" },
84
+ { userId: "c", paidOn: "2026-03-01" },
85
+ ],
86
+ ["a"],
87
+ 10
88
+ );
89
+ expect(ids[0]).toBe("a");
90
+ expect(ids[1]).toBe("b");
91
+ });
92
+ });
93
+
94
+ describe("maxGuestListDepth", () => {
95
+ it("picks highest depth", () => {
96
+ expect(maxGuestListDepth("None", "Partial", "Full")).toBe("Full");
97
+ });
98
+ });
99
+
100
+ describe("capRosterDepthForEventMode", () => {
101
+ it("caps VIP to None when event is NetworkOnly", () => {
102
+ expect(
103
+ capRosterDepthForEventMode("Full", "NetworkOnly", false)
104
+ ).toBe("None");
105
+ });
106
+ });
107
+
108
+ describe("filterHiddenVisibilityUserIds", () => {
109
+ it("hides FeaturedInvited from public preview", () => {
110
+ const map = new Map([
111
+ ["a", "Visible" as const],
112
+ ["b", "FeaturedInvited" as const],
113
+ ["c", "FeaturedPending" as const],
114
+ ]);
115
+ expect(filterHiddenVisibilityUserIds(["a", "b", "c"], map, false)).toEqual([
116
+ "a",
117
+ "c",
118
+ ]);
119
+ });
120
+
121
+ it("shows all ids to host including FeaturedInvited", () => {
122
+ const map = new Map([["b", "FeaturedInvited" as const]]);
123
+ expect(filterHiddenVisibilityUserIds(["a", "b"], map, true)).toEqual([
124
+ "a",
125
+ "b",
126
+ ]);
127
+ });
128
+ });
@@ -0,0 +1,131 @@
1
+ import {
2
+ isTicketTierGuestListVisible,
3
+ resolveTicketTierScheduleState,
4
+ scheduleValidationHasErrors,
5
+ validateTicketTierScheduleInput,
6
+ } from "../ticketTierSchedule.js";
7
+
8
+ describe("resolveTicketTierScheduleState", () => {
9
+ const future = new Date("2030-06-01T18:00:00.000Z");
10
+ const past = new Date("2020-01-01T12:00:00.000Z");
11
+ const now = new Date("2030-05-01T12:00:00.000Z");
12
+
13
+ it("returns Live for immediate tier with inventory", () => {
14
+ const state = resolveTicketTierScheduleState(
15
+ { availabilityType: "Immediate" },
16
+ { now, availableInventory: 10 }
17
+ );
18
+ expect(state.phase).toBe("Live");
19
+ expect(state.canPurchase).toBe(true);
20
+ });
21
+
22
+ it("does not treat missing inventory as sold out", () => {
23
+ const state = resolveTicketTierScheduleState(
24
+ { availabilityType: "Immediate" },
25
+ { now }
26
+ );
27
+ expect(state.phase).toBe("Live");
28
+ expect(state.soldOut).toBe(false);
29
+ expect(state.hostPreviewLabel).toBe("Live");
30
+ });
31
+
32
+ it("returns TeasePreRelease before OnDate", () => {
33
+ const state = resolveTicketTierScheduleState(
34
+ {
35
+ availabilityType: "OnDate",
36
+ availableAt: future,
37
+ preReleaseDisplay: "Tease",
38
+ },
39
+ { now, availableInventory: 10 }
40
+ );
41
+ expect(state.phase).toBe("TeasePreRelease");
42
+ expect(state.canPurchase).toBe(false);
43
+ expect(state.availability.reason).toBe("OnDate");
44
+ expect(isTicketTierGuestListVisible(state)).toBe(true);
45
+ });
46
+
47
+ it("returns HiddenPreRelease when preReleaseDisplay is Hidden", () => {
48
+ const state = resolveTicketTierScheduleState(
49
+ {
50
+ availabilityType: "OnDate",
51
+ availableAt: future,
52
+ preReleaseDisplay: "Hidden",
53
+ },
54
+ { now, availableInventory: 10 }
55
+ );
56
+ expect(state.phase).toBe("HiddenPreRelease");
57
+ expect(isTicketTierGuestListVisible(state)).toBe(false);
58
+ });
59
+
60
+ it("returns Expired after expiresAt", () => {
61
+ const state = resolveTicketTierScheduleState(
62
+ { expiresAt: past },
63
+ { now, availableInventory: 10 }
64
+ );
65
+ expect(state.phase).toBe("Expired");
66
+ expect(state.canPurchase).toBe(false);
67
+ });
68
+
69
+ it("returns SoldOut when manually sold out", () => {
70
+ const state = resolveTicketTierScheduleState(
71
+ { isManuallySoldOut: true },
72
+ { now, availableInventory: 50 }
73
+ );
74
+ expect(state.phase).toBe("SoldOut");
75
+ expect(state.showWaitlistCta).toBe(false);
76
+ });
77
+
78
+ it("shows waitlist CTA when sold out and waitlist tier", () => {
79
+ const state = resolveTicketTierScheduleState(
80
+ { isWaitlistTier: true },
81
+ { now, availableInventory: 0 }
82
+ );
83
+ expect(state.phase).toBe("SoldOut");
84
+ expect(state.showWaitlistCta).toBe(true);
85
+ });
86
+
87
+ it("blocks EngagedUsersOnly when viewer not engaged", () => {
88
+ const state = resolveTicketTierScheduleState(
89
+ { availabilityType: "EngagedUsersOnly" },
90
+ { now, availableInventory: 5, isViewerEngaged: false }
91
+ );
92
+ expect(state.canPurchase).toBe(false);
93
+ expect(state.availability.reason).toBe("EngagedUsersOnly");
94
+ });
95
+
96
+ it("UnlockWhenTicketsSold unlocks at threshold", () => {
97
+ const locked = resolveTicketTierScheduleState(
98
+ {
99
+ availabilityType: "UnlockWhenTicketsSold",
100
+ unlockWhenTicketsSold: 50,
101
+ },
102
+ { now, availableInventory: 10, ticketsSoldCount: 10 }
103
+ );
104
+ expect(locked.canPurchase).toBe(false);
105
+
106
+ const open = resolveTicketTierScheduleState(
107
+ {
108
+ availabilityType: "UnlockWhenTicketsSold",
109
+ unlockWhenTicketsSold: 50,
110
+ },
111
+ { now, availableInventory: 10, ticketsSoldCount: 50 }
112
+ );
113
+ expect(open.canPurchase).toBe(true);
114
+ });
115
+ });
116
+
117
+ describe("validateTicketTierScheduleInput", () => {
118
+ it("requires availableAt for OnDate", () => {
119
+ const issues = validateTicketTierScheduleInput({ availabilityType: "OnDate" });
120
+ expect(scheduleValidationHasErrors(issues)).toBe(true);
121
+ });
122
+
123
+ it("rejects expires before release", () => {
124
+ const issues = validateTicketTierScheduleInput({
125
+ availabilityType: "OnDate",
126
+ availableAt: new Date("2030-06-02T12:00:00Z"),
127
+ expiresAt: new Date("2030-06-01T12:00:00Z"),
128
+ });
129
+ expect(scheduleValidationHasErrors(issues)).toBe(true);
130
+ });
131
+ });
@@ -0,0 +1,75 @@
1
+ /** Default featured-discovery radius when the client omits `featuredRadiusMiles`. */
2
+ export const DEFAULT_FEATURED_RADIUS_MILES = 200;
3
+
4
+ /**
5
+ * Haversine distance in miles between two WGS84 points.
6
+ */
7
+ export function haversineMiles(
8
+ lat1: number,
9
+ lng1: number,
10
+ lat2: number,
11
+ lng2: number
12
+ ): number {
13
+ const R = 3958.8;
14
+ const dLat = ((lat2 - lat1) * Math.PI) / 180;
15
+ const dLng = ((lng2 - lng1) * Math.PI) / 180;
16
+ const a =
17
+ Math.sin(dLat / 2) * Math.sin(dLat / 2) +
18
+ Math.cos((lat1 * Math.PI) / 180) *
19
+ Math.cos((lat2 * Math.PI) / 180) *
20
+ Math.sin(dLng / 2) *
21
+ Math.sin(dLng / 2);
22
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
23
+ return R * c;
24
+ }
25
+
26
+ export interface ViewerGeoScope {
27
+ lat: number;
28
+ lng: number;
29
+ radiusMiles: number;
30
+ }
31
+
32
+ export function effectiveFeaturedForGeoScope(
33
+ isFeatured: boolean,
34
+ coordinates: Array<{ lat: number | null; lng: number | null }> | null | undefined,
35
+ viewer: ViewerGeoScope | undefined
36
+ ): boolean {
37
+ if (!isFeatured) return false;
38
+ if (!viewer) return true;
39
+ const first = coordinates?.find((c) => c.lat != null && c.lng != null);
40
+ if (!first || first.lat == null || first.lng == null) return false;
41
+ const d = haversineMiles(first.lat, first.lng, viewer.lat, viewer.lng);
42
+ return d <= viewer.radiusMiles;
43
+ }
44
+
45
+ export type EventWithFeaturedAndCoords = {
46
+ id: string;
47
+ isFeatured: boolean | null;
48
+ featuredUntil?: Date | null;
49
+ startDateTime: Date | string | null;
50
+ coordinates?: Array<{ lat: number | null; lng: number | null }> | null;
51
+ };
52
+
53
+ /**
54
+ * Sort: effective featured (in geo scope if viewer set) first, then by startDateTime.
55
+ */
56
+ export function sortPublicEventsForFeaturedDiscovery<
57
+ T extends EventWithFeaturedAndCoords,
58
+ >(events: T[], viewer: ViewerGeoScope | undefined): T[] {
59
+ const withKeys = events.map((e) => {
60
+ const eff = effectiveFeaturedForGeoScope(
61
+ !!e.isFeatured,
62
+ e.coordinates,
63
+ viewer
64
+ );
65
+ const start = e.startDateTime
66
+ ? new Date(e.startDateTime as Date | string).getTime()
67
+ : 0;
68
+ return { e, eff, start };
69
+ });
70
+ withKeys.sort((a, b) => {
71
+ if (a.eff !== b.eff) return (b.eff ? 1 : 0) - (a.eff ? 1 : 0);
72
+ return a.start - b.start;
73
+ });
74
+ return withKeys.map((x) => x.e);
75
+ }
@@ -158,6 +158,39 @@ export function computeHostTotalAfterSponsors(
158
158
  return Math.max(0, printPostageCostCents - sponsorRevenueCents);
159
159
  }
160
160
 
161
+ /** Default slot price in cents for Featured vs Supporting flyer slots. */
162
+ export function flyerSponsorSlotPriceCents(
163
+ slot: "FEATURED" | "SUPPORTING_1" | "SUPPORTING_2",
164
+ requestedPrice?: number
165
+ ): number {
166
+ if (requestedPrice != null) return requestedPrice;
167
+ return slot === "FEATURED"
168
+ ? FLYER_SPONSOR_DEFAULT_FEATURED_CENTS
169
+ : FLYER_SPONSOR_DEFAULT_SUPPORTING_CENTS;
170
+ }
171
+
172
+ /** Host revenue share for a flyer sponsor slot. */
173
+ export function flyerSponsorSlotHostPct(
174
+ slot: "FEATURED" | "SUPPORTING_1" | "SUPPORTING_2"
175
+ ): number {
176
+ return slot === "FEATURED" ? 0.6 : 0.65;
177
+ }
178
+
179
+ export function flyerSponsorSlotLabel(
180
+ slot: "FEATURED" | "SUPPORTING_1" | "SUPPORTING_2"
181
+ ): string {
182
+ switch (slot) {
183
+ case "FEATURED":
184
+ return "Featured";
185
+ case "SUPPORTING_1":
186
+ return "Supporting 1";
187
+ case "SUPPORTING_2":
188
+ return "Supporting 2";
189
+ default:
190
+ return slot;
191
+ }
192
+ }
193
+
161
194
  const EARTH_RADIUS_MI = 3958.8;
162
195
 
163
196
  /** Haversine distance in miles between two WGS84 points. */