@tribe-nest/forge 3.58.0 → 3.60.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/_tests/testCommunity.tsx +28 -0
- package/src/contexts/CommunityContext.tsx +192 -0
- package/src/data/queries/useChat.ts +42 -39
- package/src/data/queries/useCommunities.ts +343 -0
- package/src/data/queries/useCommunity.ts +93 -89
- package/src/data/queries/useMembership.ts +32 -7
- package/src/data/queries/useMembershipCalls.ts +116 -7
- package/src/data/queries/usePostCollections.ts +28 -16
- package/src/data/queries/usePosts.ts +16 -14
- package/src/data/queries/useSubscriptions.ts +22 -8
- package/src/i18n/de.json +145 -1
- package/src/i18n/en.json +145 -1
- package/src/index.ts +19 -7
- package/src/runtime/pages.spec.ts +31 -0
- package/src/runtime/pages.ts +21 -22
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
- package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
- package/src/ui/format/membershipGate.ts +19 -15
- package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
- package/src/ui/headless/_tests/communityHarness.tsx +205 -0
- package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
- package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
- package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +382 -0
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +151 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +286 -0
- package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
- package/src/ui/headless/auth/useSignupForm.ts +10 -2
- package/src/ui/headless/communities/CommunityGate.tsx +67 -0
- package/src/ui/headless/communities/index.ts +55 -0
- package/src/ui/headless/communities/paths.ts +36 -0
- package/src/ui/headless/communities/useCommunity.ts +140 -0
- package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
- package/src/ui/headless/communities/useCommunityHome.ts +88 -0
- package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +444 -0
- package/src/ui/headless/communities/useCommunityMembership.ts +136 -0
- package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
- package/src/ui/headless/communities/useMyMemberships.ts +59 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
- package/src/ui/headless/index.ts +8 -4
- package/src/ui/headless/memberHome/index.ts +1 -1
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +44 -42
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
- package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
- package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
- package/src/ui/headless/posts/upsell.ts +54 -8
- package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
- package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
- package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
- package/src/ui/headless/posts/usePostItem.ts +6 -5
- package/src/ui/index.ts +17 -12
- package/src/ui/styled/AccountDashboard.tsx +48 -256
- package/src/ui/styled/MembershipCheckout.tsx +37 -17
- package/src/ui/styled/MembershipTierCallout.tsx +20 -1
- package/src/ui/styled/MembershipTiers.tsx +49 -11
- package/src/ui/styled/Paywall.tsx +1 -1
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +1 -1
- package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
- package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
- package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
- package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
- package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
- package/src/ui/styled/communities/CommunityAbout.tsx +140 -0
- package/src/ui/styled/communities/CommunityHome.tsx +175 -0
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +444 -0
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +133 -0
- package/src/ui/styled/communities/CommunityPending.tsx +49 -0
- package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +89 -0
- package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
- package/src/ui/styled/communities/index.ts +10 -0
- package/src/ui/styled/communities/shared.tsx +149 -0
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
- package/src/ui/styled/forge-utilities.css +19 -0
- package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +142 -10
- package/src/ui/styled/members/MemberHomeLayout.tsx +35 -17
- package/src/ui/styled/members/MemberPostCard.tsx +9 -5
- package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
- package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
- package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
- package/src/ui/styled/members/callTranscriptText.ts +67 -0
- package/src/utils/_tests/safeRedirect.spec.ts +2 -2
- package/src/ui/headless/membership/MembershipGate.tsx +0 -44
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
package/src/ui/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Forge UI
|
|
1
|
+
// Forge UI: Tier-2 styled blocks (theme-aware, opinionated) + the theme
|
|
2
2
|
// contract. Built on the Tier-1 headless primitives in `forge/ui/headless`.
|
|
3
3
|
|
|
4
4
|
// Compiled Tailwind utilities for every className in Forge's source, generated
|
|
@@ -50,7 +50,7 @@ export * from "./headless";
|
|
|
50
50
|
// Currency formatting primitive (presentational helper).
|
|
51
51
|
export { useFormatCurrency, useAmountFormatter, formatCurrency, getCurrencySymbol } from "./format/useFormatCurrency";
|
|
52
52
|
|
|
53
|
-
// Content layer (Component 7)
|
|
53
|
+
// Content layer (Component 7): editable field wrappers.
|
|
54
54
|
export {
|
|
55
55
|
EditableText,
|
|
56
56
|
EditableRichText,
|
|
@@ -96,7 +96,7 @@ export { Cart, type CartProps } from "./styled/Cart";
|
|
|
96
96
|
export { ResumeCart, type ResumeCartProps } from "./styled/ResumeCart";
|
|
97
97
|
// The resolver is exported alongside the component because the Craft themes
|
|
98
98
|
// are a separate rendering stack (Tailwind, not inline styles) and cannot use
|
|
99
|
-
// the component
|
|
99
|
+
// the component: but the legacy `color`/`size` fallback must not diverge.
|
|
100
100
|
export { CartLineOptions, resolveCartLineOptions } from "./styled/CartLineOptions";
|
|
101
101
|
export { CurrencySwitcher, type CurrencySwitcherProps } from "./styled/CurrencySwitcher";
|
|
102
102
|
export { UserMenu, type UserMenuProps } from "./styled/UserMenu";
|
|
@@ -128,14 +128,14 @@ export { tierColorMap, TIER_PALETTE } from "./styled/seating/paint";
|
|
|
128
128
|
export { EventCountdown, type EventCountdownProps } from "./styled/EventCountdown";
|
|
129
129
|
export { EventWaitlist, type EventWaitlistProps, formatCountdown } from "./styled/EventWaitlist";
|
|
130
130
|
export { EventDetail, type EventDetailProps } from "./styled/EventDetail";
|
|
131
|
-
// Phase 5
|
|
131
|
+
// Phase 5: the page for a NAMED, multi-date series. The API refuses anything
|
|
132
132
|
// else, so this component never has to decide whether a series is publishable.
|
|
133
133
|
export { EventSeriesDetail, type EventSeriesDetailProps } from "./styled/EventSeriesDetail";
|
|
134
134
|
export { AddToCalendar, type AddToCalendarProps } from "./styled/AddToCalendar";
|
|
135
135
|
export { WalletPassButtons, type WalletPassButtonsProps } from "./styled/WalletPassButtons";
|
|
136
136
|
export { CoachingBooking, type CoachingBookingProps } from "./styled/CoachingBooking";
|
|
137
137
|
export { CoachingDetail, type CoachingDetailProps } from "./styled/CoachingDetail";
|
|
138
|
-
// S.6
|
|
138
|
+
// S.6: the buyer-facing cancellation policy block. Exported so a code site that
|
|
139
139
|
// lays out its own booking page still has ONE renderer for these terms.
|
|
140
140
|
export { CancellationTerms, type CancellationTermsProps } from "./styled/CancellationTerms";
|
|
141
141
|
export { CourseCheckout, type CourseCheckoutProps } from "./styled/CourseCheckout";
|
|
@@ -255,7 +255,7 @@ export { PriceDisplay, priceTaxCaption, summarizeTaxQuote, type PriceDisplayProp
|
|
|
255
255
|
export { computeBookingFeeAmount, hasBookingFee, describeBookingFee } from "./format/bookingFee";
|
|
256
256
|
// Pay-what-you-want, for a site rendering its own ticket UI. `price` is the
|
|
257
257
|
// FLOOR on a PWYW tier, so an existing "from $X" is already correct; these only
|
|
258
|
-
// resolve what a buyer's chosen amount costs. The clamp here is UX
|
|
258
|
+
// resolve what a buyer's chosen amount costs. The clamp here is UX: the server
|
|
259
259
|
// clamps again and is the one that decides what is charged.
|
|
260
260
|
export {
|
|
261
261
|
isPayWhatYouWant,
|
|
@@ -288,7 +288,7 @@ export {
|
|
|
288
288
|
// subtracts `held` the way the server does, so a fan is never shown seats that
|
|
289
289
|
// are already in someone else's checkout; `ticketSaleWindow` surfaces a
|
|
290
290
|
// scheduled on-sale, which shipped enforced and drawn nowhere. Presentation
|
|
291
|
-
// only
|
|
291
|
+
// only: `createOrder` re-checks both.
|
|
292
292
|
export {
|
|
293
293
|
ticketSeatsAvailable,
|
|
294
294
|
ticketSeatsUnsold,
|
|
@@ -305,7 +305,7 @@ export {
|
|
|
305
305
|
type TicketSaleWindow,
|
|
306
306
|
} from "./format/ticketAvailability";
|
|
307
307
|
// Per-attendee names at checkout. Exported for a site rendering its own ticket
|
|
308
|
-
// UI
|
|
308
|
+
// UI: and used by `apps/client`, which has its own event checkout: the request
|
|
309
309
|
// shape is positional per tier and a length mismatch is refused server-side, so
|
|
310
310
|
// neither stack derives it for itself.
|
|
311
311
|
export {
|
|
@@ -323,7 +323,7 @@ export {
|
|
|
323
323
|
type AttendeeProblem,
|
|
324
324
|
} from "./format/attendees";
|
|
325
325
|
// Members-only gates (S.4). The wording and the resolving ACTION are decided
|
|
326
|
-
// purely, in one place, because BOTH rendering stacks draw this
|
|
326
|
+
// purely, in one place, because BOTH rendering stacks draw this: `apps/client`
|
|
327
327
|
// imports these directly, exactly as it does the PWYW arithmetic. A members-only
|
|
328
328
|
// badge that says one thing on an artist's PWA and another on their website is
|
|
329
329
|
// the same bug twice.
|
|
@@ -345,10 +345,10 @@ export {
|
|
|
345
345
|
export { MembershipGateNotice, type MembershipGateNoticeProps } from "./styled/MembershipGateNotice";
|
|
346
346
|
export { usePricesIncludeTax } from "../data/queries/useWebsite";
|
|
347
347
|
|
|
348
|
-
// PWA (installable code sites)
|
|
348
|
+
// PWA (installable code sites): SW registration + install/push UX (Tier 2).
|
|
349
349
|
export { PwaRegistration, type PwaRegistrationProps } from "./styled/PwaRegistration";
|
|
350
350
|
export { InstallBanner, type InstallBannerProps } from "./styled/InstallBanner";
|
|
351
|
-
// The single root component
|
|
351
|
+
// The single root component: providers + audio + analytics + PWA + consent in one.
|
|
352
352
|
export { TribeNestApp, type TribeNestAppProps } from "./shell/TribeNestApp";
|
|
353
353
|
// The SSR-bootstrap failure banner + its gating. Exported so a bespoke shell that
|
|
354
354
|
// doesn't use <TribeNestApp> can still fail loudly instead of silently rendering
|
|
@@ -366,10 +366,15 @@ export { PushOptIn, type PushOptInProps } from "./styled/PushOptIn";
|
|
|
366
366
|
// Community (forum) styled blocks.
|
|
367
367
|
export * from "./styled/community";
|
|
368
368
|
|
|
369
|
+
// Communities: the list, About page with the in-place join sheet, the pending
|
|
370
|
+
// screen, the community home shell, the upgrade sheet, the membership menu,
|
|
371
|
+
// and the account page's list of memberships.
|
|
372
|
+
export * from "./styled/communities";
|
|
373
|
+
|
|
369
374
|
// Work (project management) client-portal styled blocks.
|
|
370
375
|
export * from "./styled/work";
|
|
371
376
|
|
|
372
|
-
//
|
|
377
|
+
// A community's member home (`/i/communities/<slug>/home`) styled blocks.
|
|
373
378
|
export * from "./styled/members";
|
|
374
379
|
|
|
375
380
|
// The review page a "how did we do?" email lands on. Here rather than in a
|
|
@@ -23,14 +23,12 @@ import {
|
|
|
23
23
|
useMyCourses,
|
|
24
24
|
useMyFilmRentals,
|
|
25
25
|
type MyCourse,
|
|
26
|
-
useCancelMembership,
|
|
27
|
-
useMembershipAccess,
|
|
28
26
|
useMyMembershipApplications,
|
|
29
27
|
useWithdrawMembershipApplication,
|
|
30
28
|
useConfirmMembershipApplication,
|
|
31
29
|
type MembershipApplication,
|
|
32
30
|
type MembershipApplicationStatus,
|
|
33
|
-
|
|
31
|
+
usePublicCommunities,
|
|
34
32
|
useUpdateAccount,
|
|
35
33
|
useChangePassword,
|
|
36
34
|
useExportAccountData,
|
|
@@ -53,7 +51,8 @@ import { TicketTransferPanel } from "./TicketTransfer";
|
|
|
53
51
|
import { CartLineOptions } from "./CartLineOptions";
|
|
54
52
|
import { FilmRentalList } from "./FilmLibrary";
|
|
55
53
|
import { canWithdrawMembershipApplication } from "../headless/membership/membershipApplication";
|
|
56
|
-
import {
|
|
54
|
+
import { communityAboutPath, communitiesListPath } from "../headless/communities/paths";
|
|
55
|
+
import { MyMembershipsList } from "./communities/MyMembershipsList";
|
|
57
56
|
|
|
58
57
|
export const ACCOUNT_TABS = [
|
|
59
58
|
"membership",
|
|
@@ -63,7 +62,7 @@ export const ACCOUNT_TABS = [
|
|
|
63
62
|
// holding for this artist's shows? (2.6)
|
|
64
63
|
"waitlist",
|
|
65
64
|
// Sessions bought from this artist (S.6 / bookings § 1.4). A booking is not an
|
|
66
|
-
// "order"
|
|
65
|
+
// "order" (the Orders tab covers product orders only) so it needs its own
|
|
67
66
|
// tab or it is unreachable, which is what it was.
|
|
68
67
|
"bookings",
|
|
69
68
|
// Films and series this fan has rented. Next to the other purchase surfaces
|
|
@@ -109,21 +108,10 @@ export interface AccountDashboardProps {
|
|
|
109
108
|
/** Called when the visitor selects a different tab. Host maps this to navigation. */
|
|
110
109
|
onTabChange?: (tab: AccountTabKey) => void;
|
|
111
110
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
111
|
+
* SPA navigation adapter for the Memberships tab (each membership opens its
|
|
112
|
+
* community's home). Defaults to a full-page visit.
|
|
114
113
|
*/
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Navigate to the member area, the member home (host maps this to its router,
|
|
118
|
-
* e.g. `/i/members`).
|
|
119
|
-
*
|
|
120
|
-
* Optional, and drawn for every signed-in visitor, membership or none. It used
|
|
121
|
-
* to be gated on `/public/community/spaces` returning a space they could open,
|
|
122
|
-
* which hid the member area from exactly the people who had not joined yet:
|
|
123
|
-
* the member home itself shows the locked posts and the way to buy access, so
|
|
124
|
-
* the door is never a dead end.
|
|
125
|
-
*/
|
|
126
|
-
onNavigateCommunity?: () => void;
|
|
114
|
+
navigate?: (href: string) => void;
|
|
127
115
|
/**
|
|
128
116
|
* Navigate to a film route from the Rentals tab (watch, resume, rent again).
|
|
129
117
|
*
|
|
@@ -208,8 +196,7 @@ interface TabContext {
|
|
|
208
196
|
formatAmount: (amount: number, currencyCode?: string) => string;
|
|
209
197
|
formatDate: (value: string | number | Date) => string;
|
|
210
198
|
currency?: string;
|
|
211
|
-
|
|
212
|
-
onNavigateCommunity?: () => void;
|
|
199
|
+
navigate?: (href: string) => void;
|
|
213
200
|
onNavigateFilm?: (href: string) => void;
|
|
214
201
|
/**
|
|
215
202
|
* Open a course the fan owns. Host-supplied, like every other navigation
|
|
@@ -232,8 +219,7 @@ interface TabContext {
|
|
|
232
219
|
export function AccountDashboard({
|
|
233
220
|
tab,
|
|
234
221
|
onTabChange,
|
|
235
|
-
|
|
236
|
-
onNavigateCommunity,
|
|
222
|
+
navigate,
|
|
237
223
|
onNavigateFilm,
|
|
238
224
|
onNavigateCourse,
|
|
239
225
|
formatAmount,
|
|
@@ -271,11 +257,10 @@ export function AccountDashboard({
|
|
|
271
257
|
const ctx: TabContext = {
|
|
272
258
|
formatAmount: fmtAmount,
|
|
273
259
|
formatDate: fmtDate,
|
|
274
|
-
// Fallback for records with no explicit currency
|
|
260
|
+
// Fallback for records with no explicit currency: the tenant's runtime
|
|
275
261
|
// settlement currency (single source), unless the host overrides it.
|
|
276
262
|
currency: currency ?? siteConfig?.currency,
|
|
277
|
-
|
|
278
|
-
onNavigateCommunity,
|
|
263
|
+
navigate,
|
|
279
264
|
onNavigateFilm,
|
|
280
265
|
onNavigateCourse,
|
|
281
266
|
};
|
|
@@ -350,222 +335,22 @@ export function AccountDashboard({
|
|
|
350
335
|
|
|
351
336
|
// ---- Membership tab ---------------------------------------------------------
|
|
352
337
|
|
|
338
|
+
/**
|
|
339
|
+
* "My memberships": one card per community the fan belongs to, each linking
|
|
340
|
+
* inside its community, where billing, cancel and downgrade live. A fan can
|
|
341
|
+
* hold a membership in several communities of one creator, so there is no
|
|
342
|
+
* single "your membership" any more. Nothing in a join or upgrade flow sends a
|
|
343
|
+
* fan here; this is for someone who wants everything in one place.
|
|
344
|
+
*/
|
|
353
345
|
function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
354
|
-
const {
|
|
355
|
-
const { t, card, button, ghostButton } = useCardStyles();
|
|
346
|
+
const { t, card } = useCardStyles();
|
|
356
347
|
const tr = useForgeT();
|
|
357
|
-
const { formatAmount, formatDate, currency, onNavigateMembership, onNavigateCommunity } = ctx;
|
|
358
|
-
const cancelMembership = useCancelMembership();
|
|
359
|
-
// The way into the member area. Being signed in is the whole gate: a fan with
|
|
360
|
-
// no membership needs this door most, because the member home is where the
|
|
361
|
-
// locked posts and the offer to join actually live. The old gate asked
|
|
362
|
-
// `/public/community/spaces` for an openable space first, so a fan who had not
|
|
363
|
-
// joined saw no way in at all.
|
|
364
|
-
const canOpenCommunity = !!onNavigateCommunity && !!user;
|
|
365
|
-
const [isCancelling, setIsCancelling] = useState(false);
|
|
366
|
-
/**
|
|
367
|
-
* Cancelling is destructive and irreversible from here, so it asks first.
|
|
368
|
-
* CLAUDE.md puts a destructive confirmation in the category that must stay
|
|
369
|
-
* VISIBLE rather than behind a `?`, and until now one click on Cancel simply
|
|
370
|
-
* ended the membership. The client app has always asked (`EditorModal` in
|
|
371
|
-
* `MembershipTab.tsx`); this surface was ported without it.
|
|
372
|
-
*/
|
|
373
|
-
const [confirmingCancel, setConfirmingCancel] = useState(false);
|
|
374
|
-
const [cancelError, setCancelError] = useState<string | null>(null);
|
|
375
|
-
|
|
376
|
-
const membership = user?.membership;
|
|
377
|
-
/**
|
|
378
|
-
* Inside a free trial: nothing has been charged yet. The date the first
|
|
379
|
-
* charge falls and what it will be are state the member acts on (cancel
|
|
380
|
-
* before then, or not), so they are visible, not behind a hint.
|
|
381
|
-
*/
|
|
382
|
-
const inTrial = isMembershipInTrial(membership);
|
|
383
|
-
const cycleWord = (cycle: string | undefined) =>
|
|
384
|
-
cycle === "year" ? tr("forge.account_dashboard.cycle_year") : tr("forge.account_dashboard.cycle_month");
|
|
385
|
-
// Two separate questions, deliberately answered separately:
|
|
386
|
-
// `access.hasAccess` → are the benefits live (true through past_due/grace)
|
|
387
|
-
// `message` → what the member is told, including a failed payment
|
|
388
|
-
// The old `status === "active"` answered both at once and got both wrong for
|
|
389
|
-
// anyone whose card bounced.
|
|
390
|
-
const { access, message } = useMembershipAccess();
|
|
391
|
-
const openBillingPortal = useOpenBillingPortal();
|
|
392
|
-
const [billingError, setBillingError] = useState<string | null>(null);
|
|
393
|
-
|
|
394
|
-
// A failure here used to be swallowed whole: the button said "Cancelling…",
|
|
395
|
-
// then said "Cancel" again, and the member was never told anything. That is
|
|
396
|
-
// how a free-tier member — whose cancel the server rejected outright — could
|
|
397
|
-
// press this forever with no effect and no explanation.
|
|
398
|
-
const onCancel = async () => {
|
|
399
|
-
if (!membership?.id) return;
|
|
400
|
-
setCancelError(null);
|
|
401
|
-
setIsCancelling(true);
|
|
402
|
-
try {
|
|
403
|
-
await cancelMembership.mutateAsync({ membershipId: membership.id });
|
|
404
|
-
window.location.reload();
|
|
405
|
-
} catch (error) {
|
|
406
|
-
const detail = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
407
|
-
setCancelError(detail || tr("forge.account_dashboard.membership_cancel_error"));
|
|
408
|
-
} finally {
|
|
409
|
-
setIsCancelling(false);
|
|
410
|
-
}
|
|
411
|
-
};
|
|
412
|
-
|
|
413
|
-
const onUpdatePayment = async () => {
|
|
414
|
-
setBillingError(null);
|
|
415
|
-
try {
|
|
416
|
-
const { url } = await openBillingPortal.mutateAsync({});
|
|
417
|
-
window.location.href = url;
|
|
418
|
-
} catch (error) {
|
|
419
|
-
const detail = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
420
|
-
setBillingError(detail || tr("forge.account_dashboard.billing_portal_error"));
|
|
421
|
-
}
|
|
422
|
-
};
|
|
423
|
-
|
|
424
348
|
return (
|
|
425
349
|
<div style={card}>
|
|
426
350
|
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
|
|
427
351
|
{tr("forge.account_dashboard.membership_title")}
|
|
428
352
|
</h2>
|
|
429
|
-
{
|
|
430
|
-
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
431
|
-
<div>
|
|
432
|
-
<h3 style={{ fontWeight: 700 }}>{membership.membershipTier.name}</h3>
|
|
433
|
-
<p
|
|
434
|
-
style={{
|
|
435
|
-
fontSize: 13,
|
|
436
|
-
opacity: 0.85,
|
|
437
|
-
fontWeight: message.tone === "warning" ? 700 : 400,
|
|
438
|
-
color: message.tone === "warning" ? t.primary : undefined,
|
|
439
|
-
}}
|
|
440
|
-
>
|
|
441
|
-
{message.label}
|
|
442
|
-
</p>
|
|
443
|
-
{message.detail && (
|
|
444
|
-
<p style={{ fontSize: 13, opacity: 0.8, marginTop: 4, maxWidth: 480 }}>{message.detail}</p>
|
|
445
|
-
)}
|
|
446
|
-
{!!membership.subscriptionAmount && (
|
|
447
|
-
<p style={{ fontSize: 14, marginTop: 4 }}>
|
|
448
|
-
{tr("forge.account_dashboard.membership_price", {
|
|
449
|
-
amount: formatAmount(membership.subscriptionAmount, membership.subscriptionCurrency || currency),
|
|
450
|
-
cycle: membership.billingCycle,
|
|
451
|
-
})}
|
|
452
|
-
</p>
|
|
453
|
-
)}
|
|
454
|
-
</div>
|
|
455
|
-
{!!membership.membershipTier.benefits?.length && (
|
|
456
|
-
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 6 }}>
|
|
457
|
-
{membership.membershipTier.benefits.map((b) => (
|
|
458
|
-
<li key={b.id} style={{ display: "flex", gap: 8 }}>
|
|
459
|
-
<span style={{ color: t.primary }}>✓</span>
|
|
460
|
-
{b.title}
|
|
461
|
-
</li>
|
|
462
|
-
))}
|
|
463
|
-
</ul>
|
|
464
|
-
)}
|
|
465
|
-
{inTrial && membership.trialEndsAt && (
|
|
466
|
-
<div data-testid="membership-trial-state">
|
|
467
|
-
<p style={{ fontSize: 14, fontWeight: 600 }}>
|
|
468
|
-
{tr("forge.account_dashboard.membership_trial_until", { date: formatDate(membership.trialEndsAt) })}
|
|
469
|
-
</p>
|
|
470
|
-
<p style={{ fontSize: 14, opacity: 0.85, marginTop: 2 }}>
|
|
471
|
-
{access.cancelAtPeriodEnd
|
|
472
|
-
? tr("forge.account_dashboard.membership_trial_cancelled")
|
|
473
|
-
: membership.subscriptionAmount
|
|
474
|
-
? tr("forge.account_dashboard.membership_trial_then", {
|
|
475
|
-
amount: formatAmount(membership.subscriptionAmount, membership.subscriptionCurrency || currency),
|
|
476
|
-
cycle: cycleWord(membership.billingCycle),
|
|
477
|
-
date: formatDate(membership.trialEndsAt),
|
|
478
|
-
})
|
|
479
|
-
: null}
|
|
480
|
-
</p>
|
|
481
|
-
</div>
|
|
482
|
-
)}
|
|
483
|
-
{membership.endDate && !inTrial && (
|
|
484
|
-
<p style={{ fontSize: 14, opacity: 0.8 }}>
|
|
485
|
-
{access.billingState === "active"
|
|
486
|
-
? tr("forge.account_dashboard.membership_renews_on", { date: formatDate(membership.endDate) })
|
|
487
|
-
: tr("forge.account_dashboard.membership_ends_on", { date: formatDate(membership.endDate) })}
|
|
488
|
-
</p>
|
|
489
|
-
)}
|
|
490
|
-
{billingError && <p style={{ fontSize: 13, opacity: 0.9 }}>{billingError}</p>}
|
|
491
|
-
{cancelError && (
|
|
492
|
-
<p role="alert" style={{ fontSize: 13, opacity: 0.9, color: t.primary }}>
|
|
493
|
-
{cancelError}
|
|
494
|
-
</p>
|
|
495
|
-
)}
|
|
496
|
-
</div>
|
|
497
|
-
) : (
|
|
498
|
-
<p style={{ opacity: 0.8 }}>{tr("forge.account_dashboard.membership_empty")}</p>
|
|
499
|
-
)}
|
|
500
|
-
|
|
501
|
-
<div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 24, flexWrap: "wrap" }}>
|
|
502
|
-
{/* Kept hard left, apart from the billing actions: this is the door to
|
|
503
|
-
what the membership is FOR, not another thing to do to the plan. */}
|
|
504
|
-
{canOpenCommunity && (
|
|
505
|
-
<button onClick={() => onNavigateCommunity?.()} style={{ ...ghostButton, marginRight: "auto" }}>
|
|
506
|
-
{tr("forge.account_dashboard.member_area")}
|
|
507
|
-
</button>
|
|
508
|
-
)}
|
|
509
|
-
{message.showUpdatePayment && (
|
|
510
|
-
<button onClick={onUpdatePayment} disabled={openBillingPortal.isPending} style={button}>
|
|
511
|
-
{openBillingPortal.isPending
|
|
512
|
-
? tr("forge.account_dashboard.billing_portal_opening")
|
|
513
|
-
: tr("forge.account_dashboard.update_payment_method")}
|
|
514
|
-
</button>
|
|
515
|
-
)}
|
|
516
|
-
{/* Cancelling stays available for anyone who still HAS access — a
|
|
517
|
-
past_due member must not be trapped in a subscription they can see
|
|
518
|
-
but not leave. Hidden once it is already scheduled ("Ending"), where
|
|
519
|
-
the only honest label would be "Cancel again". */}
|
|
520
|
-
{access.hasAccess && !access.cancelAtPeriodEnd && (
|
|
521
|
-
<button onClick={() => setConfirmingCancel(true)} disabled={isCancelling} style={ghostButton}>
|
|
522
|
-
{isCancelling ? tr("forge.account_dashboard.cancelling") : tr("forge.account_dashboard.cancel")}
|
|
523
|
-
</button>
|
|
524
|
-
)}
|
|
525
|
-
<button onClick={() => onNavigateMembership?.()} style={button}>
|
|
526
|
-
{membership
|
|
527
|
-
? tr("forge.account_dashboard.membership_change")
|
|
528
|
-
: tr("forge.account_dashboard.membership_join")}
|
|
529
|
-
</button>
|
|
530
|
-
</div>
|
|
531
|
-
|
|
532
|
-
{/* Says WHICH of the two cancellations this is. A paid membership runs to
|
|
533
|
-
the end of the period already paid for; a free one stops now. A flat
|
|
534
|
-
"are you sure" tells a member who paid this month that they have lost
|
|
535
|
-
the rest of it, which is not true and reads as a threat. */}
|
|
536
|
-
{confirmingCancel && (
|
|
537
|
-
<div role="alertdialog" aria-modal="true" style={{ ...card, marginTop: 16 }}>
|
|
538
|
-
<p style={{ fontWeight: 700 }}>{tr("forge.account_dashboard.cancel_confirm_title")}</p>
|
|
539
|
-
<p style={{ fontSize: 14, opacity: 0.85, marginTop: 8 }}>
|
|
540
|
-
{inTrial && membership?.trialEndsAt
|
|
541
|
-
? tr("forge.account_dashboard.cancel_confirm_trial", { date: formatDate(membership.trialEndsAt) })
|
|
542
|
-
: membership?.subscriptionAmount
|
|
543
|
-
? membership.endDate
|
|
544
|
-
? tr("forge.account_dashboard.cancel_confirm_paid_until", { date: formatDate(membership.endDate) })
|
|
545
|
-
: tr("forge.account_dashboard.cancel_confirm_paid")
|
|
546
|
-
: tr("forge.account_dashboard.cancel_confirm_free")}
|
|
547
|
-
</p>
|
|
548
|
-
<div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 16 }}>
|
|
549
|
-
<button onClick={() => setConfirmingCancel(false)} disabled={isCancelling} style={ghostButton}>
|
|
550
|
-
{tr("forge.account_dashboard.cancel_confirm_keep")}
|
|
551
|
-
</button>
|
|
552
|
-
<button
|
|
553
|
-
onClick={() => {
|
|
554
|
-
// Closed FIRST, so the dialog cannot be double-submitted while
|
|
555
|
-
// the request is in flight. The row behind it already renders
|
|
556
|
-
// "Cancelling" from `isCancelling`.
|
|
557
|
-
setConfirmingCancel(false);
|
|
558
|
-
void onCancel();
|
|
559
|
-
}}
|
|
560
|
-
disabled={isCancelling}
|
|
561
|
-
style={button}
|
|
562
|
-
>
|
|
563
|
-
{tr("forge.account_dashboard.cancel_confirm_confirm")}
|
|
564
|
-
</button>
|
|
565
|
-
</div>
|
|
566
|
-
</div>
|
|
567
|
-
)}
|
|
568
|
-
|
|
353
|
+
<MyMembershipsList navigate={ctx.navigate} formatAmount={ctx.formatAmount} />
|
|
569
354
|
<MembershipApplicationsSection ctx={ctx} />
|
|
570
355
|
</div>
|
|
571
356
|
);
|
|
@@ -597,6 +382,13 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
|
|
|
597
382
|
const { t, ghostButton, button } = useCardStyles();
|
|
598
383
|
const { formatAmount, formatDate, currency } = ctx;
|
|
599
384
|
const { data: applications } = useMyMembershipApplications(user?.id);
|
|
385
|
+
const { data: communities } = usePublicCommunities({ enabled: !!applications?.some((a) => a.status === "payment_failed") });
|
|
386
|
+
/** An approved application whose card failed is finished on its community's About page, on that tier. */
|
|
387
|
+
const completeHref = (application: MembershipApplication) => {
|
|
388
|
+
const communityId = (application as MembershipApplication & { communityId?: string }).communityId;
|
|
389
|
+
const slug = communities?.find((community) => community.id === communityId)?.slug;
|
|
390
|
+
return slug ? communityAboutPath(slug, { tier: application.membershipTierId }) : communitiesListPath();
|
|
391
|
+
};
|
|
600
392
|
const withdraw = useWithdrawMembershipApplication();
|
|
601
393
|
const confirm = useConfirmMembershipApplication();
|
|
602
394
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -674,7 +466,7 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
|
|
|
674
466
|
)}
|
|
675
467
|
{application.status === "payment_failed" && (
|
|
676
468
|
<a
|
|
677
|
-
href={
|
|
469
|
+
href={completeHref(application)}
|
|
678
470
|
style={{ ...button, textDecoration: "none" }}
|
|
679
471
|
>
|
|
680
472
|
{tr("forge.account_dashboard.application_complete")}
|
|
@@ -698,7 +490,7 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
|
|
|
698
490
|
* The buyer's event tickets, and cancelling one inside the policy they were
|
|
699
491
|
* sold under (S.6).
|
|
700
492
|
*
|
|
701
|
-
* Before this, a ticket buyer had no purchase surface at all
|
|
493
|
+
* Before this, a ticket buyer had no purchase surface at all: the confirmation
|
|
702
494
|
* email and a one-shot finalise page were the only artefacts of the sale.
|
|
703
495
|
*
|
|
704
496
|
* Eligibility is NOT recomputed here. `cancellation.canCancel` and its reason
|
|
@@ -709,7 +501,7 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
|
|
|
709
501
|
* ## Two kinds of row
|
|
710
502
|
*
|
|
711
503
|
* `role` says whether the caller PAID for this order or merely HOLDS a pass out
|
|
712
|
-
* of it
|
|
504
|
+
* of it: a ticket somebody transferred to them, which until now reached them
|
|
713
505
|
* nowhere at all. A holder's row is scrubbed server-side (no totals, no line
|
|
714
506
|
* prices, no other seats, no cancel), and this renders that difference rather
|
|
715
507
|
* than printing "$0.00" over somebody else's purchase.
|
|
@@ -821,7 +613,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
821
613
|
})}
|
|
822
614
|
</span>
|
|
823
615
|
{/* A holder is not shown a price, because the server did
|
|
824
|
-
not send one
|
|
616
|
+
not send one: `Number(null)` is 0, and a confident
|
|
825
617
|
"$0.00" on somebody else's purchase is a lie. */}
|
|
826
618
|
{!gifted && (
|
|
827
619
|
<span>{formatAmount(Number(item.price) * item.quantity, ticket.currency || currency)}</span>
|
|
@@ -866,7 +658,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
866
658
|
</div>
|
|
867
659
|
)}
|
|
868
660
|
|
|
869
|
-
{/* Diarise it
|
|
661
|
+
{/* Diarise it: including the Apple option, which needs the
|
|
870
662
|
platform's own `.ics` and so could not exist before. Null
|
|
871
663
|
for a called-off show, and the component draws nothing. */}
|
|
872
664
|
{/* Not offered on a request: the fan has not been admitted to
|
|
@@ -885,7 +677,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
885
677
|
|
|
886
678
|
{/*
|
|
887
679
|
Wallet passes (2.3). Renders NOTHING unless the API offers a
|
|
888
|
-
real pass for a real pass id
|
|
680
|
+
real pass for a real pass id: which, with no signing
|
|
889
681
|
credentials configured, is every ticket today. Deliberately
|
|
890
682
|
not wrapped in a condition of its own: the component owns that
|
|
891
683
|
decision, and a second copy of the rule here is how one of the
|
|
@@ -899,20 +691,20 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
899
691
|
{/*
|
|
900
692
|
Transfer (2.2). The email a transfer sends points at
|
|
901
693
|
`/i/tickets/claim`, so this affordance and that route ship
|
|
902
|
-
together
|
|
694
|
+
together: a send with nowhere to land is undeliverable in
|
|
903
695
|
practice.
|
|
904
696
|
|
|
905
697
|
Renders nothing when the payload carries no `TN-…` pass ids
|
|
906
698
|
(an older API build), because every pass-scoped endpoint would
|
|
907
699
|
404. Whether THIS pass may be handed on is the server's call,
|
|
908
|
-
answered on submit in its own words
|
|
700
|
+
answered on submit in its own words: not hidden behind a
|
|
909
701
|
guess here.
|
|
910
702
|
|
|
911
703
|
HELD passes only: the endpoints resolve the current holder, so
|
|
912
704
|
a control on a named guest's seat or on a ticket already
|
|
913
705
|
claimed by somebody else could answer nothing but 404. The
|
|
914
706
|
pass objects go through too, because each carries the caller's
|
|
915
|
-
own `pendingTransfer
|
|
707
|
+
own `pendingTransfer`: which is what lets a sender withdraw
|
|
916
708
|
from a device that is not the one they sent from.
|
|
917
709
|
*/}
|
|
918
710
|
<TicketTransferPanel
|
|
@@ -967,8 +759,8 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
967
759
|
*
|
|
968
760
|
* Two states, and they are genuinely different things:
|
|
969
761
|
*
|
|
970
|
-
* WAITING
|
|
971
|
-
* NOTIFIED
|
|
762
|
+
* WAITING: a 1-based `position` in the queue.
|
|
763
|
+
* NOTIFIED: an OFFER with a deadline, and `position: null`. An offer is not a
|
|
972
764
|
* queue place, so it is never rendered as one; the deadline is the
|
|
973
765
|
* whole point and gets the emphasis.
|
|
974
766
|
*
|
|
@@ -1131,13 +923,13 @@ function WaitlistPlaceRow({
|
|
|
1131
923
|
* Session buyers had no purchase surface at all: `GET /public/coaching/bookings/mine`
|
|
1132
924
|
* and the reschedule action shipped with nothing that could reach them.
|
|
1133
925
|
*
|
|
1134
|
-
* A booking is deliberately NOT folded into the Orders tab
|
|
926
|
+
* A booking is deliberately NOT folded into the Orders tab: that tab reads
|
|
1135
927
|
* `/public/orders` and covers product orders only, so a session would never
|
|
1136
928
|
* appear there however hard one looked.
|
|
1137
929
|
*
|
|
1138
930
|
* Nothing is recomputed here. `canReschedule` and `rescheduleReason` arrive on
|
|
1139
931
|
* every row, and the slot grid the picker offers is the server's own
|
|
1140
|
-
* availability read
|
|
932
|
+
* availability read: which already excludes taken slots and hours the coach is
|
|
1141
933
|
* busy on any of their services.
|
|
1142
934
|
*
|
|
1143
935
|
* `cancellation` comes from the POLICY SNAPSHOT taken on the booking at
|
|
@@ -1194,15 +986,15 @@ function formatSessionWindow(booking: MyBooking, tr: ForgeT): string {
|
|
|
1194
986
|
}
|
|
1195
987
|
|
|
1196
988
|
/**
|
|
1197
|
-
* WHERE the session is
|
|
989
|
+
* WHERE the session is: the line this portal existed without.
|
|
1198
990
|
*
|
|
1199
991
|
* A client who bought a coaching call could see the time, the price and an
|
|
1200
992
|
* add-to-calendar button, and nothing at all about how to attend; the answer
|
|
1201
993
|
* arrived, if it arrived, in a separate email from the coach.
|
|
1202
994
|
*
|
|
1203
995
|
* The two kinds are not interchangeable, so they do not render the same. An
|
|
1204
|
-
* online session's link is an anchor
|
|
1205
|
-
* thing on this row anyone wants
|
|
996
|
+
* online session's link is an anchor: at 9:59 for a 10:00 call it is the only
|
|
997
|
+
* thing on this row anyone wants: shown in full rather than as the word "Join",
|
|
1206
998
|
* because the client may be reading this on a phone and about to type it into
|
|
1207
999
|
* a laptop. An in-person address is plain text: it is the coach's own words,
|
|
1208
1000
|
* often "Studio B, ring the buzzer" rather than anything a maps provider could
|
|
@@ -1210,7 +1002,7 @@ function formatSessionWindow(booking: MyBooking, tr: ForgeT): string {
|
|
|
1210
1002
|
*
|
|
1211
1003
|
* `location` is already resolved server-side (booking override over product
|
|
1212
1004
|
* default) and is null unless the session is confirmed, so there is no rule to
|
|
1213
|
-
* apply here
|
|
1005
|
+
* apply here: which is the same discipline `canReschedule` follows two blocks
|
|
1214
1006
|
* down.
|
|
1215
1007
|
*/
|
|
1216
1008
|
function BookingLocationLine({
|
|
@@ -1331,7 +1123,7 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
|
|
|
1331
1123
|
</div>
|
|
1332
1124
|
</div>
|
|
1333
1125
|
|
|
1334
|
-
{/* Diarise it
|
|
1126
|
+
{/* Diarise it: including Apple, which needs the platform's own `.ics`.
|
|
1335
1127
|
Null unless the session is confirmed, and the component draws nothing. */}
|
|
1336
1128
|
<AddToCalendar
|
|
1337
1129
|
variant="compact"
|
|
@@ -1358,8 +1150,8 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
|
|
|
1358
1150
|
)}
|
|
1359
1151
|
|
|
1360
1152
|
{/*
|
|
1361
|
-
The action row. Both controls are drawn from the SERVER's flags
|
|
1362
|
-
`canReschedule` and `cancellation.canCancel
|
|
1153
|
+
The action row. Both controls are drawn from the SERVER's flags:
|
|
1154
|
+
`canReschedule` and `cancellation.canCancel`: never from eligibility
|
|
1363
1155
|
re-derived in the browser, which would give two answers to one question.
|
|
1364
1156
|
*/}
|
|
1365
1157
|
{error && (
|
|
@@ -1487,7 +1279,7 @@ function BookingCallEntry({ booking }: { booking: MyBooking }) {
|
|
|
1487
1279
|
* Pick another time for a session already bought.
|
|
1488
1280
|
*
|
|
1489
1281
|
* The grid is `GET /public/coaching/products/:id/availability` over the next
|
|
1490
|
-
* fortnight
|
|
1282
|
+
* fortnight: the same read the original purchase used, which already drops
|
|
1491
1283
|
* taken slots and hours the coach is busy on any of their services. It is not
|
|
1492
1284
|
* filtered further here: a slot this list offers and the server then refuses
|
|
1493
1285
|
* (two buyers racing for one hour, decided by a database EXCLUDE constraint)
|