@tribe-nest/forge 3.26.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/client/_tests/localeHeader.spec.tsx +98 -0
- package/src/client/activeLocale.ts +34 -0
- package/src/client/createForgeClient.ts +13 -0
- package/src/contexts/CartContext.tsx +76 -0
- package/src/contexts/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/CartContext.spec.tsx +210 -0
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useCheckouts.ts +83 -0
- package/src/data/queries/useMyBookings.ts +9 -1
- package/src/data/queries/useWebsite.ts +7 -0
- package/src/i18n/CONVENTION.md +184 -0
- package/src/i18n/_tests/translationKeys.spec.ts +153 -0
- package/src/i18n/de.json +1019 -0
- package/src/i18n/en.json +1019 -0
- package/src/i18n/index.ts +125 -0
- package/src/index.ts +21 -0
- package/src/provider/ForgeAppProvider.tsx +2 -1
- package/src/provider/SiteConfigProvider.tsx +11 -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/checkout/_tests/cartResume.spec.ts +291 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
- package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
- package/src/ui/headless/checkout/useCartResume.ts +340 -0
- package/src/ui/headless/checkout/useCheckout.ts +9 -2
- package/src/ui/headless/event/useEventCheckout.ts +8 -13
- package/src/ui/headless/index.ts +32 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
- package/src/ui/index.ts +38 -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/shell/TribeNestApp.tsx +36 -27
- package/src/ui/styled/AccountDashboard.tsx +289 -96
- package/src/ui/styled/AiAgentWidget.tsx +10 -6
- package/src/ui/styled/AudioPlayer.tsx +14 -6
- package/src/ui/styled/BlogCategory.tsx +4 -2
- package/src/ui/styled/BlogComments.tsx +27 -20
- package/src/ui/styled/BlogList.tsx +5 -3
- package/src/ui/styled/BlogPost.tsx +14 -11
- package/src/ui/styled/BroadcastWatch.tsx +107 -0
- package/src/ui/styled/BundleConfirmation.tsx +32 -23
- package/src/ui/styled/CancellationTerms.tsx +3 -1
- package/src/ui/styled/Cart.tsx +32 -14
- package/src/ui/styled/ChatRoom.tsx +65 -20
- package/src/ui/styled/Checkout.tsx +151 -89
- package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
- package/src/ui/styled/CoachingBooking.tsx +71 -44
- package/src/ui/styled/CoachingConfirmation.tsx +56 -19
- package/src/ui/styled/CoachingDetail.tsx +15 -11
- package/src/ui/styled/CohortPage.tsx +10 -8
- package/src/ui/styled/ConfirmSubscription.tsx +14 -8
- package/src/ui/styled/ContactForm.tsx +31 -12
- package/src/ui/styled/CookieConsent.tsx +19 -15
- package/src/ui/styled/CourseAccess.tsx +54 -47
- package/src/ui/styled/CourseCheckout.tsx +56 -38
- package/src/ui/styled/CourseConfirmation.tsx +39 -17
- package/src/ui/styled/CourseDetail.tsx +21 -15
- package/src/ui/styled/CoursesGrid.tsx +4 -2
- package/src/ui/styled/CurrencySwitcher.tsx +4 -1
- package/src/ui/styled/DiscountCode.tsx +22 -18
- package/src/ui/styled/DonationButton.tsx +52 -17
- package/src/ui/styled/DonationPage.tsx +4 -4
- package/src/ui/styled/EmailListForm.tsx +22 -9
- package/src/ui/styled/EventConfirmation.tsx +37 -14
- package/src/ui/styled/EventCountdown.tsx +10 -6
- package/src/ui/styled/EventDetail.tsx +8 -6
- package/src/ui/styled/EventSeriesDetail.tsx +18 -13
- package/src/ui/styled/EventTickets.tsx +232 -108
- package/src/ui/styled/EventWaitlist.tsx +42 -33
- package/src/ui/styled/EventsList.tsx +15 -13
- package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
- package/src/ui/styled/FormRenderer.tsx +12 -7
- package/src/ui/styled/InstallBanner.tsx +21 -18
- package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
- package/src/ui/styled/InvoicePayment.tsx +37 -16
- package/src/ui/styled/LeadMagnet.tsx +9 -7
- package/src/ui/styled/LiveBroadcastList.tsx +171 -0
- package/src/ui/styled/Loading.tsx +7 -1
- package/src/ui/styled/LoginForm.tsx +32 -14
- package/src/ui/styled/MembershipCheckout.tsx +341 -49
- package/src/ui/styled/MembershipTiers.tsx +34 -18
- package/src/ui/styled/OfferButton.tsx +33 -13
- package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
- package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
- package/src/ui/styled/PodcastEpisode.tsx +20 -9
- package/src/ui/styled/PodcastList.tsx +9 -3
- package/src/ui/styled/PodcastShow.tsx +29 -7
- package/src/ui/styled/PostsFeed.tsx +10 -8
- package/src/ui/styled/PresaleCode.tsx +8 -11
- package/src/ui/styled/ProductBrowseNav.tsx +21 -11
- package/src/ui/styled/ProductDetail.tsx +58 -28
- package/src/ui/styled/ReactionBar.tsx +4 -2
- package/src/ui/styled/ReplayList.tsx +24 -13
- package/src/ui/styled/ResetPasswordForm.tsx +32 -15
- package/src/ui/styled/ResumeCart.tsx +289 -0
- package/src/ui/styled/ReviewForm.tsx +52 -58
- package/src/ui/styled/SignupForm.tsx +34 -16
- package/src/ui/styled/TicketTransfer.tsx +55 -54
- package/src/ui/styled/UserMenu.tsx +16 -14
- 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/_tests/ResumeCart.spec.tsx +183 -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,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
|
@@ -55,6 +55,8 @@ export { PresaleCodeField, type PresaleCodeFieldProps } from "./styled/PresaleCo
|
|
|
55
55
|
export { MembershipCheckout, type MembershipCheckoutProps } from "./styled/MembershipCheckout";
|
|
56
56
|
export { ForgeStripePayment } from "./payment/ForgeStripePayment";
|
|
57
57
|
export { Cart, type CartProps } from "./styled/Cart";
|
|
58
|
+
/** The page a cart-recovery email lands on. Drop in at `/i/checkout/resume`. */
|
|
59
|
+
export { ResumeCart, type ResumeCartProps } from "./styled/ResumeCart";
|
|
58
60
|
// The resolver is exported alongside the component because the Craft themes
|
|
59
61
|
// are a separate rendering stack (Tailwind, not inline styles) and cannot use
|
|
60
62
|
// the component — but the legacy `color`/`size` fallback must not diverge.
|
|
@@ -151,6 +153,24 @@ export { PodcastList, type PodcastListProps } from "./styled/PodcastList";
|
|
|
151
153
|
export { PodcastShow, type PodcastShowProps } from "./styled/PodcastShow";
|
|
152
154
|
export { PodcastEpisode, type PodcastEpisodeProps } from "./styled/PodcastEpisode";
|
|
153
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";
|
|
154
174
|
export { ConfirmSubscription, type ConfirmSubscriptionProps } from "./styled/ConfirmSubscription";
|
|
155
175
|
export { LeadMagnet, type LeadMagnetProps } from "./styled/LeadMagnet";
|
|
156
176
|
export { CohortPage, type CohortPageProps } from "./styled/CohortPage";
|
|
@@ -175,6 +195,24 @@ export {
|
|
|
175
195
|
resolveUnitPrice,
|
|
176
196
|
ticketSubtotals,
|
|
177
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";
|
|
178
216
|
// When a tier may be BOUGHT, for a site rendering its own ticket UI. Both halves
|
|
179
217
|
// close a "discoverable only by failing" journey: `ticketSeatsAvailable`
|
|
180
218
|
// subtracts `held` the way the server does, so a fan is never shown seats that
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useId, useState, type ReactNode } from "react";
|
|
2
|
+
import { HelpCircle } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `?` that carries an explanation, instead of a paragraph that always shows.
|
|
8
|
+
*
|
|
9
|
+
* House design law: if a piece of text exists to EXPLAIN something, it goes
|
|
10
|
+
* behind a `?`. A call screen is the worst possible place to break that rule,
|
|
11
|
+
* because it is looked at while something is happening and every sentence that
|
|
12
|
+
* is not about right now is a sentence in the way.
|
|
13
|
+
*
|
|
14
|
+
* ## What is NOT help, and stays visible on this surface
|
|
15
|
+
*
|
|
16
|
+
* - the connection state, and what went wrong when it went wrong
|
|
17
|
+
* - a denied camera or microphone permission
|
|
18
|
+
* - the recording indicator, which is a consent signal
|
|
19
|
+
*
|
|
20
|
+
* ## Why Forge grows its own rather than importing `HelpHint`
|
|
21
|
+
*
|
|
22
|
+
* `@tribe-nest/frontend-shared` is not a dependency of this package and cannot
|
|
23
|
+
* become one: Forge publishes raw source and a creator site installs only
|
|
24
|
+
* Forge's declared dependencies, so an import of it would resolve here (npm
|
|
25
|
+
* workspaces symlink everything) and fail on every real site. See
|
|
26
|
+
* `_tests/publishedResolvability.spec.ts`. This is a disclosure built from a
|
|
27
|
+
* button and a span, which needs nothing.
|
|
28
|
+
*/
|
|
29
|
+
export type CallHelpHintProps = {
|
|
30
|
+
/** Accessible name. Say what it explains ("What does sharing send?"), never "help". */
|
|
31
|
+
label: string;
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function CallHelpHint({ label, children }: CallHelpHintProps) {
|
|
36
|
+
const tokens = useThemeTokens();
|
|
37
|
+
const [open, setOpen] = useState(false);
|
|
38
|
+
const panelId = useId();
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<span style={{ position: "relative", display: "inline-flex", verticalAlign: "middle" }}>
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
aria-label={label}
|
|
45
|
+
aria-expanded={open}
|
|
46
|
+
aria-controls={panelId}
|
|
47
|
+
onClick={() => setOpen((was) => !was)}
|
|
48
|
+
style={{
|
|
49
|
+
display: "inline-flex",
|
|
50
|
+
alignItems: "center",
|
|
51
|
+
justifyContent: "center",
|
|
52
|
+
background: "transparent",
|
|
53
|
+
border: "none",
|
|
54
|
+
padding: 0,
|
|
55
|
+
cursor: "pointer",
|
|
56
|
+
color: tokens.muted,
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
<HelpCircle width={14} height={14} aria-hidden="true" />
|
|
60
|
+
</button>
|
|
61
|
+
{open && (
|
|
62
|
+
<span
|
|
63
|
+
id={panelId}
|
|
64
|
+
role="note"
|
|
65
|
+
style={{
|
|
66
|
+
position: "absolute",
|
|
67
|
+
bottom: "calc(100% + 6px)",
|
|
68
|
+
left: 0,
|
|
69
|
+
zIndex: 20,
|
|
70
|
+
width: "16rem",
|
|
71
|
+
padding: "0.5rem 0.625rem",
|
|
72
|
+
borderRadius: tokens.cornerRadius,
|
|
73
|
+
border: `1px solid ${tokens.border}`,
|
|
74
|
+
backgroundColor: tokens.surface,
|
|
75
|
+
color: tokens.text,
|
|
76
|
+
fontSize: "0.75rem",
|
|
77
|
+
lineHeight: 1.5,
|
|
78
|
+
textAlign: "left",
|
|
79
|
+
fontWeight: 400,
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
{children}
|
|
83
|
+
</span>
|
|
84
|
+
)}
|
|
85
|
+
</span>
|
|
86
|
+
);
|
|
87
|
+
}
|