@tribe-nest/forge 3.54.0 → 3.57.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 +1 -1
- package/src/data/queries/useCoachingProducts.ts +15 -1
- package/src/data/queries/useCourses.ts +15 -1
- package/src/data/queries/useEvents.ts +12 -1
- package/src/data/queries/useMembership.ts +176 -4
- package/src/data/queries/useProducts.ts +15 -1
- package/src/data/queries/useSubscriptions.ts +21 -1
- package/src/i18n/de.json +74 -0
- package/src/i18n/en.json +74 -0
- package/src/server/index.ts +74 -0
- package/src/server/platformEvents.generated.ts +60 -0
- package/src/types/models.ts +127 -0
- package/src/ui/headless/index.ts +23 -0
- package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
- package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
- package/src/ui/headless/membership/membershipApplication.ts +109 -0
- package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
- package/src/ui/headless/membership/membershipTrial.ts +53 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
- package/src/ui/index.ts +9 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
- package/src/ui/payment/ForgeStripePayment.tsx +78 -7
- package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
- package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
- package/src/ui/payment/stripeIntentKind.ts +32 -0
- package/src/ui/styled/AccountDashboard.tsx +160 -2
- package/src/ui/styled/LoginForm.tsx +5 -3
- package/src/ui/styled/MembershipCheckout.tsx +596 -256
- package/src/ui/styled/MembershipTierCallout.tsx +17 -3
- package/src/ui/styled/MembershipTiers.tsx +148 -6
- package/src/ui/styled/ProductGrid.tsx +14 -2
- package/src/ui/styled/SignupForm.tsx +4 -2
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
- package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
package/src/i18n/en.json
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
{
|
|
2
|
+
"forge.account_dashboard.application_complete": "Complete your membership",
|
|
3
|
+
"forge.account_dashboard.application_complete_by": "Complete by {date}",
|
|
4
|
+
"forge.account_dashboard.application_status_approved": "Approved, finishing up",
|
|
5
|
+
"forge.account_dashboard.application_status_card_pending": "Card not confirmed yet",
|
|
6
|
+
"forge.account_dashboard.application_status_declined": "Not approved",
|
|
7
|
+
"forge.account_dashboard.application_status_expired": "Expired",
|
|
8
|
+
"forge.account_dashboard.application_status_joined": "Joined",
|
|
9
|
+
"forge.account_dashboard.application_status_payment_failed": "Approved: complete your membership",
|
|
10
|
+
"forge.account_dashboard.application_status_pending": "Application pending",
|
|
11
|
+
"forge.account_dashboard.application_status_withdrawn": "Withdrawn",
|
|
12
|
+
"forge.account_dashboard.application_submitted_on": "Applied on {date}",
|
|
13
|
+
"forge.account_dashboard.application_withdraw": "Withdraw",
|
|
14
|
+
"forge.account_dashboard.application_withdraw_error": "We couldn't withdraw your application. Please try again.",
|
|
15
|
+
"forge.account_dashboard.application_withdrawing": "Withdrawing…",
|
|
16
|
+
"forge.account_dashboard.applications_title": "Your applications",
|
|
2
17
|
"forge.account_dashboard.billing_portal_error": "We couldn't open the billing page. Please contact us.",
|
|
3
18
|
"forge.account_dashboard.billing_portal_opening": "Opening…",
|
|
4
19
|
"forge.account_dashboard.booking_cancel_error": "We could not cancel this session. Please try again.",
|
|
@@ -16,6 +31,7 @@
|
|
|
16
31
|
"forge.account_dashboard.cancel_confirm_paid": "It will not renew, and you keep your benefits until the end of the period you have paid for.",
|
|
17
32
|
"forge.account_dashboard.cancel_confirm_paid_until": "It will not renew, and you keep your benefits until {date}.",
|
|
18
33
|
"forge.account_dashboard.cancel_confirm_title": "Cancel your membership?",
|
|
34
|
+
"forge.account_dashboard.cancel_confirm_trial": "Your free trial will not turn into a paid membership. You keep your benefits until {date}, and nothing is charged.",
|
|
19
35
|
"forge.account_dashboard.cancel_deletion": "Cancel Deletion",
|
|
20
36
|
"forge.account_dashboard.cancel_session": "Cancel session",
|
|
21
37
|
"forge.account_dashboard.cancel_ticket": "Cancel ticket",
|
|
@@ -29,6 +45,8 @@
|
|
|
29
45
|
"forge.account_dashboard.courses_start": "Start",
|
|
30
46
|
"forge.account_dashboard.courses_title": "Your courses",
|
|
31
47
|
"forge.account_dashboard.current_password": "Current password",
|
|
48
|
+
"forge.account_dashboard.cycle_month": "month",
|
|
49
|
+
"forge.account_dashboard.cycle_year": "year",
|
|
32
50
|
"forge.account_dashboard.data_export_description": "Export a copy of your personal data.",
|
|
33
51
|
"forge.account_dashboard.data_section_title": "Your Data",
|
|
34
52
|
"forge.account_dashboard.delete_section_title": "Delete Account",
|
|
@@ -50,6 +68,9 @@
|
|
|
50
68
|
"forge.account_dashboard.membership_price": "{amount} / {cycle}",
|
|
51
69
|
"forge.account_dashboard.membership_renews_on": "Renews on {date}",
|
|
52
70
|
"forge.account_dashboard.membership_title": "Current Membership",
|
|
71
|
+
"forge.account_dashboard.membership_trial_cancelled": "Cancelled. Nothing will be charged.",
|
|
72
|
+
"forge.account_dashboard.membership_trial_then": "Then {amount} per {cycle} from {date}.",
|
|
73
|
+
"forge.account_dashboard.membership_trial_until": "Free trial until {date}",
|
|
53
74
|
"forge.account_dashboard.new_password": "New password",
|
|
54
75
|
"forge.account_dashboard.notifications_title": "Notification Types",
|
|
55
76
|
"forge.account_dashboard.notify_chat_description": "Get notified when new chat messages arrive",
|
|
@@ -1003,8 +1024,16 @@
|
|
|
1003
1024
|
"forge.film_watch.up_next": "Up next: {title}",
|
|
1004
1025
|
"forge.film_watch.window_not_started": "Your window starts now.",
|
|
1005
1026
|
"forge.forge_stripe_payment.authorize": "Authorize {amount}",
|
|
1027
|
+
"forge.forge_stripe_payment.cycle_month": "month",
|
|
1028
|
+
"forge.forge_stripe_payment.cycle_year": "year",
|
|
1006
1029
|
"forge.forge_stripe_payment.deferred_charged_on_approval": "Your card is only charged if we approve your request.",
|
|
1007
1030
|
"forge.forge_stripe_payment.deferred_hold_on_statement": "Until then a temporary hold will show on your statement.",
|
|
1031
|
+
"forge.forge_stripe_payment.setup_charged_on_approval": "Nothing is charged now. We will only charge this card if we approve your application, and then at the amount shown ({amount}).",
|
|
1032
|
+
"forge.forge_stripe_payment.setup_failed": "We couldn't save your card. Please try again.",
|
|
1033
|
+
"forge.forge_stripe_payment.setup_incomplete": "Your card was not saved. Please try again.",
|
|
1034
|
+
"forge.forge_stripe_payment.start_free_trial": "Start free trial",
|
|
1035
|
+
"forge.forge_stripe_payment.submit_application": "Submit application",
|
|
1036
|
+
"forge.forge_stripe_payment.trial_consent": "Nothing is charged today. {amount} per {cycle} from {date}. Cancel before then and you will not be charged.",
|
|
1008
1037
|
"forge.forgot_password_form.back_to_login": "Back to login",
|
|
1009
1038
|
"forge.forgot_password_form.email_label": "Email",
|
|
1010
1039
|
"forge.forgot_password_form.email_placeholder": "Email",
|
|
@@ -1082,8 +1111,27 @@
|
|
|
1082
1111
|
"forge.login_form.verify_submit": "Verify & Sign In",
|
|
1083
1112
|
"forge.login_form.verify_title": "Verify your email",
|
|
1084
1113
|
"forge.login_form.verifying": "Verifying…",
|
|
1114
|
+
"forge.membership_checkout.accept_invite": "Accept invite",
|
|
1085
1115
|
"forge.membership_checkout.amount_below_minimum": "Enter {minimum} or more.",
|
|
1086
1116
|
"forge.membership_checkout.amount_not_positive": "Enter an amount greater than zero.",
|
|
1117
|
+
"forge.membership_checkout.answer_required": "Please answer: {question}",
|
|
1118
|
+
"forge.membership_checkout.application_approved_body": "Your application to {tier_name} was approved. Your membership is being set up.",
|
|
1119
|
+
"forge.membership_checkout.application_approved_title": "You're approved",
|
|
1120
|
+
"forge.membership_checkout.application_card_title": "Your card",
|
|
1121
|
+
"forge.membership_checkout.application_closed_body": "Your application to {tier_name} is closed. Nothing was charged.",
|
|
1122
|
+
"forge.membership_checkout.application_closed_title": "Application closed",
|
|
1123
|
+
"forge.membership_checkout.application_declined_body": "Your application to {tier_name} was not approved. Nothing was charged.",
|
|
1124
|
+
"forge.membership_checkout.application_declined_title": "Application not approved",
|
|
1125
|
+
"forge.membership_checkout.application_received_body": "Thanks for applying to {tier_name}. We'll email you once we've reviewed your application.",
|
|
1126
|
+
"forge.membership_checkout.application_received_paid_body": "Thanks for applying to {tier_name}. Nothing has been charged. If we approve your application, {amount} {cycle} will be charged to your card and your membership starts then.",
|
|
1127
|
+
"forge.membership_checkout.application_received_title": "Application received",
|
|
1128
|
+
"forge.membership_checkout.application_state_approved": "You're approved. We're finishing setting up your membership.",
|
|
1129
|
+
"forge.membership_checkout.application_state_complete": "You're approved, but we couldn't charge your card. Complete your membership below.",
|
|
1130
|
+
"forge.membership_checkout.application_state_declined": "Your application was not approved.",
|
|
1131
|
+
"forge.membership_checkout.application_state_pending": "Your application is pending.",
|
|
1132
|
+
"forge.membership_checkout.application_tier_full_body": "The last place was taken while your card was being saved, so your application was closed. Nothing was charged.",
|
|
1133
|
+
"forge.membership_checkout.application_tier_full_title": "{tier_name} is full",
|
|
1134
|
+
"forge.membership_checkout.apply": "Apply to join",
|
|
1087
1135
|
"forge.membership_checkout.back": "Back",
|
|
1088
1136
|
"forge.membership_checkout.billing_cycle_label": "Billing cycle",
|
|
1089
1137
|
"forge.membership_checkout.billing_monthly": "Monthly",
|
|
@@ -1093,10 +1141,18 @@
|
|
|
1093
1141
|
"forge.membership_checkout.billing_yearly_price": "Yearly ({amount})",
|
|
1094
1142
|
"forge.membership_checkout.billing_yearly_pwyw": "Pay what you want from {amount}/yr",
|
|
1095
1143
|
"forge.membership_checkout.choose_title": "Choose your membership",
|
|
1144
|
+
"forge.membership_checkout.complete_membership": "Complete your membership",
|
|
1096
1145
|
"forge.membership_checkout.confirm_change": "Confirm change",
|
|
1097
1146
|
"forge.membership_checkout.current_plan": "Current plan",
|
|
1098
1147
|
"forge.membership_checkout.empty_title": "No memberships available",
|
|
1148
|
+
"forge.membership_checkout.field_required": "This field is required",
|
|
1099
1149
|
"forge.membership_checkout.free": "Free",
|
|
1150
|
+
"forge.membership_checkout.go_to_account": "Go to your account",
|
|
1151
|
+
"forge.membership_checkout.invite_dead_body": "It may have expired, been used already, or been replaced by a newer invite. Ask us for a new one.",
|
|
1152
|
+
"forge.membership_checkout.invite_dead_title": "This invite is no longer valid",
|
|
1153
|
+
"forge.membership_checkout.invite_email_mismatch": "You're signed in as {signed_in_email}. Sign in with {email} to accept this invite.",
|
|
1154
|
+
"forge.membership_checkout.invite_for_email": "This invite is for {email}.",
|
|
1155
|
+
"forge.membership_checkout.invite_title": "You're invited to join {tier_name}",
|
|
1100
1156
|
"forge.membership_checkout.more_benefits": "+{count} more",
|
|
1101
1157
|
"forge.membership_checkout.no_tier_body": "Select a membership tier to continue.",
|
|
1102
1158
|
"forge.membership_checkout.on_this_plan": "You are on this plan",
|
|
@@ -1108,15 +1164,28 @@
|
|
|
1108
1164
|
"forge.membership_checkout.pwyw_charged_as": "You will be charged {amount}.",
|
|
1109
1165
|
"forge.membership_checkout.pwyw_label": "How much would you like to pay?",
|
|
1110
1166
|
"forge.membership_checkout.pwyw_min": "(minimum {amount})",
|
|
1167
|
+
"forge.membership_checkout.questions_title": "A few questions",
|
|
1168
|
+
"forge.membership_checkout.select_placeholder": "Select…",
|
|
1111
1169
|
"forge.membership_checkout.select_tier": "Select this tier",
|
|
1170
|
+
"forge.membership_checkout.start_free_trial": "Start free trial",
|
|
1112
1171
|
"forge.membership_checkout.submit": "Subscribe",
|
|
1113
1172
|
"forge.membership_checkout.submitting": "Processing…",
|
|
1114
1173
|
"forge.membership_checkout.title": "Join {tier_name}",
|
|
1115
1174
|
"forge.membership_checkout.total": "Total",
|
|
1175
|
+
"forge.membership_checkout.trial_card_title": "Start your free trial",
|
|
1176
|
+
"forge.membership_checkout.trial_line": "{days}-day free trial. Your first charge is on {date}.",
|
|
1116
1177
|
"forge.membership_checkout.view_memberships": "View memberships",
|
|
1117
1178
|
"forge.membership_tier_callout.benefits_label": "Benefits:",
|
|
1179
|
+
"forge.membership_tier_callout.invite_for_email": "This invite is for {email}. Sign in or create an account with that address.",
|
|
1118
1180
|
"forge.membership_tier_callout.join_title": "Join {tier_name}",
|
|
1119
1181
|
"forge.membership_tier_callout.more_benefits": "+{count} more benefits",
|
|
1182
|
+
"forge.membership_tiers.accept_invite": "Accept invite",
|
|
1183
|
+
"forge.membership_tiers.application_approved": "Approved, finishing up",
|
|
1184
|
+
"forge.membership_tiers.application_declined": "Not approved",
|
|
1185
|
+
"forge.membership_tiers.application_payment_failed": "Approved: complete your membership",
|
|
1186
|
+
"forge.membership_tiers.application_pending": "Application pending",
|
|
1187
|
+
"forge.membership_tiers.apply": "Apply to join",
|
|
1188
|
+
"forge.membership_tiers.complete_membership": "Complete your membership",
|
|
1120
1189
|
"forge.membership_tiers.empty_title": "No membership tiers available.",
|
|
1121
1190
|
"forge.membership_tiers.heading": "Membership",
|
|
1122
1191
|
"forge.membership_tiers.price_free": "Free",
|
|
@@ -1125,6 +1194,11 @@
|
|
|
1125
1194
|
"forge.membership_tiers.price_pwyw_yearly": "Pay what you want from {amount}/yr",
|
|
1126
1195
|
"forge.membership_tiers.price_yearly": "{amount}/yr",
|
|
1127
1196
|
"forge.membership_tiers.select": "Select",
|
|
1197
|
+
"forge.membership_tiers.start_free_trial": "Start free trial",
|
|
1198
|
+
"forge.membership_tiers.trial_badge": "{days}-day free trial",
|
|
1199
|
+
"forge.membership_tiers.withdraw": "Withdraw application",
|
|
1200
|
+
"forge.membership_tiers.withdraw_error": "We couldn't withdraw your application. Please try again.",
|
|
1201
|
+
"forge.membership_tiers.withdrawing": "Withdrawing…",
|
|
1128
1202
|
"forge.offer_button.buy_now": "Buy now",
|
|
1129
1203
|
"forge.offer_button.confirm_email": "Confirm email",
|
|
1130
1204
|
"forge.offer_button.continue_to_payment": "Continue to payment",
|
package/src/server/index.ts
CHANGED
|
@@ -46,6 +46,7 @@ import type {
|
|
|
46
46
|
CollectionQueryParams,
|
|
47
47
|
CollectionQuery,
|
|
48
48
|
CollectionSearchResult,
|
|
49
|
+
PaginatedData,
|
|
49
50
|
} from "../types/models";
|
|
50
51
|
import { collectionParamsToQuery, splitCollectionQuery } from "../data/collectionParams";
|
|
51
52
|
// SEO context for structured data. Pure functions over plain data, so this is
|
|
@@ -274,6 +275,18 @@ export async function fetchSiteBootstrap(opts: {
|
|
|
274
275
|
}
|
|
275
276
|
|
|
276
277
|
/** Fetch a single event by id or slug for SSR. */
|
|
278
|
+
/**
|
|
279
|
+
* Every published event, for SSR of the events list.
|
|
280
|
+
*
|
|
281
|
+
* The list page had no server fetch at all: it rendered a spinner into the
|
|
282
|
+
* document and filled itself in from the browser, so a crawler saw an empty
|
|
283
|
+
* page and a fan saw a flash of nothing. It is the same endpoint the client
|
|
284
|
+
* hook reads, so the markup and the first client render agree.
|
|
285
|
+
*/
|
|
286
|
+
export async function fetchEventsServer(opts: { apiUrl: string; profileId?: string }): Promise<IEvent[]> {
|
|
287
|
+
return (await getJson<IEvent[]>(opts.apiUrl, `/public/events`, { profileId: opts.profileId })) ?? [];
|
|
288
|
+
}
|
|
289
|
+
|
|
277
290
|
export function fetchEventServer(opts: {
|
|
278
291
|
apiUrl: string;
|
|
279
292
|
profileId?: string;
|
|
@@ -351,6 +364,67 @@ export function buildMusicLinkHead(
|
|
|
351
364
|
}
|
|
352
365
|
|
|
353
366
|
/** Fetch a single product by id or slug for SSR. */
|
|
367
|
+
/**
|
|
368
|
+
* The catalogue pages, server-rendered.
|
|
369
|
+
*
|
|
370
|
+
* Each of these list pages used to render a spinner into the document and
|
|
371
|
+
* fill itself in from the browser, so a crawler indexed an empty page and a
|
|
372
|
+
* fan saw nothing until the fetch landed. They are the pages search engines
|
|
373
|
+
* actually index, which makes an empty server render the expensive kind.
|
|
374
|
+
*
|
|
375
|
+
* Each mirrors its client hook exactly, same endpoint and same page size, so
|
|
376
|
+
* the markup and the first client render agree and nothing is fetched twice.
|
|
377
|
+
* An empty page is a real answer here (a creator who sells no courses), so a
|
|
378
|
+
* failed fetch resolves to an empty page rather than throwing.
|
|
379
|
+
*/
|
|
380
|
+
const emptyPage = <T,>(): PaginatedData<T> => ({
|
|
381
|
+
data: [],
|
|
382
|
+
total: 0,
|
|
383
|
+
hasNextPage: false,
|
|
384
|
+
page: 1,
|
|
385
|
+
nextPage: null,
|
|
386
|
+
pageSize: 10,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
export async function fetchProductsServer(opts: {
|
|
390
|
+
apiUrl: string;
|
|
391
|
+
profileId?: string;
|
|
392
|
+
}): Promise<PaginatedData<IPublicProduct>> {
|
|
393
|
+
return (
|
|
394
|
+
(await getJson<PaginatedData<IPublicProduct>>(opts.apiUrl, `/public/products`, {
|
|
395
|
+
profileId: opts.profileId,
|
|
396
|
+
page: "1",
|
|
397
|
+
limit: "10",
|
|
398
|
+
})) ?? emptyPage<IPublicProduct>()
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export async function fetchCoursesServer(opts: {
|
|
403
|
+
apiUrl: string;
|
|
404
|
+
profileId?: string;
|
|
405
|
+
}): Promise<PaginatedData<PublicCourse>> {
|
|
406
|
+
return (
|
|
407
|
+
(await getJson<PaginatedData<PublicCourse>>(opts.apiUrl, `/public/courses`, {
|
|
408
|
+
profileId: opts.profileId,
|
|
409
|
+
page: "1",
|
|
410
|
+
limit: "10",
|
|
411
|
+
})) ?? emptyPage<PublicCourse>()
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export async function fetchCoachingProductsServer(opts: {
|
|
416
|
+
apiUrl: string;
|
|
417
|
+
profileId?: string;
|
|
418
|
+
}): Promise<PaginatedData<CoachingProduct>> {
|
|
419
|
+
return (
|
|
420
|
+
(await getJson<PaginatedData<CoachingProduct>>(opts.apiUrl, `/public/coaching/products`, {
|
|
421
|
+
profileId: opts.profileId,
|
|
422
|
+
page: "1",
|
|
423
|
+
limit: "10",
|
|
424
|
+
})) ?? emptyPage<CoachingProduct>()
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
354
428
|
export function fetchProductServer(opts: {
|
|
355
429
|
apiUrl: string;
|
|
356
430
|
profileId?: string;
|
|
@@ -308,6 +308,63 @@ export type PlatformEventMap = {
|
|
|
308
308
|
unitPrice: number | null;
|
|
309
309
|
}>;
|
|
310
310
|
};
|
|
311
|
+
/**
|
|
312
|
+
* Membership application approved: An application was approved. `status` is `joined`, or `payment_failed` when the saved card was declined.
|
|
313
|
+
*
|
|
314
|
+
* Requires the `membership.application_approved` grant.
|
|
315
|
+
*/
|
|
316
|
+
"membership_application.approved": {
|
|
317
|
+
applicationId: string;
|
|
318
|
+
membershipTierId: string | null;
|
|
319
|
+
status: string | null;
|
|
320
|
+
billingCycle: string | null;
|
|
321
|
+
amount: number | null;
|
|
322
|
+
currency: string | null;
|
|
323
|
+
membershipId: string | null;
|
|
324
|
+
applicant: {
|
|
325
|
+
contactId: string | null;
|
|
326
|
+
email: string | null;
|
|
327
|
+
name: string | null;
|
|
328
|
+
};
|
|
329
|
+
};
|
|
330
|
+
/**
|
|
331
|
+
* Membership application declined: An application to join a membership tier was declined.
|
|
332
|
+
*
|
|
333
|
+
* Requires the `membership.application_declined` grant.
|
|
334
|
+
*/
|
|
335
|
+
"membership_application.declined": {
|
|
336
|
+
applicationId: string;
|
|
337
|
+
membershipTierId: string | null;
|
|
338
|
+
status: string | null;
|
|
339
|
+
billingCycle: string | null;
|
|
340
|
+
amount: number | null;
|
|
341
|
+
currency: string | null;
|
|
342
|
+
membershipId: string | null;
|
|
343
|
+
applicant: {
|
|
344
|
+
contactId: string | null;
|
|
345
|
+
email: string | null;
|
|
346
|
+
name: string | null;
|
|
347
|
+
};
|
|
348
|
+
};
|
|
349
|
+
/**
|
|
350
|
+
* Membership application submitted: Someone applied to join a membership tier that needs approval. Nothing is charged until approved.
|
|
351
|
+
*
|
|
352
|
+
* Requires the `membership.application_submitted` grant.
|
|
353
|
+
*/
|
|
354
|
+
"membership_application.submitted": {
|
|
355
|
+
applicationId: string;
|
|
356
|
+
membershipTierId: string | null;
|
|
357
|
+
status: string | null;
|
|
358
|
+
billingCycle: string | null;
|
|
359
|
+
amount: number | null;
|
|
360
|
+
currency: string | null;
|
|
361
|
+
membershipId: string | null;
|
|
362
|
+
applicant: {
|
|
363
|
+
contactId: string | null;
|
|
364
|
+
email: string | null;
|
|
365
|
+
name: string | null;
|
|
366
|
+
};
|
|
367
|
+
};
|
|
311
368
|
/**
|
|
312
369
|
* Membership tier archived: A membership tier was archived and can no longer be joined.
|
|
313
370
|
*
|
|
@@ -495,6 +552,9 @@ export const PLATFORM_EVENT_PERMISSIONS: Record<PlatformEventName, string> = {
|
|
|
495
552
|
"event_ticket_request.declined": "events.read",
|
|
496
553
|
"event_ticket_request.expired": "events.read",
|
|
497
554
|
"event_ticket_request.submitted": "events.read",
|
|
555
|
+
"membership_application.approved": "membership.application_approved",
|
|
556
|
+
"membership_application.declined": "membership.application_declined",
|
|
557
|
+
"membership_application.submitted": "membership.application_submitted",
|
|
498
558
|
"membership_tier.archived": "membership.tier_archived",
|
|
499
559
|
"membership_tier.created": "membership.tier_created",
|
|
500
560
|
"message.received": "contacts.read",
|
package/src/types/models.ts
CHANGED
|
@@ -307,6 +307,116 @@ export type MembershipTier = {
|
|
|
307
307
|
welcomeMessageContent?: string | null;
|
|
308
308
|
cancellationMessageSubject?: string | null;
|
|
309
309
|
cancellationMessageContent?: string | null;
|
|
310
|
+
/**
|
|
311
|
+
* How a fan gets onto this tier (curated access). `open` is the ordinary
|
|
312
|
+
* checkout; `invite` needs a live invite for the signed-in email (and the
|
|
313
|
+
* tier is only listed when the request carried that invite's token);
|
|
314
|
+
* `application` means the fan applies and the business approves. Optional,
|
|
315
|
+
* because an older API build sends nothing, which reads as `open`.
|
|
316
|
+
*/
|
|
317
|
+
joinMode?: MembershipJoinMode;
|
|
318
|
+
/**
|
|
319
|
+
* The signed-in fan's most recent open application on this tier (or their
|
|
320
|
+
* latest declined one), so the tier card can show where it stands instead of
|
|
321
|
+
* a join button. Absent when signed out or on an older API build.
|
|
322
|
+
*/
|
|
323
|
+
myApplication?: MembershipTierMyApplication | null;
|
|
324
|
+
/**
|
|
325
|
+
* Free trial length in days on a paid tier (1 to 90), or null for no trial.
|
|
326
|
+
* A card is still collected; the first charge is on today + trialDays.
|
|
327
|
+
*/
|
|
328
|
+
trialDays?: number | null;
|
|
329
|
+
/**
|
|
330
|
+
* False once the signed-in fan has had a trial with this business (one per
|
|
331
|
+
* fan per business, any tier). Absent when signed out: treat as eligible.
|
|
332
|
+
*/
|
|
333
|
+
trialEligible?: boolean;
|
|
334
|
+
/**
|
|
335
|
+
* Questions the fan answers before paying or joining, on every join mode.
|
|
336
|
+
* Null or absent when the tier asks nothing.
|
|
337
|
+
*/
|
|
338
|
+
questionnaire?: QuestionnaireQuestion[] | null;
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
/** How a fan gets onto a membership tier. */
|
|
342
|
+
export type MembershipJoinMode = "open" | "invite" | "application";
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Where an application to a membership tier stands.
|
|
346
|
+
*
|
|
347
|
+
* card_pending paid tier: card step started, not confirmed. Not yet visible to the business.
|
|
348
|
+
* pending lodged and waiting for a decision; holds a seat
|
|
349
|
+
* approved approved; the subscription is being created
|
|
350
|
+
* payment_failed approved, but the saved card was declined; the fan can finish at checkout
|
|
351
|
+
* joined the membership exists
|
|
352
|
+
* declined not approved
|
|
353
|
+
* withdrawn withdrawn by the fan, or closed because the tier filled (`closedReason: "tier_full"`)
|
|
354
|
+
* expired the window to finish a failed payment lapsed
|
|
355
|
+
*/
|
|
356
|
+
export type MembershipApplicationStatus =
|
|
357
|
+
| "card_pending"
|
|
358
|
+
| "pending"
|
|
359
|
+
| "approved"
|
|
360
|
+
| "payment_failed"
|
|
361
|
+
| "joined"
|
|
362
|
+
| "declined"
|
|
363
|
+
| "withdrawn"
|
|
364
|
+
| "expired";
|
|
365
|
+
|
|
366
|
+
/** The slice of an application the public tier list carries per tier. */
|
|
367
|
+
export type MembershipTierMyApplication = {
|
|
368
|
+
id: string;
|
|
369
|
+
status: MembershipApplicationStatus;
|
|
370
|
+
paymentWindowExpiresAt: string | null;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
export type MembershipApplication = {
|
|
374
|
+
id: string;
|
|
375
|
+
profileId: string;
|
|
376
|
+
membershipTierId: string;
|
|
377
|
+
/** Present on list payloads. */
|
|
378
|
+
membershipTierName?: string;
|
|
379
|
+
accountId: string;
|
|
380
|
+
status: MembershipApplicationStatus;
|
|
381
|
+
/** Null on a free tier. */
|
|
382
|
+
billingCycle: "month" | "year" | null;
|
|
383
|
+
/** What will be charged per cycle, in major units. Null on a free tier. */
|
|
384
|
+
amount: number | null;
|
|
385
|
+
currency: string | null;
|
|
386
|
+
/** A note from an older checkout. Current checkouts ask the tier's questions instead. */
|
|
387
|
+
message: string | null;
|
|
388
|
+
/** The fan's answers to the tier's questions, as given when applying. */
|
|
389
|
+
questionnaire?: QuestionnaireAnswer[] | null;
|
|
390
|
+
/** e.g. "Visa •••• 4242", set once the card is confirmed. */
|
|
391
|
+
instrumentLabel: string | null;
|
|
392
|
+
decidedAt: string | null;
|
|
393
|
+
decisionNote: string | null;
|
|
394
|
+
/** `"tier_full"`, `"expired"`, or the provider's decline reason on `payment_failed`. */
|
|
395
|
+
closedReason: string | null;
|
|
396
|
+
/** The deadline to finish a `payment_failed` application. */
|
|
397
|
+
paymentWindowExpiresAt: string | null;
|
|
398
|
+
membershipId: string | null;
|
|
399
|
+
createdAt: string;
|
|
400
|
+
updatedAt: string;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
/** What `POST /public/membership-applications` answers. */
|
|
404
|
+
export type ApplyForMembershipResult = {
|
|
405
|
+
application: MembershipApplication;
|
|
406
|
+
/** True on a paid tier until the card is confirmed. */
|
|
407
|
+
requiresCard: boolean;
|
|
408
|
+
/** A Stripe SetupIntent secret (`seti_..._secret_...`) when `requiresCard`. */
|
|
409
|
+
clientSecret: string | null;
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
/** What `GET /public/membership-invites/info` answers for a live invite. */
|
|
413
|
+
export type MembershipInviteInfo = {
|
|
414
|
+
membershipTierId: string;
|
|
415
|
+
membershipTierName: string;
|
|
416
|
+
profileId: string;
|
|
417
|
+
/** The address the invite was sent to; the fan must be signed in with it. */
|
|
418
|
+
email: string;
|
|
419
|
+
expiresAt: string;
|
|
310
420
|
};
|
|
311
421
|
|
|
312
422
|
/**
|
|
@@ -346,6 +456,10 @@ export interface Membership {
|
|
|
346
456
|
subscriptionAmount: number;
|
|
347
457
|
subscriptionCurrency: string;
|
|
348
458
|
billingCycle: string;
|
|
459
|
+
/** Set when the membership started with a free trial: when the first charge is due. */
|
|
460
|
+
trialEndsAt?: string | null;
|
|
461
|
+
/** The fan's answers to the tier's questions, as given when joining. */
|
|
462
|
+
questionnaire?: QuestionnaireAnswer[] | null;
|
|
349
463
|
}
|
|
350
464
|
|
|
351
465
|
// ---- Public auth -------------------------------------------------------------
|
|
@@ -911,6 +1025,19 @@ export type QuestionnaireQuestion = {
|
|
|
911
1025
|
optional?: boolean;
|
|
912
1026
|
};
|
|
913
1027
|
|
|
1028
|
+
/**
|
|
1029
|
+
* A fan's answer to one {@link QuestionnaireQuestion}, as sent at checkout and
|
|
1030
|
+
* as stored on what the checkout created. `answer` is `""` for an optional
|
|
1031
|
+
* question left blank.
|
|
1032
|
+
*/
|
|
1033
|
+
export type QuestionnaireAnswer = {
|
|
1034
|
+
id: string;
|
|
1035
|
+
question: string;
|
|
1036
|
+
type: string;
|
|
1037
|
+
options?: string[];
|
|
1038
|
+
answer: string;
|
|
1039
|
+
};
|
|
1040
|
+
|
|
914
1041
|
/**
|
|
915
1042
|
* S.6 — the cancellation terms a buyer is entitled to read BEFORE they pay.
|
|
916
1043
|
*
|
package/src/ui/headless/index.ts
CHANGED
|
@@ -100,6 +100,29 @@ export {
|
|
|
100
100
|
type MembershipCheckoutStep,
|
|
101
101
|
type BillingCycle,
|
|
102
102
|
} from "./membership/useMembershipCheckout";
|
|
103
|
+
// Curated access (invite-only and apply-to-join tiers): the pure decisions the
|
|
104
|
+
// tier card, the checkout and the account page share.
|
|
105
|
+
export {
|
|
106
|
+
canWithdrawMembershipApplication,
|
|
107
|
+
MEMBERSHIP_APPLICATION_MESSAGE_MAX,
|
|
108
|
+
membershipApplicationOutcome,
|
|
109
|
+
tierCardAction,
|
|
110
|
+
tierUsesApplication,
|
|
111
|
+
type MembershipApplicationOutcome,
|
|
112
|
+
type MembershipTierCardAction,
|
|
113
|
+
} from "./membership/membershipApplication";
|
|
114
|
+
export {
|
|
115
|
+
formatTrialDate,
|
|
116
|
+
isMembershipInTrial,
|
|
117
|
+
tierTrialDays,
|
|
118
|
+
trialEndDate,
|
|
119
|
+
} from "./membership/membershipTrial";
|
|
120
|
+
// Tier questionnaires: which questions a checkout asks and what it sends.
|
|
121
|
+
export {
|
|
122
|
+
buildQuestionnaireAnswers,
|
|
123
|
+
firstMissingAnswer,
|
|
124
|
+
tierQuestionnaire,
|
|
125
|
+
} from "./membership/membershipQuestionnaire";
|
|
103
126
|
export { useInvoicePayment, type UseInvoicePaymentOptions } from "./invoice/useInvoicePayment";
|
|
104
127
|
export { usePaymentLinkPayment, type UsePaymentLinkPaymentOptions } from "./paymentLink/usePaymentLinkPayment";
|
|
105
128
|
export { useBroadcastWatch } from "./broadcast/useBroadcastWatch";
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
canWithdrawMembershipApplication,
|
|
4
|
+
membershipApplicationOutcome,
|
|
5
|
+
tierCardAction,
|
|
6
|
+
tierUsesApplication,
|
|
7
|
+
} from "../membershipApplication";
|
|
8
|
+
import type { MembershipApplicationStatus, MembershipTier } from "../../../../types/models";
|
|
9
|
+
|
|
10
|
+
const app = (status: MembershipApplicationStatus) => ({ id: "app-1", status, paymentWindowExpiresAt: null });
|
|
11
|
+
|
|
12
|
+
describe("tierCardAction", () => {
|
|
13
|
+
it("offers the tier in its own words when the fan has no application", () => {
|
|
14
|
+
expect(tierCardAction({})).toBe("join");
|
|
15
|
+
expect(tierCardAction({ joinMode: "open" })).toBe("join");
|
|
16
|
+
expect(tierCardAction({ joinMode: "invite" })).toBe("accept_invite");
|
|
17
|
+
expect(tierCardAction({ joinMode: "application" })).toBe("apply");
|
|
18
|
+
expect(tierCardAction({ joinMode: "application", myApplication: null })).toBe("apply");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("shows where the fan's application stands instead of the button", () => {
|
|
22
|
+
expect(tierCardAction({ joinMode: "application", myApplication: app("pending") })).toBe("pending");
|
|
23
|
+
expect(tierCardAction({ joinMode: "application", myApplication: app("approved") })).toBe("approved");
|
|
24
|
+
expect(tierCardAction({ joinMode: "application", myApplication: app("payment_failed") })).toBe("complete");
|
|
25
|
+
expect(tierCardAction({ joinMode: "application", myApplication: app("declined") })).toBe("declined");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("lets a fan pick an unfinished card step back up", () => {
|
|
29
|
+
expect(tierCardAction({ joinMode: "application", myApplication: app("card_pending") })).toBe("apply");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("tierUsesApplication", () => {
|
|
34
|
+
it("is true on an application tier", () => {
|
|
35
|
+
expect(tierUsesApplication({ joinMode: "application" })).toBe(true);
|
|
36
|
+
expect(tierUsesApplication({ joinMode: "application", myApplication: app("pending") })).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("EXCEPTION: a payment_failed application completes through the ordinary paid checkout", () => {
|
|
40
|
+
expect(tierUsesApplication({ joinMode: "application", myApplication: app("payment_failed") })).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("is false on open and invite tiers, and with no tier", () => {
|
|
44
|
+
expect(tierUsesApplication({ joinMode: "open" } as Pick<MembershipTier, "joinMode">)).toBe(false);
|
|
45
|
+
expect(tierUsesApplication({ joinMode: "invite" })).toBe(false);
|
|
46
|
+
expect(tierUsesApplication({})).toBe(false);
|
|
47
|
+
expect(tierUsesApplication(undefined)).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("canWithdrawMembershipApplication", () => {
|
|
52
|
+
it("allows withdrawing only before a decision", () => {
|
|
53
|
+
expect(canWithdrawMembershipApplication("card_pending")).toBe(true);
|
|
54
|
+
expect(canWithdrawMembershipApplication("pending")).toBe(true);
|
|
55
|
+
for (const status of ["approved", "payment_failed", "joined", "declined", "withdrawn", "expired"] as const) {
|
|
56
|
+
expect(canWithdrawMembershipApplication(status)).toBe(false);
|
|
57
|
+
}
|
|
58
|
+
expect(canWithdrawMembershipApplication(undefined)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("membershipApplicationOutcome", () => {
|
|
63
|
+
const outcome = (status: MembershipApplicationStatus, closedReason: string | null = null, confirmed = false) =>
|
|
64
|
+
membershipApplicationOutcome({ status, closedReason }, confirmed);
|
|
65
|
+
|
|
66
|
+
it("is null before anything was submitted, and while the card step is still ahead", () => {
|
|
67
|
+
expect(membershipApplicationOutcome(null)).toBeNull();
|
|
68
|
+
expect(outcome("card_pending")).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("reads a confirmed card the server has not caught up with as received", () => {
|
|
72
|
+
expect(outcome("card_pending", null, true)).toBe("received");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("maps every other status", () => {
|
|
76
|
+
expect(outcome("pending")).toBe("received");
|
|
77
|
+
expect(outcome("approved")).toBe("approved");
|
|
78
|
+
expect(outcome("joined")).toBe("approved");
|
|
79
|
+
expect(outcome("payment_failed")).toBe("approved");
|
|
80
|
+
expect(outcome("declined")).toBe("declined");
|
|
81
|
+
expect(outcome("withdrawn", "tier_full")).toBe("tier_full");
|
|
82
|
+
expect(outcome("withdrawn")).toBe("closed");
|
|
83
|
+
expect(outcome("expired", "expired")).toBe("closed");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -39,6 +39,8 @@ vi.mock("../../../../contexts/PublicAuthContext", async (importOriginal) => ({
|
|
|
39
39
|
vi.mock("../../../../data/queries/useMembership", async (importOriginal) => ({
|
|
40
40
|
...(await importOriginal<typeof import("../../../../data/queries/useMembership")>()),
|
|
41
41
|
useGetMembershipTiers: () => ({ data: tiers, isLoading: false }),
|
|
42
|
+
useApplyForMembership: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
|
43
|
+
useConfirmMembershipApplication: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
|
42
44
|
}));
|
|
43
45
|
|
|
44
46
|
vi.mock("../../../../data/queries/useSubscriptions", async (importOriginal) => ({
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { formatTrialDate, isMembershipInTrial, tierTrialDays, trialEndDate } from "../membershipTrial";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Free trials on paid membership tiers: who gets one, when it ends, and whether
|
|
6
|
+
* a membership is still inside it. Every surface reads these, so they are pinned
|
|
7
|
+
* as pure functions.
|
|
8
|
+
*/
|
|
9
|
+
describe("tierTrialDays", () => {
|
|
10
|
+
it("gives the tier's trial to an eligible fan, and to a signed-out visitor (no trialEligible)", () => {
|
|
11
|
+
expect(tierTrialDays({ trialDays: 14, trialEligible: true })).toBe(14);
|
|
12
|
+
expect(tierTrialDays({ trialDays: 7 })).toBe(7);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("gives none to a fan who already had a trial with this business", () => {
|
|
16
|
+
expect(tierTrialDays({ trialDays: 14, trialEligible: false })).toBe(0);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("gives none when the tier has no trial", () => {
|
|
20
|
+
expect(tierTrialDays({ trialDays: null })).toBe(0);
|
|
21
|
+
expect(tierTrialDays({ trialDays: 0 })).toBe(0);
|
|
22
|
+
expect(tierTrialDays({ trialDays: -3 })).toBe(0);
|
|
23
|
+
expect(tierTrialDays({})).toBe(0);
|
|
24
|
+
expect(tierTrialDays(undefined)).toBe(0);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("gives none to a member with a live paid subscription (that is a tier change)", () => {
|
|
28
|
+
expect(
|
|
29
|
+
tierTrialDays({ trialDays: 14 }, { paymentProviderSubscriptionId: "sub_1", status: "active" }),
|
|
30
|
+
).toBe(0);
|
|
31
|
+
// A lapsed or free membership does not block the trial.
|
|
32
|
+
expect(tierTrialDays({ trialDays: 14 }, { paymentProviderSubscriptionId: "sub_1", status: "cancelled" })).toBe(14);
|
|
33
|
+
expect(tierTrialDays({ trialDays: 14 }, { status: "active" })).toBe(14);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("trialEndDate", () => {
|
|
38
|
+
it("is today plus the trial days", () => {
|
|
39
|
+
const now = new Date("2026-09-17T10:00:00.000Z");
|
|
40
|
+
expect(trialEndDate(14, now).toISOString()).toBe("2026-10-01T10:00:00.000Z");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("isMembershipInTrial", () => {
|
|
45
|
+
const now = new Date("2026-09-17T10:00:00.000Z");
|
|
46
|
+
it("is true only while trialEndsAt is in the future", () => {
|
|
47
|
+
expect(isMembershipInTrial({ trialEndsAt: "2026-09-30T00:00:00.000Z" }, now)).toBe(true);
|
|
48
|
+
expect(isMembershipInTrial({ trialEndsAt: "2026-09-01T00:00:00.000Z" }, now)).toBe(false);
|
|
49
|
+
expect(isMembershipInTrial({ trialEndsAt: null }, now)).toBe(false);
|
|
50
|
+
expect(isMembershipInTrial({}, now)).toBe(false);
|
|
51
|
+
expect(isMembershipInTrial(undefined, now)).toBe(false);
|
|
52
|
+
expect(isMembershipInTrial({ trialEndsAt: "not a date" }, now)).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("formatTrialDate", () => {
|
|
57
|
+
it("formats per locale", () => {
|
|
58
|
+
expect(formatTrialDate("2026-10-01T12:00:00.000Z", "en")).toBe("Oct 1, 2026");
|
|
59
|
+
expect(formatTrialDate("2026-10-01T12:00:00.000Z", "de")).toContain("2026");
|
|
60
|
+
});
|
|
61
|
+
});
|