@tribe-nest/forge 3.29.0 → 3.31.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 (51) hide show
  1. package/package.json +6 -3
  2. package/src/_tests/publishedResolvability.spec.ts +184 -0
  3. package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
  4. package/src/_tests/workspaceAliases.ts +40 -0
  5. package/src/contexts/PublicAuthContext.tsx +34 -5
  6. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  7. package/src/data/queries/useBroadcasts.ts +151 -0
  8. package/src/data/queries/useMyBookings.ts +9 -1
  9. package/src/i18n/de.json +59 -0
  10. package/src/i18n/en.json +59 -0
  11. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  12. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  13. package/src/ui/format/membershipPwyw.ts +164 -0
  14. package/src/ui/format/pwyw.ts +37 -0
  15. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  16. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  17. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  18. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  19. package/src/ui/headless/index.ts +14 -0
  20. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  21. package/src/ui/index.ts +36 -0
  22. package/src/ui/media/CallHelpHint.tsx +87 -0
  23. package/src/ui/media/CallStage.tsx +542 -0
  24. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  25. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  26. package/src/ui/media/_tests/callState.spec.ts +452 -0
  27. package/src/ui/media/_tests/fakeNode.ts +178 -0
  28. package/src/ui/media/bookingSession.tsx +194 -0
  29. package/src/ui/media/callState.ts +341 -0
  30. package/src/ui/media/index.ts +135 -0
  31. package/src/ui/styled/AccountDashboard.tsx +92 -3
  32. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  33. package/src/ui/styled/ForgotPasswordForm.tsx +5 -0
  34. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  35. package/src/ui/styled/LoginForm.tsx +10 -0
  36. package/src/ui/styled/MembershipCheckout.tsx +318 -45
  37. package/src/ui/styled/MembershipTiers.tsx +10 -3
  38. package/src/ui/styled/ResetPasswordForm.tsx +5 -0
  39. package/src/ui/styled/SignupForm.tsx +5 -0
  40. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  41. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  42. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  43. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  44. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  45. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  46. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  47. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  48. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  49. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  50. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  51. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
@@ -1,43 +1,143 @@
1
- import { useMemo, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import type { IBroadcastPass } from "../../../types/models";
3
- import { useLiveBroadcasts, useValidateBroadcastPass } from "../../../data/queries/useBroadcasts";
3
+ import {
4
+ useLiveBroadcast,
5
+ useLiveBroadcastPoll,
6
+ useBroadcastSessionApi,
7
+ useValidateBroadcastPass,
8
+ } from "../../../data/queries/useBroadcasts";
9
+ import {
10
+ broadcastSessionKey,
11
+ hasBroadcastEnded,
12
+ needsPassValidation,
13
+ selectBroadcastScreen,
14
+ type BroadcastScreen,
15
+ } from "./broadcastState";
4
16
 
5
17
  const errMessage = (e: unknown) =>
6
18
  (e as { response?: { data?: { message?: string } } })?.response?.data?.message || "Invalid pass.";
7
19
 
20
+ /** How often a watching viewer tells the server it is still there. */
21
+ const SESSION_PING_MS = 15000;
22
+
23
+ const readSessionId = (broadcastId?: string): string | null => {
24
+ if (!broadcastId || typeof localStorage === "undefined") return null;
25
+ return localStorage.getItem(broadcastSessionKey(broadcastId));
26
+ };
27
+
8
28
  /**
9
- * Headless broadcast watch: resolves a broadcast from the live list and gates it
10
- * behind pass validation. Composes `useLiveBroadcasts` + `useValidateBroadcastPass`.
11
- * Once `pass` is set the caller can mount its player with the returned playback info.
29
+ * Headless broadcast watch: everything the watch page does apart from drawing it.
30
+ *
31
+ * Reads the broadcast by id, polls a second copy so the end of a stream reaches
32
+ * a viewer who is already watching, re-opens a session held from an earlier
33
+ * visit, gates the whole thing behind pass validation, and keeps the session
34
+ * heartbeat running while the broadcast is on.
35
+ *
36
+ * The render decision itself is `screen`, computed by the pure
37
+ * `selectBroadcastScreen`, so what is on the page is decided by rules that can
38
+ * be checked without a browser.
12
39
  */
13
40
  export function useBroadcastWatch(broadcastId?: string) {
14
- const broadcasts = useLiveBroadcasts();
41
+ const broadcastQuery = useLiveBroadcast(broadcastId);
42
+ const pollQuery = useLiveBroadcastPoll(broadcastId);
15
43
  const validatePass = useValidateBroadcastPass();
44
+ const sessionApi = useBroadcastSessionApi();
45
+
16
46
  const [pass, setPass] = useState<IBroadcastPass | null>(null);
47
+ const [hasValidPass, setHasValidPass] = useState(false);
48
+ const [isSessionLoading, setIsSessionLoading] = useState(true);
17
49
  const [error, setError] = useState<string | null>(null);
50
+ const pingRef = useRef<ReturnType<typeof setInterval> | null>(null);
18
51
 
19
- const broadcast = useMemo(
20
- () => broadcasts.data?.find((b) => b.id === broadcastId),
21
- [broadcasts.data, broadcastId],
52
+ const broadcast = broadcastQuery.data;
53
+ const polled = pollQuery.data;
54
+
55
+ const startPing = useCallback(
56
+ (sessionId: string) => {
57
+ if (!broadcastId) return;
58
+ if (pingRef.current) clearInterval(pingRef.current);
59
+ pingRef.current = setInterval(() => {
60
+ sessionApi.sessionPing(broadcastId, sessionId).catch(() => {
61
+ // A dropped heartbeat is not worth interrupting the stream over. The
62
+ // server ages the session out and the next ping re-establishes it.
63
+ });
64
+ }, SESSION_PING_MS);
65
+ },
66
+ // `sessionApi` is rebuilt every render (it closes over the Axios client), so
67
+ // it is deliberately not a dependency: including it would restart the
68
+ // heartbeat on every render and reset the interval before it ever fired.
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ [broadcastId],
22
71
  );
23
72
 
24
- // Derive lifecycle from the broadcast timestamps (no explicit status field).
25
- const isEnded = !!broadcast?.endedAt;
26
- const isLive = !!broadcast?.startedAt && !broadcast?.endedAt;
27
- const status: "scheduled" | "live" | "ended" | undefined = !broadcast
28
- ? undefined
29
- : isEnded
30
- ? "ended"
31
- : isLive
32
- ? "live"
33
- : "scheduled";
73
+ // Re-open a session parked in storage by an earlier visit. Until this settles
74
+ // the page shows nothing, so a returning viewer never sees the ticket box
75
+ // flash before their existing pass is recognised.
76
+ useEffect(() => {
77
+ if (!broadcastId) return;
78
+ const sessionId = readSessionId(broadcastId);
79
+ if (!sessionId) {
80
+ setIsSessionLoading(false);
81
+ return;
82
+ }
83
+ let cancelled = false;
84
+ sessionApi
85
+ .validateSession(broadcastId, sessionId)
86
+ .then((restored) => {
87
+ if (cancelled) return;
88
+ setPass(restored);
89
+ setHasValidPass(true);
90
+ startPing(restored.sessionId);
91
+ })
92
+ .catch(() => {
93
+ // A session the server no longer honours simply means "ask for the
94
+ // ticket again". It is not an error the viewer needs to read.
95
+ })
96
+ .finally(() => {
97
+ if (!cancelled) setIsSessionLoading(false);
98
+ });
99
+ return () => {
100
+ cancelled = true;
101
+ };
102
+ // eslint-disable-next-line react-hooks/exhaustive-deps
103
+ }, [broadcastId, startPing]);
34
104
 
105
+ const isEnded = hasBroadcastEnded(broadcast, polled);
106
+
107
+ // Once it has ended there is nobody to be counted as watching.
108
+ useEffect(() => {
109
+ if (isEnded && pingRef.current) {
110
+ clearInterval(pingRef.current);
111
+ pingRef.current = null;
112
+ }
113
+ }, [isEnded]);
114
+
115
+ useEffect(
116
+ () => () => {
117
+ if (pingRef.current) clearInterval(pingRef.current);
118
+ },
119
+ [],
120
+ );
121
+
122
+ /**
123
+ * Present a ticket code. On acceptance the session id is parked in storage so
124
+ * a refresh does not re-ask for it, and the heartbeat starts.
125
+ */
35
126
  const validate = async (eventPassId: string, sessionId?: string) => {
36
127
  setError(null);
37
128
  if (!broadcastId) return;
38
129
  try {
39
- const result = await validatePass.mutateAsync({ broadcastId, eventPassId, sessionId });
130
+ const result = await validatePass.mutateAsync({
131
+ broadcastId,
132
+ eventPassId,
133
+ sessionId: sessionId ?? readSessionId(broadcastId) ?? undefined,
134
+ });
135
+ setHasValidPass(true);
136
+ if (typeof localStorage !== "undefined") {
137
+ localStorage.setItem(broadcastSessionKey(broadcastId), result.sessionId);
138
+ }
40
139
  setPass(result);
140
+ startPing(result.sessionId);
41
141
  return result;
42
142
  } catch (e) {
43
143
  setError(errMessage(e));
@@ -45,15 +145,68 @@ export function useBroadcastWatch(broadcastId?: string) {
45
145
  }
46
146
  };
47
147
 
148
+ /**
149
+ * Leave deliberately. The server is told first (so the audience count drops
150
+ * now rather than when the heartbeat ages out), then the stored session goes,
151
+ * so the next visit starts clean.
152
+ */
153
+ const leave = useCallback(() => {
154
+ if (!broadcastId) return;
155
+ sessionApi.leave(broadcastId, readSessionId(broadcastId) ?? undefined).catch(() => {
156
+ // Best effort. The session ages out on its own.
157
+ });
158
+ if (typeof localStorage !== "undefined") {
159
+ localStorage.removeItem(broadcastSessionKey(broadcastId));
160
+ }
161
+ setHasValidPass(false);
162
+ if (pingRef.current) {
163
+ clearInterval(pingRef.current);
164
+ pingRef.current = null;
165
+ }
166
+ // eslint-disable-next-line react-hooks/exhaustive-deps
167
+ }, [broadcastId]);
168
+
169
+ const isLive = !!broadcast?.startedAt && !broadcast?.endedAt;
170
+ const status: "scheduled" | "live" | "ended" | undefined = !broadcast
171
+ ? undefined
172
+ : isEnded
173
+ ? "ended"
174
+ : isLive
175
+ ? "live"
176
+ : "scheduled";
177
+
178
+ const screen: BroadcastScreen = selectBroadcastScreen({
179
+ broadcast,
180
+ polled,
181
+ isBroadcastLoading: broadcastQuery.isLoading,
182
+ isSessionLoading,
183
+ error: broadcastQuery.error,
184
+ hasValidPass,
185
+ pass,
186
+ });
187
+
48
188
  return {
49
189
  broadcast,
50
- isLoading: broadcasts.isLoading,
190
+ /** The polled copy. Only its `endedAt` is acted on. */
191
+ polled,
192
+ isLoading: broadcastQuery.isLoading,
193
+ /** True while a stored session is being re-checked. */
194
+ isSessionLoading,
195
+ /** The failure of the broadcast READ, as opposed to a rejected ticket. */
196
+ loadError: broadcastQuery.error,
51
197
  status,
52
198
  isLive,
53
199
  isEnded,
200
+ /** Does this broadcast sit behind a ticket at all? */
201
+ isGated: needsPassValidation(broadcast),
54
202
  pass,
203
+ hasValidPass,
55
204
  validate,
205
+ leave,
56
206
  isValidating: validatePass.isPending,
207
+ /** The rejected-ticket message, for the viewer to read. */
57
208
  error,
209
+ /** What to draw. */
210
+ screen,
58
211
  };
59
212
  }
@@ -18,7 +18,7 @@ import {
18
18
  import { holdExpiredMessage, isHoldExpiredError } from "../checkout/inventoryHold";
19
19
  import { useInventoryHold } from "../checkout/useInventoryHold";
20
20
  import { computeBookingFeeAmount } from "../../format/bookingFee";
21
- import { isPayWhatYouWant, pwywDefaultAmount, resolveUnitPrice, ticketSubtotals } from "../../format/pwyw";
21
+ import { checkoutAmounts, isPayWhatYouWant, resolveUnitPrice, ticketSubtotals } from "../../format/pwyw";
22
22
  import { parseMembershipGateError, type MembershipGateRefusal } from "../../format/membershipGate";
23
23
  import { readAttributionRef } from "../../../utils/attribution";
24
24
  import { readLanding } from "../../../utils/landing";
@@ -151,18 +151,13 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
151
151
  * they have not. This is what gets SENT, so an untouched box is not the same
152
152
  * as choosing the minimum: the suggestion is the default offer.
153
153
  */
154
- const effectiveAmounts = useMemo(() => {
155
- if (!event) return {} as Record<string, number>;
156
- const out: Record<string, number> = {};
157
- for (const ticket of event.tickets) {
158
- if (!isPayWhatYouWant(ticket) || (selectedTickets[ticket.id] ?? 0) <= 0) continue;
159
- out[ticket.id] =
160
- pwywAmounts[ticket.id] != null
161
- ? resolveUnitPrice(ticket, pwywAmounts[ticket.id])
162
- : pwywDefaultAmount(ticket);
163
- }
164
- return out;
165
- }, [event, selectedTickets, pwywAmounts]);
154
+ const effectiveAmounts = useMemo(
155
+ () =>
156
+ event
157
+ ? checkoutAmounts({ tickets: event.tickets, quantities: selectedTickets, chosen: pwywAmounts })
158
+ : ({} as Record<string, number>),
159
+ [event, selectedTickets, pwywAmounts],
160
+ );
166
161
 
167
162
  /**
168
163
  * `paid` is what the buyer owes for tickets; `floor` is the same cart valued
@@ -99,6 +99,20 @@ export {
99
99
  export { useInvoicePayment, type UseInvoicePaymentOptions } from "./invoice/useInvoicePayment";
100
100
  export { usePaymentLinkPayment, type UsePaymentLinkPaymentOptions } from "./paymentLink/usePaymentLinkPayment";
101
101
  export { useBroadcastWatch } from "./broadcast/useBroadcastWatch";
102
+ export {
103
+ broadcastListStatus,
104
+ broadcastSessionKey,
105
+ hasBroadcastEnded,
106
+ isPassValidated,
107
+ minTicketPrice,
108
+ needsPassValidation,
109
+ selectBroadcastScreen,
110
+ type BroadcastListStatus,
111
+ type BroadcastListStatusText,
112
+ type BroadcastScreen,
113
+ type BroadcastScreenInput,
114
+ type BroadcastView,
115
+ } from "./broadcast/broadcastState";
102
116
  export { useChatRoom } from "./chat/useChatRoom";
103
117
  export { useMessageThread, type UseMessageThreadResult } from "./chat/useMessageThread";
104
118
  export { useChatAttachmentUpload, type PendingAttachment } from "./chat/useChatAttachmentUpload";
@@ -1,4 +1,4 @@
1
- import { useMemo, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
3
3
  import { useGetMembershipTiers } from "../../../data/queries/useMembership";
4
4
  import {
@@ -14,9 +14,30 @@ import {
14
14
  type PaystackCheckoutOutcome,
15
15
  type PaystackCheckoutSession,
16
16
  } from "../../../utils/paystackCheckout";
17
+ import type { MembershipTier } from "../../../types/models";
18
+ import {
19
+ cycleCeiling,
20
+ cycleFloor,
21
+ cycleIsFree,
22
+ defaultChosenAmount,
23
+ defaultCycle,
24
+ offeredCycles,
25
+ refuseMembershipAmount,
26
+ resolveSubscriptionAmount,
27
+ type BillingCycle,
28
+ type MembershipAmountRefusal,
29
+ } from "../../format/membershipPwyw";
17
30
 
18
- export type MembershipCheckoutStep = "select" | "payment";
19
- export type BillingCycle = "month" | "year";
31
+ /**
32
+ * Three steps, matching the client app's three stages.
33
+ *
34
+ * `"tier"` is the tier grid, reached when the page was opened with no
35
+ * `?membershipTierId`. `"select"` keeps its old name deliberately: it is the
36
+ * tier detail + billing options step, and every site already calls
37
+ * `setStep("select")` to go back from payment.
38
+ */
39
+ export type MembershipCheckoutStep = "tier" | "select" | "payment";
40
+ export type { BillingCycle };
20
41
 
21
42
  export interface UseMembershipCheckoutOptions {
22
43
  /** Pre-select a tier (e.g. from a `?membershipTierId=` param). */
@@ -25,27 +46,37 @@ export interface UseMembershipCheckoutOptions {
25
46
  returnPath?: string;
26
47
  /** Where free activations land. Default `/i/account?tab=membership`. */
27
48
  freeReturnPath?: string;
28
- /** Called when a FREE activation completes in-app — the host navigates however
29
- * it wants, instead of Forge doing a `window.location` redirect. */
30
- onComplete?: () => void;
49
+ /**
50
+ * Called when the subscription completes IN-APP with nothing left to pay: a
51
+ * free activation, or a tier change the server settled by proration. The host
52
+ * navigates however it wants instead of Forge doing a `window.location`
53
+ * redirect. `requiresConfirmation` is false for both of those, because there
54
+ * is no provider payment for the account page to reconcile.
55
+ */
56
+ onComplete?: (result: { requiresConfirmation: boolean }) => void;
31
57
  }
32
58
 
33
59
  const errMessage = (e: unknown) =>
34
60
  (e as { response?: { data?: { message?: string } } })?.response?.data?.message || "Something went wrong.";
35
61
 
36
- const tierIsFree = (tier: { payWhatYouWant: boolean; priceMonthly?: number; priceYearly?: number }, cycle: BillingCycle) =>
37
- !tier.payWhatYouWant && !(cycle === "month" ? tier.priceMonthly : tier.priceYearly);
38
-
39
62
  /**
40
- * Headless membership subscribe: tier selection free activation OR paid Stripe
41
- * subscription. Composes `useGetMembershipTiers`, `useCreateFreeSubscription`,
63
+ * Headless membership subscribe: tier selection, billing cycle, pay-what-you-want
64
+ * amount, then free activation OR a paid subscription on the tenant's provider.
65
+ * Composes `useGetMembershipTiers`, `useCreateFreeSubscription`,
42
66
  * `useCreateSubscription`, and (for the return page) `useConfirmLatestSubscription`.
43
- * Paid flow exposes `clientSecret` for `ForgePaymentProvider`.
67
+ * Paid Stripe flow exposes `clientSecret` for `ForgePaymentProvider`; paid
68
+ * Paystack flow exposes `openPaystackCheckout`.
44
69
  *
45
70
  * Three outcomes, not two: a member who ALREADY subscribes to this artist is
46
71
  * changing tier, which the server settles by proration on their existing
47
- * subscription. That completes server-side with no payment step it is
72
+ * subscription. That completes server-side with no payment step, it is
48
73
  * finished the moment `subscribe()` resolves, exactly like a free activation.
74
+ *
75
+ * ## Pricing lives in `ui/format/membershipPwyw`
76
+ *
77
+ * The floor, the default cycle, which cycles are on offer and the single number
78
+ * that gets sent are pure functions there, so they are testable without a
79
+ * provider tree. Read that file before changing anything about money here.
49
80
  */
50
81
  export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
51
82
  const { user } = usePublicAuth();
@@ -54,28 +85,105 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
54
85
  const createFree = useCreateFreeSubscription();
55
86
  const confirmLatest = useConfirmLatestSubscription();
56
87
 
57
- const [step, setStep] = useState<MembershipCheckoutStep>("select");
88
+ const [step, setStep] = useState<MembershipCheckoutStep>(opts.initialTierId ? "select" : "tier");
58
89
  const [selectedTierId, setSelectedTierId] = useState<string | undefined>(opts.initialTierId);
59
- const [billingCycle, setBillingCycle] = useState<BillingCycle>("month");
90
+ const [billingCycle, setBillingCycleState] = useState<BillingCycle>("month");
60
91
  const [customAmount, setCustomAmount] = useState<number>(0);
92
+ const [amountRefusal, setAmountRefusal] = useState<MembershipAmountRefusal | null>(null);
61
93
  const [clientSecret, setClientSecret] = useState<string | undefined>();
62
94
  /** The started Paystack checkout, kept so the modal can be re-opened. */
63
95
  const [paystackSession, setPaystackSession] = useState<PaystackCheckoutSession | null>(null);
64
96
  const [error, setError] = useState<string | null>(null);
65
97
 
66
- const selectedTier = useMemo(
67
- () => tiers?.find((t) => t.id === selectedTierId),
68
- [tiers, selectedTierId],
98
+ const selectedTier = useMemo(() => tiers?.find((t) => t.id === selectedTierId), [tiers, selectedTierId]);
99
+
100
+ /**
101
+ * Seed the cycle and the amount box from the tier itself.
102
+ *
103
+ * This runs on the tier ARRIVING as much as on it being clicked: a page opened
104
+ * with `?membershipTierId=` has a selected id before the tier list has
105
+ * loaded, and without this the box would sit at 0 on a tier whose floor is 20.
106
+ *
107
+ * Keyed on the tier ID, not the tier OBJECT: the tier list is a React Query
108
+ * result, so a background refetch hands back an equal-but-new object, and
109
+ * keying on that would wipe the amount a fan had just typed.
110
+ */
111
+ useEffect(() => {
112
+ if (!selectedTier) return;
113
+ const cycle = defaultCycle(selectedTier);
114
+ setBillingCycleState(cycle);
115
+ setCustomAmount(defaultChosenAmount(selectedTier, cycle));
116
+ setAmountRefusal(null);
117
+ // eslint-disable-next-line react-hooks/exhaustive-deps
118
+ }, [selectedTier?.id]);
119
+
120
+ /** Switching cycle re-seeds the amount box: the floor belongs to the cycle. */
121
+ const setBillingCycle = useCallback(
122
+ (cycle: BillingCycle) => {
123
+ setBillingCycleState(cycle);
124
+ setAmountRefusal(null);
125
+ if (selectedTier) setCustomAmount(defaultChosenAmount(selectedTier, cycle));
126
+ },
127
+ [selectedTier],
69
128
  );
70
129
 
71
- const amount = useMemo(() => {
72
- if (!selectedTier) return 0;
73
- if (selectedTier.payWhatYouWant) return customAmount;
74
- return (billingCycle === "month" ? selectedTier.priceMonthly : selectedTier.priceYearly) ?? 0;
75
- }, [selectedTier, billingCycle, customAmount]);
130
+ const selectTier = useCallback((tier: MembershipTier) => {
131
+ setSelectedTierId(tier.id);
132
+ setError(null);
133
+ setAmountRefusal(null);
134
+ setStep("select");
135
+ }, []);
136
+
137
+ const backToTierList = useCallback(() => {
138
+ setSelectedTierId(undefined);
139
+ setError(null);
140
+ setAmountRefusal(null);
141
+ setStep("tier");
142
+ }, []);
143
+
144
+ const minimumAmount = selectedTier ? cycleFloor(selectedTier, billingCycle) : 0;
145
+ const maximumAmount = selectedTier ? cycleCeiling(selectedTier, billingCycle) : null;
146
+ const cycles = selectedTier ? offeredCycles(selectedTier) : { month: false, year: false };
147
+ const isFreeCycle = selectedTier ? cycleIsFree(selectedTier, billingCycle) : false;
148
+
149
+ /**
150
+ * What will be charged, and what will be SENT. A fixed tier ignores the
151
+ * amount box, so a figure left over from a pay-what-you-want tier cannot leak
152
+ * into a fixed-price subscription.
153
+ */
154
+ const amount = useMemo(
155
+ () => (selectedTier ? resolveSubscriptionAmount(selectedTier, billingCycle, customAmount) : 0),
156
+ [selectedTier, billingCycle, customAmount],
157
+ );
158
+
159
+ const membership = user?.membership;
160
+ /** The tier they are on now, so its card can say so instead of offering itself. */
161
+ const currentTierId = membership?.membershipTierId;
162
+ /**
163
+ * A member with a live provider subscription picking a PAID tier is changing,
164
+ * not joining. The server settles it by proration on the subscription they
165
+ * already have, so the button says so.
166
+ */
167
+ const isChange =
168
+ !!membership?.paymentProviderSubscriptionId && membership.status === "active" && !isFreeCycle && !!selectedTier;
169
+
170
+ const finish = (requiresConfirmation: boolean) => {
171
+ if (opts.onComplete) {
172
+ opts.onComplete({ requiresConfirmation });
173
+ return;
174
+ }
175
+ if (typeof window !== "undefined") {
176
+ const origin = window.location.origin;
177
+ const path = requiresConfirmation
178
+ ? (opts.returnPath ?? "/i/account?tab=membership&confirmSubscription=true")
179
+ : (opts.freeReturnPath ?? "/i/account?tab=membership");
180
+ window.location.href = `${origin}${path}`;
181
+ }
182
+ };
76
183
 
77
184
  const subscribe = async () => {
78
185
  setError(null);
186
+ setAmountRefusal(null);
79
187
  if (!selectedTier) {
80
188
  setError("Select a membership tier.");
81
189
  return;
@@ -84,17 +192,22 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
84
192
  setError("Please sign in to subscribe.");
85
193
  return;
86
194
  }
195
+ // Checked BEFORE the request, so a pay-what-you-want box below the floor
196
+ // (or still on its initial zero) is a message beside the field rather than
197
+ // an `amount_must_be_positive` 400 with nowhere to land.
198
+ const refusal = refuseMembershipAmount(selectedTier, billingCycle, customAmount);
199
+ if (refusal) {
200
+ setAmountRefusal(refusal);
201
+ return;
202
+ }
87
203
  const origin = typeof window !== "undefined" ? window.location.origin : "";
88
204
  const returnUrl = `${origin}${opts.returnPath ?? "/i/account?tab=membership&confirmSubscription=true"}`;
89
205
  const attributionRefId = readAttributionRef() ?? undefined;
90
206
  const landing = readLanding() ?? {};
91
207
  try {
92
- if (tierIsFree(selectedTier, billingCycle)) {
208
+ if (isFreeCycle) {
93
209
  await createFree.mutateAsync({ membershipTierId: selectedTier.id, attributionRefId, ...landing });
94
- if (opts.onComplete) opts.onComplete();
95
- else if (typeof window !== "undefined") {
96
- window.location.href = `${origin}${opts.freeReturnPath ?? "/i/account?tab=membership"}`;
97
- }
210
+ finish(false);
98
211
  return;
99
212
  }
100
213
  const data = await createPaid.mutateAsync({
@@ -115,10 +228,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
115
228
  // membership nobody has paid for.
116
229
  const requiresPayment = data.requiresPayment ?? !!(data.clientSecret || data.accessCode || data.checkoutUrl);
117
230
  if (!requiresPayment) {
118
- if (opts.onComplete) opts.onComplete();
119
- else if (typeof window !== "undefined") {
120
- window.location.href = `${origin}${opts.freeReturnPath ?? "/i/account?tab=membership"}`;
121
- }
231
+ finish(false);
122
232
  return;
123
233
  }
124
234
 
@@ -156,10 +266,26 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
156
266
  selectedTierId,
157
267
  setSelectedTierId,
158
268
  selectedTier,
269
+ selectTier,
270
+ backToTierList,
271
+ /** The tier the member is already on, so its card can be shown as taken. */
272
+ currentTierId,
273
+ /** True when pressing subscribe MOVES an existing subscription rather than starting one. */
274
+ isChange,
159
275
  billingCycle,
160
276
  setBillingCycle,
277
+ /** Which billing cycles this tier actually sells. */
278
+ cycles,
279
+ /** True when this cycle costs nothing, so `/subscriptions/free` is the call. */
280
+ isFreeCycle,
161
281
  customAmount,
162
282
  setCustomAmount,
283
+ /** The lowest amount this cycle may be bought at, in settlement-currency major units. */
284
+ minimumAmount,
285
+ /** The operator's ceiling for display, or null. Never applied to what is sent. */
286
+ maximumAmount,
287
+ /** Why the entered amount was refused, or null. Cleared on every edit path. */
288
+ amountRefusal,
163
289
  amount,
164
290
  subscribe,
165
291
  clientSecret,
@@ -178,5 +304,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
178
304
  error,
179
305
  /** Call on the return page to reconcile the latest subscription. */
180
306
  confirmLatest,
307
+ /** Signals a completed paid payment (the Stripe `onSucceeded` leg). */
308
+ finish,
181
309
  };
182
310
  }
package/src/ui/index.ts CHANGED
@@ -153,6 +153,24 @@ export { PodcastList, type PodcastListProps } from "./styled/PodcastList";
153
153
  export { PodcastShow, type PodcastShowProps } from "./styled/PodcastShow";
154
154
  export { PodcastEpisode, type PodcastEpisodeProps } from "./styled/PodcastEpisode";
155
155
  export { ReplayList, type ReplayListProps } from "./styled/ReplayList";
156
+ // Live broadcasts. A replay is the recording of one of these; this is the half
157
+ // that is on now. `BroadcastWatch` carries the paywall.
158
+ export { LiveBroadcastList, type LiveBroadcastListProps } from "./styled/LiveBroadcastList";
159
+ export { BroadcastWatch, type BroadcastWatchProps } from "./styled/BroadcastWatch";
160
+ export { EndedBroadcast, type EndedBroadcastProps } from "./styled/broadcast/EndedBroadcast";
161
+ export {
162
+ BroadcastPassValidation,
163
+ type BroadcastPassValidationProps,
164
+ } from "./styled/broadcast/BroadcastPassValidation";
165
+ export {
166
+ BroadcastTicketPurchase,
167
+ type BroadcastTicketPurchaseProps,
168
+ } from "./styled/broadcast/BroadcastTicketPurchase";
169
+ export {
170
+ BroadcastPlayer,
171
+ type BroadcastPlayerProps,
172
+ type BroadcastPlayerRenderProps,
173
+ } from "./styled/broadcast/BroadcastPlayer";
156
174
  export { ConfirmSubscription, type ConfirmSubscriptionProps } from "./styled/ConfirmSubscription";
157
175
  export { LeadMagnet, type LeadMagnetProps } from "./styled/LeadMagnet";
158
176
  export { CohortPage, type CohortPageProps } from "./styled/CohortPage";
@@ -177,6 +195,24 @@ export {
177
195
  resolveUnitPrice,
178
196
  ticketSubtotals,
179
197
  } from "./format/pwyw";
198
+ // Membership pricing: which billing cycles a tier sells, a pay-what-you-want
199
+ // cycle's floor, and the ONE number that may be sent to the subscriptions API.
200
+ // Settlement-currency MAJOR units throughout: convert for the screen, never for
201
+ // the request.
202
+ export {
203
+ cycleFloor,
204
+ cycleCeiling,
205
+ cycleIsOffered,
206
+ cycleIsFree,
207
+ offeredCycles,
208
+ defaultCycle,
209
+ defaultChosenAmount,
210
+ clampMembershipAmount,
211
+ resolveSubscriptionAmount,
212
+ refuseMembershipAmount,
213
+ type PricedTier,
214
+ type MembershipAmountRefusal,
215
+ } from "./format/membershipPwyw";
180
216
  // When a tier may be BOUGHT, for a site rendering its own ticket UI. Both halves
181
217
  // close a "discoverable only by failing" journey: `ticketSeatsAvailable`
182
218
  // subtracts `held` the way the server does, so a fan is never shown seats that