@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.
@@ -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
- const finish = (requiresConfirmation: boolean) => {
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
- export { FilmLibrary, type FilmLibraryProps } from "./styled/FilmLibrary";
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 community spaces, the member home's Spaces tab (host maps
92
- * this to its router, e.g. `/i/members/spaces`).
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 only when the signed-in member has at least one space
95
- * they can actually open: `/public/community/spaces` returns open spaces plus
96
- * the spaces gated to a tier they hold, so an empty list is the server saying
97
- * there is no door here. Without that check the account page would offer a
98
- * member a room they get bounced out of, which is worse than no link at all.
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
- {ACCOUNT_TABS.map((tabKey) => (
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 community, which the account page had no link to at all.
266
- // The list is already access-filtered by the server (open spaces plus the
267
- // ones gated to a tier this member holds), so its emptiness is the whole
268
- // gate: no spaces, no link, and nobody is sent at a locked door.
269
- const { data: communitySpaces } = useCommunitySpaces();
270
- const canOpenCommunity = !!onNavigateCommunity && (communitySpaces?.length ?? 0) > 0;
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.community_spaces")}
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 FilmLibraryProps {
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 fan's own rentals: what is running, what has not been started, and what
25
- * has run out.
27
+ * The rentals themselves, with no page furniture around them.
26
28
  *
27
- * Expired rentals are here deliberately. The library is where a fan re-rents,
28
- * and a rental that vanishes on expiry reads as "you were robbed" rather than
29
- * "that ended" - so an expired row keeps its title, states plainly that the
30
- * window closed, and offers the way back.
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
- * A row states one fact the reader must act on, so it stays visible rather than
33
- * going behind a `?`: how long is left. There is no "what was bought" line any
34
- * more, because a rental is always the whole title and the title is the row.
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 FilmLibrary({ basePath = "/i/films", navigate, className, style }: FilmLibraryProps) {
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 fullPage />;
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