@tribe-nest/forge 3.35.0 → 3.37.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/_tests/paymentFlowReturnUrl.spec.tsx +165 -0
- package/src/data/queries/usePaymentFlow.ts +28 -3
- package/src/i18n/de.json +6 -1
- package/src/i18n/en.json +6 -1
- package/src/ui/format/membershipPriceLabel.ts +29 -0
- package/src/ui/headless/_tests/dialogPaystackStandDown.spec.tsx +185 -0
- package/src/ui/headless/dialog.tsx +139 -4
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +136 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +18 -4
- package/src/ui/index.ts +5 -1
- package/src/ui/styled/AccountDashboard.tsx +87 -16
- package/src/ui/styled/FilmLibrary.tsx +61 -24
- package/src/ui/styled/LoginForm.tsx +15 -1
- package/src/ui/styled/MembershipTierCallout.tsx +91 -0
- package/src/ui/styled/MembershipTiers.tsx +5 -15
- package/src/ui/styled/SignupForm.tsx +10 -0
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +49 -34
- package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +200 -0
- package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +126 -0
- package/src/ui/styled/forge-utilities.css +3 -0
- package/src/utils/_tests/paystackCheckout.spec.ts +82 -1
- package/src/utils/paystackCheckout.ts +47 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
import { renderHook, act } from "@testing-library/react";
|
|
4
|
+
import { useMembershipCheckout } from "../useMembershipCheckout";
|
|
5
|
+
import type { MembershipTier } from "../../../../types/models";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The member the account page reads is re-read before the checkout hands over.
|
|
9
|
+
*
|
|
10
|
+
* A host that passes `onComplete` navigates with its own router, and that does
|
|
11
|
+
* NOT re-read the signed-in member: `user` is context state, fetched once when
|
|
12
|
+
* the page was first opened. So a fan who joined a free tier arrived on an
|
|
13
|
+
* account page still holding the user they were BEFORE they joined, and it
|
|
14
|
+
* offered them "Join" over a live membership until they refreshed by hand.
|
|
15
|
+
*
|
|
16
|
+
* Three seams, all mocked at the module boundary so nothing touches the network:
|
|
17
|
+
* the auth context (for `refetchUser`), the tier list, and the subscription
|
|
18
|
+
* mutations.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const auth = vi.hoisted(() => ({
|
|
22
|
+
user: { id: "account-1", membership: undefined as unknown },
|
|
23
|
+
refetchUser: vi.fn(async () => {}),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const subscriptions = vi.hoisted(() => ({
|
|
27
|
+
createFree: vi.fn(async () => ({})),
|
|
28
|
+
createPaid: vi.fn(async () => ({}) as Record<string, unknown>),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
/** Every call in the order it happened, which is the whole assertion. */
|
|
32
|
+
const calls = vi.hoisted(() => [] as string[]);
|
|
33
|
+
|
|
34
|
+
vi.mock("../../../../contexts/PublicAuthContext", async (importOriginal) => ({
|
|
35
|
+
...(await importOriginal<typeof import("../../../../contexts/PublicAuthContext")>()),
|
|
36
|
+
usePublicAuth: () => ({ user: auth.user, refetchUser: auth.refetchUser }),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
vi.mock("../../../../data/queries/useMembership", async (importOriginal) => ({
|
|
40
|
+
...(await importOriginal<typeof import("../../../../data/queries/useMembership")>()),
|
|
41
|
+
useGetMembershipTiers: () => ({ data: tiers, isLoading: false }),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
vi.mock("../../../../data/queries/useSubscriptions", async (importOriginal) => ({
|
|
45
|
+
...(await importOriginal<typeof import("../../../../data/queries/useSubscriptions")>()),
|
|
46
|
+
useCreateSubscription: () => ({ mutateAsync: subscriptions.createPaid, isPending: false }),
|
|
47
|
+
useCreateFreeSubscription: () => ({ mutateAsync: subscriptions.createFree, isPending: false }),
|
|
48
|
+
useConfirmLatestSubscription: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const tier = (overrides: Partial<MembershipTier>): MembershipTier =>
|
|
52
|
+
({
|
|
53
|
+
id: "tier-free",
|
|
54
|
+
profileId: "profile-1",
|
|
55
|
+
name: "Free tier",
|
|
56
|
+
priceMonthly: 0,
|
|
57
|
+
priceYearly: null,
|
|
58
|
+
payWhatYouWant: false,
|
|
59
|
+
benefits: [],
|
|
60
|
+
...overrides,
|
|
61
|
+
}) as unknown as MembershipTier;
|
|
62
|
+
|
|
63
|
+
const tiers: MembershipTier[] = [
|
|
64
|
+
tier({}),
|
|
65
|
+
tier({ id: "tier-paid", name: "Inner Circle", priceMonthly: 10 } as Partial<MembershipTier>),
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
calls.length = 0;
|
|
70
|
+
auth.refetchUser.mockClear();
|
|
71
|
+
auth.refetchUser.mockImplementation(async () => {
|
|
72
|
+
calls.push("refetch");
|
|
73
|
+
});
|
|
74
|
+
subscriptions.createFree.mockClear();
|
|
75
|
+
subscriptions.createFree.mockResolvedValue({});
|
|
76
|
+
subscriptions.createPaid.mockClear();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const onComplete = () => calls.push("complete");
|
|
80
|
+
|
|
81
|
+
describe("useMembershipCheckout: the member is re-read before the host navigates", () => {
|
|
82
|
+
it("REGRESSION: refetches the member after a free activation, before handing over", async () => {
|
|
83
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-free", onComplete }));
|
|
84
|
+
|
|
85
|
+
await act(async () => {
|
|
86
|
+
await result.current.subscribe();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(subscriptions.createFree).toHaveBeenCalledTimes(1);
|
|
90
|
+
// Order matters: handing over first means the account page renders from the
|
|
91
|
+
// stale user and never re-renders, which IS the bug.
|
|
92
|
+
expect(calls).toEqual(["refetch", "complete"]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("REGRESSION: refetches when the server settled a tier change with nothing to pay", async () => {
|
|
96
|
+
// Proration on an existing subscription: `requiresPayment` false, no card
|
|
97
|
+
// step, and the same stale-user landing as a free join.
|
|
98
|
+
subscriptions.createPaid.mockResolvedValue({ requiresPayment: false });
|
|
99
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-paid", onComplete }));
|
|
100
|
+
|
|
101
|
+
await act(async () => {
|
|
102
|
+
await result.current.subscribe();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(calls).toEqual(["refetch", "complete"]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("leaves the confirmation leg to the account page", async () => {
|
|
109
|
+
// A real provider payment has to be reconciled first, and the account page
|
|
110
|
+
// re-reads the member AFTER that. Refetching here would read the membership
|
|
111
|
+
// a beat too early and cache the pre-payment answer.
|
|
112
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-paid", onComplete }));
|
|
113
|
+
|
|
114
|
+
await act(async () => {
|
|
115
|
+
await result.current.finish(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(auth.refetchUser).not.toHaveBeenCalled();
|
|
119
|
+
expect(calls).toEqual(["complete"]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("does not refetch when the fan still has a card step in front of them", async () => {
|
|
123
|
+
// Stripe handed back a client secret: nothing has been paid, nothing has
|
|
124
|
+
// changed about the member, and the checkout is still on screen.
|
|
125
|
+
subscriptions.createPaid.mockResolvedValue({ requiresPayment: true, clientSecret: "cs_test" });
|
|
126
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-paid", onComplete }));
|
|
127
|
+
|
|
128
|
+
await act(async () => {
|
|
129
|
+
await result.current.subscribe();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(auth.refetchUser).not.toHaveBeenCalled();
|
|
133
|
+
expect(calls).toEqual([]);
|
|
134
|
+
expect(result.current.step).toBe("payment");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -79,7 +79,7 @@ const errMessage = (e: unknown) =>
|
|
|
79
79
|
* provider tree. Read that file before changing anything about money here.
|
|
80
80
|
*/
|
|
81
81
|
export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
82
|
-
const { user } = usePublicAuth();
|
|
82
|
+
const { user, refetchUser } = usePublicAuth();
|
|
83
83
|
const { data: tiers, isLoading } = useGetMembershipTiers();
|
|
84
84
|
const createPaid = useCreateSubscription();
|
|
85
85
|
const createFree = useCreateFreeSubscription();
|
|
@@ -167,7 +167,21 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
167
167
|
const isChange =
|
|
168
168
|
!!membership?.paymentProviderSubscriptionId && membership.status === "active" && !isFreeCycle && !!selectedTier;
|
|
169
169
|
|
|
170
|
-
|
|
170
|
+
/**
|
|
171
|
+
* `await`ed by every caller, because of the refetch below.
|
|
172
|
+
*
|
|
173
|
+
* A host that passes `onComplete` navigates with its own router, which does
|
|
174
|
+
* NOT re-read the signed-in member: `user` is context state, loaded once when
|
|
175
|
+
* the page was first opened. So a fan who joined a free tier, or moved tier by
|
|
176
|
+
* proration, landed on an account page still holding the user they were before
|
|
177
|
+
* they pressed the button, and it offered them "Join" over a membership that
|
|
178
|
+
* was already live until they hard-refreshed.
|
|
179
|
+
*
|
|
180
|
+
* The confirmation leg is left alone on purpose: it has a provider payment to
|
|
181
|
+
* reconcile first, and the account page re-reads the member AFTER that.
|
|
182
|
+
*/
|
|
183
|
+
const finish = async (requiresConfirmation: boolean) => {
|
|
184
|
+
if (!requiresConfirmation) await refetchUser();
|
|
171
185
|
if (opts.onComplete) {
|
|
172
186
|
opts.onComplete({ requiresConfirmation });
|
|
173
187
|
return;
|
|
@@ -207,7 +221,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
207
221
|
try {
|
|
208
222
|
if (isFreeCycle) {
|
|
209
223
|
await createFree.mutateAsync({ membershipTierId: selectedTier.id, attributionRefId, ...landing });
|
|
210
|
-
finish(false);
|
|
224
|
+
await finish(false);
|
|
211
225
|
return;
|
|
212
226
|
}
|
|
213
227
|
const data = await createPaid.mutateAsync({
|
|
@@ -228,7 +242,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
228
242
|
// membership nobody has paid for.
|
|
229
243
|
const requiresPayment = data.requiresPayment ?? !!(data.clientSecret || data.accessCode || data.checkoutUrl);
|
|
230
244
|
if (!requiresPayment) {
|
|
231
|
-
finish(false);
|
|
245
|
+
await finish(false);
|
|
232
246
|
return;
|
|
233
247
|
}
|
|
234
248
|
|
package/src/ui/index.ts
CHANGED
|
@@ -96,6 +96,7 @@ export { ProductBrowseNav, type ProductBrowseNavProps } from "./styled/ProductBr
|
|
|
96
96
|
export { Addons, type AddonsProps } from "./styled/Addons";
|
|
97
97
|
export { BundleConfirmation, type BundleConfirmationProps } from "./styled/BundleConfirmation";
|
|
98
98
|
export { ProductDetail, type ProductDetailProps } from "./styled/ProductDetail";
|
|
99
|
+
export { MembershipTierCallout, type MembershipTierCalloutProps } from "./styled/MembershipTierCallout";
|
|
99
100
|
export { MembershipTiers, type MembershipTiersProps } from "./styled/MembershipTiers";
|
|
100
101
|
export { EventsList, type EventsListProps } from "./styled/EventsList";
|
|
101
102
|
export { EventTickets, type EventTicketsProps } from "./styled/EventTickets";
|
|
@@ -200,7 +201,10 @@ export { CourseAccess, type CourseAccessProps } from "./styled/CourseAccess";
|
|
|
200
201
|
// five gate states each strand somebody if drawn as one of the others.
|
|
201
202
|
export { FilmCatalog, rentalWindowLabel, type FilmCatalogProps } from "./styled/FilmCatalog";
|
|
202
203
|
export { FilmDetail, type FilmDetailProps } from "./styled/FilmDetail";
|
|
203
|
-
|
|
204
|
+
// `FilmRentalList` is the library WITHOUT its page heading: the rows on their
|
|
205
|
+
// own, for a surface that supplies its own title (the account dashboard's
|
|
206
|
+
// Rentals tab does exactly this). Same rows, same live/expired rule.
|
|
207
|
+
export { FilmLibrary, FilmRentalList, type FilmLibraryProps, type FilmRentalListProps } from "./styled/FilmLibrary";
|
|
204
208
|
export { FilmWatch, type FilmWatchProps, type FilmPlayerRenderProps } from "./styled/FilmWatch";
|
|
205
209
|
export { DonationPage, type DonationPageProps } from "./styled/DonationPage";
|
|
206
210
|
export { ReviewsSection, StarRow, type ReviewsSectionProps } from "./styled/ReviewsSection";
|
|
@@ -18,10 +18,10 @@ import {
|
|
|
18
18
|
useSavedPosts,
|
|
19
19
|
useNotificationPreferences,
|
|
20
20
|
useUpdateNotificationPreference,
|
|
21
|
+
useMyFilmRentals,
|
|
21
22
|
useCancelMembership,
|
|
22
23
|
useMembershipAccess,
|
|
23
24
|
useOpenBillingPortal,
|
|
24
|
-
useCommunitySpaces,
|
|
25
25
|
useUpdateAccount,
|
|
26
26
|
useChangePassword,
|
|
27
27
|
useExportAccountData,
|
|
@@ -42,6 +42,7 @@ import { AddToCalendar } from "./AddToCalendar";
|
|
|
42
42
|
import { WalletPassButtons } from "./WalletPassButtons";
|
|
43
43
|
import { TicketTransferPanel } from "./TicketTransfer";
|
|
44
44
|
import { CartLineOptions } from "./CartLineOptions";
|
|
45
|
+
import { FilmRentalList } from "./FilmLibrary";
|
|
45
46
|
|
|
46
47
|
export const ACCOUNT_TABS = [
|
|
47
48
|
"membership",
|
|
@@ -54,6 +55,13 @@ export const ACCOUNT_TABS = [
|
|
|
54
55
|
// "order" — the Orders tab covers product orders only — so it needs its own
|
|
55
56
|
// tab or it is unreachable, which is what it was.
|
|
56
57
|
"bookings",
|
|
58
|
+
// Films and series this fan has rented. Next to the other purchase surfaces
|
|
59
|
+
// because it answers the same question they do: what did I buy from this
|
|
60
|
+
// artist, and can I use it right now?
|
|
61
|
+
//
|
|
62
|
+
// Drawn only when the fan actually holds a rental (see `visibleTabs`), so the
|
|
63
|
+
// tab does not appear on the account page of every site that never sells one.
|
|
64
|
+
"rentals",
|
|
57
65
|
"saved",
|
|
58
66
|
"account",
|
|
59
67
|
"notifications",
|
|
@@ -70,6 +78,7 @@ const TAB_LABEL_KEYS: Record<AccountTabKey, string> = {
|
|
|
70
78
|
tickets: "forge.account_dashboard.tab_tickets",
|
|
71
79
|
waitlist: "forge.account_dashboard.tab_waitlist",
|
|
72
80
|
bookings: "forge.account_dashboard.tab_bookings",
|
|
81
|
+
rentals: "forge.account_dashboard.tab_rentals",
|
|
73
82
|
saved: "forge.account_dashboard.tab_saved",
|
|
74
83
|
account: "forge.account_dashboard.tab_account",
|
|
75
84
|
notifications: "forge.account_dashboard.tab_notifications",
|
|
@@ -88,16 +97,25 @@ export interface AccountDashboardProps {
|
|
|
88
97
|
*/
|
|
89
98
|
onNavigateMembership?: () => void;
|
|
90
99
|
/**
|
|
91
|
-
* Navigate to the
|
|
92
|
-
*
|
|
100
|
+
* Navigate to the member area, the member home (host maps this to its router,
|
|
101
|
+
* e.g. `/i/members`).
|
|
93
102
|
*
|
|
94
|
-
* Optional, and drawn
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
103
|
+
* Optional, and drawn for every signed-in visitor, membership or none. It used
|
|
104
|
+
* to be gated on `/public/community/spaces` returning a space they could open,
|
|
105
|
+
* which hid the member area from exactly the people who had not joined yet:
|
|
106
|
+
* the member home itself shows the locked posts and the way to buy access, so
|
|
107
|
+
* the door is never a dead end.
|
|
99
108
|
*/
|
|
100
109
|
onNavigateCommunity?: () => void;
|
|
110
|
+
/**
|
|
111
|
+
* Navigate to a film route from the Rentals tab (watch, resume, rent again).
|
|
112
|
+
*
|
|
113
|
+
* Takes the href rather than being one callback per destination, because a
|
|
114
|
+
* rental row points at a title AND at the episode the fan got to, so the
|
|
115
|
+
* target is data rather than a fixed page. Left out, the row falls back to a
|
|
116
|
+
* full-page visit, which works on any site and is merely slower.
|
|
117
|
+
*/
|
|
118
|
+
onNavigateFilm?: (href: string) => void;
|
|
101
119
|
/**
|
|
102
120
|
* Optional currency formatter override. Defaults to Forge's `useFormatCurrency`
|
|
103
121
|
* (which honors the visitor's selected currency + tenant exchange rates), so the
|
|
@@ -164,6 +182,7 @@ interface TabContext {
|
|
|
164
182
|
currency?: string;
|
|
165
183
|
onNavigateMembership?: () => void;
|
|
166
184
|
onNavigateCommunity?: () => void;
|
|
185
|
+
onNavigateFilm?: (href: string) => void;
|
|
167
186
|
}
|
|
168
187
|
|
|
169
188
|
// ---- page -------------------------------------------------------------------
|
|
@@ -178,6 +197,7 @@ export function AccountDashboard({
|
|
|
178
197
|
onTabChange,
|
|
179
198
|
onNavigateMembership,
|
|
180
199
|
onNavigateCommunity,
|
|
200
|
+
onNavigateFilm,
|
|
181
201
|
formatAmount,
|
|
182
202
|
formatDate,
|
|
183
203
|
currency,
|
|
@@ -188,6 +208,17 @@ export function AccountDashboard({
|
|
|
188
208
|
const { t } = useCardStyles();
|
|
189
209
|
const tr = useForgeT();
|
|
190
210
|
const { data: siteConfig } = useSiteConfig();
|
|
211
|
+
/**
|
|
212
|
+
* Does this fan hold any rental at all? That is the whole gate on the Rentals
|
|
213
|
+
* tab: the server's own answer decides, and a surface with nothing behind it
|
|
214
|
+
* is not drawn.
|
|
215
|
+
*
|
|
216
|
+
* Film rentals are off by default and per profile, so on most sites this read
|
|
217
|
+
* comes back empty for ever and the tab never appears. It is the SAME query
|
|
218
|
+
* `FilmRentalList` runs inside the tab (same key, same arguments), so
|
|
219
|
+
* react-query serves both from one request rather than two.
|
|
220
|
+
*/
|
|
221
|
+
const { data: rentals } = useMyFilmRentals(user?.id);
|
|
191
222
|
|
|
192
223
|
const fmtAmount = useAmountFormatter(formatAmount);
|
|
193
224
|
const fmtDate = formatDate ?? defaultFormatDate;
|
|
@@ -200,8 +231,18 @@ export function AccountDashboard({
|
|
|
200
231
|
currency: currency ?? siteConfig?.currency,
|
|
201
232
|
onNavigateMembership,
|
|
202
233
|
onNavigateCommunity,
|
|
234
|
+
onNavigateFilm,
|
|
203
235
|
};
|
|
204
236
|
|
|
237
|
+
/**
|
|
238
|
+
* The strip hides Rentals until there is something in it. The one exception
|
|
239
|
+
* is the tab currently being shown, so a link straight to `?tab=rentals` (a
|
|
240
|
+
* receipt email, a bookmark) does not land on a page whose strip disowns the
|
|
241
|
+
* panel underneath it.
|
|
242
|
+
*/
|
|
243
|
+
const hasRentals = (rentals?.total ?? 0) > 0;
|
|
244
|
+
const visibleTabs = ACCOUNT_TABS.filter((key) => key !== "rentals" || hasRentals || tab === "rentals");
|
|
245
|
+
|
|
205
246
|
if (!user) return <Loading fullPage />;
|
|
206
247
|
|
|
207
248
|
return (
|
|
@@ -222,7 +263,7 @@ export function AccountDashboard({
|
|
|
222
263
|
</div>
|
|
223
264
|
|
|
224
265
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 24 }}>
|
|
225
|
-
{
|
|
266
|
+
{visibleTabs.map((tabKey) => (
|
|
226
267
|
<button
|
|
227
268
|
key={tabKey}
|
|
228
269
|
onClick={() => onTabChange?.(tabKey)}
|
|
@@ -247,6 +288,7 @@ export function AccountDashboard({
|
|
|
247
288
|
{tab === "tickets" && <TicketsTab ctx={ctx} />}
|
|
248
289
|
{tab === "waitlist" && <WaitlistTab ctx={ctx} />}
|
|
249
290
|
{tab === "bookings" && <BookingsTab ctx={ctx} />}
|
|
291
|
+
{tab === "rentals" && <RentalsTab ctx={ctx} />}
|
|
250
292
|
{tab === "saved" && <SavedTab />}
|
|
251
293
|
{tab === "account" && <AccountTab ctx={ctx} />}
|
|
252
294
|
{tab === "notifications" && <NotificationsTab />}
|
|
@@ -262,12 +304,12 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
262
304
|
const tr = useForgeT();
|
|
263
305
|
const { formatAmount, formatDate, currency, onNavigateMembership, onNavigateCommunity } = ctx;
|
|
264
306
|
const cancelMembership = useCancelMembership();
|
|
265
|
-
// The way into the
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
const canOpenCommunity = !!onNavigateCommunity &&
|
|
307
|
+
// The way into the member area. Being signed in is the whole gate: a fan with
|
|
308
|
+
// no membership needs this door most, because the member home is where the
|
|
309
|
+
// locked posts and the offer to join actually live. The old gate asked
|
|
310
|
+
// `/public/community/spaces` for an openable space first, so a fan who had not
|
|
311
|
+
// joined saw no way in at all.
|
|
312
|
+
const canOpenCommunity = !!onNavigateCommunity && !!user;
|
|
271
313
|
const [isCancelling, setIsCancelling] = useState(false);
|
|
272
314
|
/**
|
|
273
315
|
* Cancelling is destructive and irreversible from here, so it asks first.
|
|
@@ -383,7 +425,7 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
383
425
|
what the membership is FOR, not another thing to do to the plan. */}
|
|
384
426
|
{canOpenCommunity && (
|
|
385
427
|
<button onClick={() => onNavigateCommunity?.()} style={{ ...ghostButton, marginRight: "auto" }}>
|
|
386
|
-
{tr("forge.account_dashboard.
|
|
428
|
+
{tr("forge.account_dashboard.member_area")}
|
|
387
429
|
</button>
|
|
388
430
|
)}
|
|
389
431
|
{message.showUpdatePayment && (
|
|
@@ -1286,6 +1328,35 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
|
|
|
1286
1328
|
);
|
|
1287
1329
|
}
|
|
1288
1330
|
|
|
1331
|
+
// ---- Rentals tab ------------------------------------------------------------
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Films and series this fan has rented, live and expired.
|
|
1335
|
+
*
|
|
1336
|
+
* `/i/films/library` already answered this, and nothing on the account page led
|
|
1337
|
+
* to it: a fan who rented a film found their tickets, their orders and their
|
|
1338
|
+
* sessions here and no sign that the rental existed. This is that page's list,
|
|
1339
|
+
* rendered in the tab strip where a buyer looks for a purchase.
|
|
1340
|
+
*
|
|
1341
|
+
* The affordance per row comes from the SERVER's `isLive`, not from arithmetic
|
|
1342
|
+
* repeated here: a running window offers Watch (Resume, when there is progress
|
|
1343
|
+
* to resume), and a window that has closed or been refunded offers Rent again.
|
|
1344
|
+
* That decision lives in one place, `FilmRentalList`, and both surfaces read it.
|
|
1345
|
+
*/
|
|
1346
|
+
function RentalsTab({ ctx }: { ctx: TabContext }) {
|
|
1347
|
+
const { t, card } = useCardStyles();
|
|
1348
|
+
const tr = useForgeT();
|
|
1349
|
+
|
|
1350
|
+
return (
|
|
1351
|
+
<div style={card}>
|
|
1352
|
+
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
|
|
1353
|
+
{tr("forge.account_dashboard.rentals_title")}
|
|
1354
|
+
</h2>
|
|
1355
|
+
<FilmRentalList navigate={ctx.onNavigateFilm} />
|
|
1356
|
+
</div>
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1289
1360
|
// ---- Orders tab -------------------------------------------------------------
|
|
1290
1361
|
|
|
1291
1362
|
function OrdersTab({ ctx }: { ctx: TabContext }) {
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "../../data/queries/useFilms";
|
|
12
12
|
import { useForgeT, type ForgeT } from "../../i18n";
|
|
13
13
|
|
|
14
|
-
export interface
|
|
14
|
+
export interface FilmRentalListProps {
|
|
15
15
|
/** Where films live. Default `/i/films`. */
|
|
16
16
|
basePath?: string;
|
|
17
17
|
/** SPA navigation adapter. Defaults to a full-page visit. */
|
|
@@ -20,27 +20,30 @@ export interface FilmLibraryProps {
|
|
|
20
20
|
style?: CSSProperties;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** The page takes exactly what the list takes: it adds a heading, nothing else. */
|
|
24
|
+
export type FilmLibraryProps = FilmRentalListProps;
|
|
25
|
+
|
|
23
26
|
/**
|
|
24
|
-
* The
|
|
25
|
-
* has run out.
|
|
27
|
+
* The rentals themselves, with no page furniture around them.
|
|
26
28
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
29
|
+
* Split out of `FilmLibrary` so the account dashboard's Rentals tab renders the
|
|
30
|
+
* SAME rows as `/i/films/library` rather than a second implementation of "is
|
|
31
|
+
* this window still open". Two answers to that question is one answer too many:
|
|
32
|
+
* the divergent copy would appear on whichever surface was edited second, and
|
|
33
|
+
* the fan would be told two different things about the same rental.
|
|
31
34
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
+
* It fetches its own data. `useMyFilmRentals` is keyed on the account, so a host
|
|
36
|
+
* that already read it (the dashboard does, to decide whether to draw the tab at
|
|
37
|
+
* all) shares one request through react-query rather than making a second.
|
|
35
38
|
*/
|
|
36
|
-
export function
|
|
39
|
+
export function FilmRentalList({ basePath = "/i/films", navigate, className, style }: FilmRentalListProps) {
|
|
37
40
|
const t = useForgeT();
|
|
38
41
|
const tokens = useThemeTokens();
|
|
39
42
|
const { user, isInitialized } = usePublicAuth();
|
|
40
43
|
const { data, isLoading } = useMyFilmRentals(user?.id);
|
|
41
44
|
const go = navigate ?? ((href: string) => window.location.assign(href));
|
|
42
45
|
|
|
43
|
-
if (!isInitialized || isLoading) return <Loading
|
|
46
|
+
if (!isInitialized || isLoading) return <Loading />;
|
|
44
47
|
|
|
45
48
|
const rentals = data?.data ?? [];
|
|
46
49
|
|
|
@@ -61,23 +64,11 @@ export function FilmLibrary({ basePath = "/i/films", navigate, className, style
|
|
|
61
64
|
display: "flex",
|
|
62
65
|
flexDirection: "column",
|
|
63
66
|
gap: 16,
|
|
64
|
-
maxWidth: 900,
|
|
65
|
-
margin: "0 auto",
|
|
66
67
|
color: tokens.text,
|
|
67
68
|
fontFamily: tokens.fontFamily,
|
|
68
69
|
...style,
|
|
69
70
|
}}
|
|
70
71
|
>
|
|
71
|
-
<h1
|
|
72
|
-
style={{
|
|
73
|
-
fontSize: 24,
|
|
74
|
-
fontWeight: 800,
|
|
75
|
-
fontFamily: tokens.headingFontFamily || tokens.fontFamily,
|
|
76
|
-
}}
|
|
77
|
-
>
|
|
78
|
-
{t("forge.film_library.heading")}
|
|
79
|
-
</h1>
|
|
80
|
-
|
|
81
72
|
{rentals.length === 0 && (
|
|
82
73
|
<div style={notice}>
|
|
83
74
|
<p style={{ marginBottom: 12 }}>{t("forge.film_library.empty")}</p>
|
|
@@ -107,6 +98,52 @@ export function FilmLibrary({ basePath = "/i/films", navigate, className, style
|
|
|
107
98
|
);
|
|
108
99
|
}
|
|
109
100
|
|
|
101
|
+
/**
|
|
102
|
+
* The fan's own rentals as a PAGE: what is running, what has not been started,
|
|
103
|
+
* and what has run out.
|
|
104
|
+
*
|
|
105
|
+
* Expired rentals are here deliberately. The library is where a fan re-rents,
|
|
106
|
+
* and a rental that vanishes on expiry reads as "you were robbed" rather than
|
|
107
|
+
* "that ended" - so an expired row keeps its title, states plainly that the
|
|
108
|
+
* window closed, and offers the way back.
|
|
109
|
+
*
|
|
110
|
+
* A row states one fact the reader must act on, so it stays visible rather than
|
|
111
|
+
* going behind a `?`: how long is left. There is no "what was bought" line any
|
|
112
|
+
* more, because a rental is always the whole title and the title is the row.
|
|
113
|
+
*/
|
|
114
|
+
export function FilmLibrary({ basePath, navigate, className, style }: FilmLibraryProps) {
|
|
115
|
+
const t = useForgeT();
|
|
116
|
+
const tokens = useThemeTokens();
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div
|
|
120
|
+
className={className}
|
|
121
|
+
style={{
|
|
122
|
+
display: "flex",
|
|
123
|
+
flexDirection: "column",
|
|
124
|
+
gap: 16,
|
|
125
|
+
maxWidth: 900,
|
|
126
|
+
margin: "0 auto",
|
|
127
|
+
color: tokens.text,
|
|
128
|
+
fontFamily: tokens.fontFamily,
|
|
129
|
+
...style,
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
<h1
|
|
133
|
+
style={{
|
|
134
|
+
fontSize: 24,
|
|
135
|
+
fontWeight: 800,
|
|
136
|
+
fontFamily: tokens.headingFontFamily || tokens.fontFamily,
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
{t("forge.film_library.heading")}
|
|
140
|
+
</h1>
|
|
141
|
+
|
|
142
|
+
<FilmRentalList basePath={basePath} navigate={navigate} />
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
110
147
|
/** Where the fan got to in the episode they should resume, or null. */
|
|
111
148
|
function resumeTarget(rental: MyFilmRental): { episodeId: string; entry: FilmWatchProgressEntry } | null {
|
|
112
149
|
// The furthest-along INCOMPLETE episode: resuming a finished one is how a
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useLoginFlow } from "../headless/auth/useLoginFlow";
|
|
2
2
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
3
3
|
import { useForgeT } from "../../i18n";
|
|
4
|
+
import { MembershipTierCallout } from "./MembershipTierCallout";
|
|
4
5
|
|
|
5
6
|
export interface LoginFormProps {
|
|
6
7
|
/** Called after a successful login. The shell typically redirects here. */
|
|
@@ -9,10 +10,17 @@ export interface LoginFormProps {
|
|
|
9
10
|
signupHref: string;
|
|
10
11
|
/** Href for the "Forgot password?" link. */
|
|
11
12
|
forgotHref: string;
|
|
13
|
+
/**
|
|
14
|
+
* The tier this visitor was on their way to join, shown above the form so
|
|
15
|
+
* they can see the sign-in wall is part of joining and which tier they picked.
|
|
16
|
+
* Hosts read it off the `redirect` they were sent here with. Left out, the
|
|
17
|
+
* form looks exactly as it did.
|
|
18
|
+
*/
|
|
19
|
+
membershipTierId?: string;
|
|
12
20
|
}
|
|
13
21
|
|
|
14
22
|
/** Themed 2FA login form (credentials → emailed code), built on `useLoginFlow`. */
|
|
15
|
-
export function LoginForm({ onSuccess, signupHref, forgotHref }: LoginFormProps) {
|
|
23
|
+
export function LoginForm({ onSuccess, signupHref, forgotHref, membershipTierId }: LoginFormProps) {
|
|
16
24
|
const t = useForgeT();
|
|
17
25
|
const theme = useForgeTheme();
|
|
18
26
|
const flow = useLoginFlow({ onSuccess });
|
|
@@ -60,6 +68,9 @@ export function LoginForm({ onSuccess, signupHref, forgotHref }: LoginFormProps)
|
|
|
60
68
|
if (flow.step === "code") {
|
|
61
69
|
return (
|
|
62
70
|
<div style={card}>
|
|
71
|
+
{/* Still on the way to the tier they picked, so it stays in view while
|
|
72
|
+
they fetch the code out of their inbox. */}
|
|
73
|
+
<MembershipTierCallout membershipTierId={membershipTierId} />
|
|
63
74
|
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8 }}>{t("forge.login_form.verify_title")}</h1>
|
|
64
75
|
<p style={{ fontSize: 14, opacity: 0.7, marginBottom: 24 }}>
|
|
65
76
|
{t("forge.login_form.code_sent_to")} <strong>{flow.email}</strong>
|
|
@@ -105,6 +116,9 @@ export function LoginForm({ onSuccess, signupHref, forgotHref }: LoginFormProps)
|
|
|
105
116
|
|
|
106
117
|
return (
|
|
107
118
|
<div style={card}>
|
|
119
|
+
{/* What the fan was doing when they hit this wall. Nothing is drawn when
|
|
120
|
+
they came here on their own. */}
|
|
121
|
+
<MembershipTierCallout membershipTierId={membershipTierId} />
|
|
108
122
|
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>{t("forge.login_form.title")}</h1>
|
|
109
123
|
{flow.error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{flow.error}</p>}
|
|
110
124
|
<form
|