@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.
- package/package.json +6 -3
- package/src/_tests/publishedResolvability.spec.ts +184 -0
- package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
- package/src/_tests/workspaceAliases.ts +40 -0
- package/src/contexts/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useMyBookings.ts +9 -1
- package/src/i18n/de.json +59 -0
- package/src/i18n/en.json +59 -0
- package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
- package/src/ui/format/_tests/pwyw.spec.ts +65 -8
- package/src/ui/format/membershipPwyw.ts +164 -0
- package/src/ui/format/pwyw.ts +37 -0
- package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
- package/src/ui/headless/broadcast/broadcastState.ts +158 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
- package/src/ui/headless/event/useEventCheckout.ts +8 -13
- package/src/ui/headless/index.ts +14 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
- package/src/ui/index.ts +36 -0
- package/src/ui/media/CallHelpHint.tsx +87 -0
- package/src/ui/media/CallStage.tsx +542 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
- package/src/ui/media/_tests/callState.spec.ts +452 -0
- package/src/ui/media/_tests/fakeNode.ts +178 -0
- package/src/ui/media/bookingSession.tsx +194 -0
- package/src/ui/media/callState.ts +341 -0
- package/src/ui/media/index.ts +135 -0
- package/src/ui/styled/AccountDashboard.tsx +92 -3
- package/src/ui/styled/BroadcastWatch.tsx +107 -0
- package/src/ui/styled/ForgotPasswordForm.tsx +5 -0
- package/src/ui/styled/LiveBroadcastList.tsx +171 -0
- package/src/ui/styled/LoginForm.tsx +10 -0
- package/src/ui/styled/MembershipCheckout.tsx +318 -45
- package/src/ui/styled/MembershipTiers.tsx +10 -3
- package/src/ui/styled/ResetPasswordForm.tsx +5 -0
- package/src/ui/styled/SignupForm.tsx +5 -0
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
- package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
- package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
- package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
- package/src/ui/styled/community/CommunityComposer.tsx +182 -3
- package/src/ui/styled/community/CommunityFeed.tsx +36 -51
- package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
|
@@ -1,43 +1,143 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import type { IBroadcastPass } from "../../../types/models";
|
|
3
|
-
import {
|
|
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:
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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 =
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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({
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
package/src/ui/headless/index.ts
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
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
|
-
/**
|
|
29
|
-
*
|
|
30
|
-
|
|
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
|
|
41
|
-
* subscription
|
|
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
|
|
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,
|
|
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
|
-
|
|
68
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 (
|
|
208
|
+
if (isFreeCycle) {
|
|
93
209
|
await createFree.mutateAsync({ membershipTierId: selectedTier.id, attributionRefId, ...landing });
|
|
94
|
-
|
|
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
|
-
|
|
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
|