@tribe-nest/forge 3.36.0 → 3.38.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.36.0",
3
+ "version": "3.38.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/i18n/de.json CHANGED
@@ -22,7 +22,6 @@
22
22
  "forge.account_dashboard.cancelled": "Storniert",
23
23
  "forge.account_dashboard.cancelling": "Wird storniert…",
24
24
  "forge.account_dashboard.close": "Schließen",
25
- "forge.account_dashboard.community_spaces": "Community-Bereiche",
26
25
  "forge.account_dashboard.confirm_password": "Neues Passwort bestätigen",
27
26
  "forge.account_dashboard.current_password": "Aktuelles Passwort",
28
27
  "forge.account_dashboard.data_export_description": "Exportiere eine Kopie deiner persönlichen Daten.",
@@ -37,6 +36,7 @@
37
36
  "forge.account_dashboard.last_name": "Nachname",
38
37
  "forge.account_dashboard.leave_waitlist": "Warteliste verlassen",
39
38
  "forge.account_dashboard.log_out": "Abmelden",
39
+ "forge.account_dashboard.member_area": "Mitgliederbereich",
40
40
  "forge.account_dashboard.membership_cancel_error": "Wir konnten deine Mitgliedschaft nicht kündigen. Bitte versuche es erneut oder kontaktiere uns.",
41
41
  "forge.account_dashboard.membership_change": "Ändern",
42
42
  "forge.account_dashboard.membership_empty": "Du hast keine aktive Mitgliedschaft.",
@@ -939,6 +939,9 @@
939
939
  "forge.membership_checkout.title": "{tier_name} beitreten",
940
940
  "forge.membership_checkout.total": "Gesamt",
941
941
  "forge.membership_checkout.view_memberships": "Mitgliedschaften ansehen",
942
+ "forge.membership_tier_callout.benefits_label": "Vorteile:",
943
+ "forge.membership_tier_callout.join_title": "{tier_name} beitreten",
944
+ "forge.membership_tier_callout.more_benefits": "+{count} weitere Vorteile",
942
945
  "forge.membership_tiers.empty_title": "Keine Mitgliedschaften verfügbar.",
943
946
  "forge.membership_tiers.heading": "Mitgliedschaft",
944
947
  "forge.membership_tiers.price_free": "Kostenlos",
package/src/i18n/en.json CHANGED
@@ -22,7 +22,6 @@
22
22
  "forge.account_dashboard.cancelled": "Cancelled",
23
23
  "forge.account_dashboard.cancelling": "Cancelling…",
24
24
  "forge.account_dashboard.close": "Close",
25
- "forge.account_dashboard.community_spaces": "Community spaces",
26
25
  "forge.account_dashboard.confirm_password": "Confirm new password",
27
26
  "forge.account_dashboard.current_password": "Current password",
28
27
  "forge.account_dashboard.data_export_description": "Export a copy of your personal data.",
@@ -37,6 +36,7 @@
37
36
  "forge.account_dashboard.last_name": "Last name",
38
37
  "forge.account_dashboard.leave_waitlist": "Leave waitlist",
39
38
  "forge.account_dashboard.log_out": "Log out",
39
+ "forge.account_dashboard.member_area": "Member Area",
40
40
  "forge.account_dashboard.membership_cancel_error": "We couldn't cancel your membership. Please try again or contact us.",
41
41
  "forge.account_dashboard.membership_change": "Change",
42
42
  "forge.account_dashboard.membership_empty": "You don't have an active membership.",
@@ -939,6 +939,9 @@
939
939
  "forge.membership_checkout.title": "Join {tier_name}",
940
940
  "forge.membership_checkout.total": "Total",
941
941
  "forge.membership_checkout.view_memberships": "View memberships",
942
+ "forge.membership_tier_callout.benefits_label": "Benefits:",
943
+ "forge.membership_tier_callout.join_title": "Join {tier_name}",
944
+ "forge.membership_tier_callout.more_benefits": "+{count} more benefits",
942
945
  "forge.membership_tiers.empty_title": "No membership tiers available.",
943
946
  "forge.membership_tiers.heading": "Membership",
944
947
  "forge.membership_tiers.price_free": "Free",
@@ -0,0 +1,29 @@
1
+ import type { ForgeT } from "../../i18n";
2
+ import type { MembershipTier } from "../../types/models";
3
+ import { cycleFloor, offeredCycles } from "./membershipPwyw";
4
+
5
+ /**
6
+ * The one sentence that prices a membership tier.
7
+ *
8
+ * Shared rather than copied, because the rule it encodes was a bug: a tier
9
+ * priced on ONE cycle has to be advertised on that cycle. Reading the monthly
10
+ * minimum unconditionally printed "Pay what you want from $0/mo" on a
11
+ * yearly-only tier, which reads as free and is the opposite of the truth. A
12
+ * second copy of this in the next surface that prices a tier would be the copy
13
+ * that misses the next fix.
14
+ *
15
+ * The keys stay under `forge.membership_tiers.*`: this is one sentence about one
16
+ * thing, and every surface that shows a tier price must say it the same way, so
17
+ * an operator editing the copy edits it once.
18
+ */
19
+ export function membershipPriceLabel(tier: MembershipTier, t: ForgeT, fmt: (amount: number) => string): string {
20
+ const cycles = offeredCycles(tier);
21
+ if (tier.payWhatYouWant) {
22
+ return cycles.month
23
+ ? t("forge.membership_tiers.price_pwyw", { amount: fmt(cycleFloor(tier, "month")) })
24
+ : t("forge.membership_tiers.price_pwyw_yearly", { amount: fmt(cycleFloor(tier, "year")) });
25
+ }
26
+ if (cycles.month) return t("forge.membership_tiers.price_monthly", { amount: fmt(cycleFloor(tier, "month")) });
27
+ if (cycles.year) return t("forge.membership_tiers.price_yearly", { amount: fmt(cycleFloor(tier, "year")) });
28
+ return t("forge.membership_tiers.price_free");
29
+ }
@@ -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
 
@@ -8,6 +8,9 @@ export {
8
8
  useWorkInvite,
9
9
  useAcceptWorkInvite,
10
10
  useWorkProjectInvoices,
11
+ useWorkSharedDocuments,
12
+ useWorkOpenSharedDocument,
13
+ useUploadWorkDocument,
11
14
  formatWorkMoney,
12
15
  formatWorkDate,
13
16
  type WorkProjectSummary,
@@ -21,5 +24,6 @@ export {
21
24
  type WorkInviteContext,
22
25
  type WorkInviteAcceptResult,
23
26
  type WorkProjectInvoice,
27
+ type WorkSharedDocument,
24
28
  type UseWorkTaskCommentsResult,
25
29
  } from "./useWorkPortal";
@@ -1,4 +1,5 @@
1
1
  import { useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query";
2
+ import axios from "axios";
2
3
  import { useForge } from "../../../provider/ForgeProvider";
3
4
 
4
5
  /**
@@ -89,6 +90,25 @@ export interface WorkTaskAttachment {
89
90
  size: number | null;
90
91
  }
91
92
 
93
+ /**
94
+ * A case-file document the firm has shared with the client, or one the client
95
+ * put there themselves. A file document describes itself but never carries an
96
+ * address: the bytes come only through `useWorkOpenSharedDocument`.
97
+ */
98
+ export interface WorkSharedDocument {
99
+ id: string;
100
+ title: string;
101
+ docType: "page" | "file";
102
+ updatedAt: string;
103
+ sharedAt: string;
104
+ file: {
105
+ mediaId: string;
106
+ filename: string | null;
107
+ size: number;
108
+ type: string;
109
+ } | null;
110
+ }
111
+
92
112
  export interface WorkProjectInvoice {
93
113
  id: string;
94
114
  invoiceNumber: string;
@@ -110,7 +130,7 @@ export interface WorkInviteContext {
110
130
  firstName: string | null;
111
131
  /**
112
132
  * The invited address already has an account, so the invite grants portal
113
- * access but cannot set a credential — render "sign in", not a password
133
+ * access but cannot set a credential: render "sign in", not a password
114
134
  * form (C10).
115
135
  */
116
136
  requiresExistingLogin?: boolean;
@@ -124,7 +144,7 @@ export interface WorkInviteAcceptResult {
124
144
  */
125
145
  requiresLogin?: boolean;
126
146
  token?: string;
127
- /** Rotating refresh token for the portal session (backend §7) — persist it. */
147
+ /** Rotating refresh token for the portal session (backend §7). Persist it. */
128
148
  refreshToken?: string;
129
149
  projectId: string;
130
150
  account: { id: string; email: string; firstName: string | null; lastName: string | null };
@@ -142,12 +162,14 @@ const keys = {
142
162
  ["work-portal-task-attachments", profileId, projectId, taskId] as const,
143
163
  projectInvoices: (profileId: string | undefined, projectId: string) =>
144
164
  ["work-portal-project-invoices", profileId, projectId] as const,
165
+ sharedDocuments: (profileId: string | undefined, projectId: string) =>
166
+ ["work-portal-shared-documents", profileId, projectId] as const,
145
167
  };
146
168
 
147
169
  // ── Authed client's projects ─────────────────────────────────────────────────
148
170
 
149
171
  /** The authenticated client's (non-archived) client projects with a task/done
150
- * summary — GET /public/work/projects. The token is the identity. */
172
+ * summary: GET /public/work/projects. The token is the identity. */
151
173
  export function useWorkPortalProjects(): UseQueryResult<WorkProjectSummary[]> {
152
174
  const { client, profileId } = useForge();
153
175
  return useQuery<WorkProjectSummary[]>({
@@ -163,7 +185,7 @@ export function useWorkPortalProjects(): UseQueryResult<WorkProjectSummary[]> {
163
185
  // ── Authed client's single-project report ────────────────────────────────────
164
186
 
165
187
  /** The authenticated client's overview + billable-only time report for one of
166
- * their projects — GET /public/work/projects/:id/report. */
188
+ * their projects: GET /public/work/projects/:id/report. */
167
189
  export function useWorkProjectReport(projectId: string): UseQueryResult<WorkProjectReportData> {
168
190
  const { client, profileId } = useForge();
169
191
  return useQuery<WorkProjectReportData>({
@@ -180,7 +202,7 @@ export function useWorkProjectReport(projectId: string): UseQueryResult<WorkProj
180
202
 
181
203
  // ── Public tokenized report (no login) ───────────────────────────────────────
182
204
 
183
- /** Login-free tokenized share of a project's time report — GET
205
+ /** Login-free tokenized share of a project's time report: GET
184
206
  * /public/work/time-report/:token. The signed token IS the authorization. */
185
207
  export function useWorkTokenReport(token: string): UseQueryResult<WorkProjectReportData> {
186
208
  const { client } = useForge();
@@ -208,7 +230,7 @@ export interface UseWorkTaskCommentsResult {
208
230
 
209
231
  /**
210
232
  * The authenticated client's comment thread for a single task on one of their
211
- * projects — GET/POST /public/work/projects/:projectId/tasks/:taskId/comments.
233
+ * projects: GET/POST /public/work/projects/:projectId/tasks/:taskId/comments.
212
234
  * Newest first. The bearer token IS the client identity (server-enforced).
213
235
  * Posting invalidates the thread so the new comment appears. Tier-1 primitive:
214
236
  * returns clean data + a bound mutation, renders nothing.
@@ -259,7 +281,7 @@ export function useWorkTaskComments(projectId: string, taskId: string): UseWorkT
259
281
 
260
282
  /**
261
283
  * Read-only attachments the team has added to a task on one of the client's
262
- * projects — GET /public/work/projects/:projectId/tasks/:taskId/attachments.
284
+ * projects: GET /public/work/projects/:projectId/tasks/:taskId/attachments.
263
285
  * Tier-1 primitive: returns clean data, renders nothing.
264
286
  */
265
287
  export function useWorkTaskAttachments(projectId: string, taskId: string): UseQueryResult<WorkTaskAttachment[]> {
@@ -279,7 +301,7 @@ export function useWorkTaskAttachments(projectId: string, taskId: string): UseQu
279
301
  // ── Authed client's invoices for a project ───────────────────────────────────
280
302
 
281
303
  /**
282
- * The client's (non-draft) invoices for one of their projects —
304
+ * The client's (non-draft) invoices for one of their projects:
283
305
  * GET /public/work/projects/:id/invoices. Each carries a `payable` flag the
284
306
  * portal turns into a Pay button (links to /i/invoice/:id). Tier-1 primitive.
285
307
  */
@@ -295,11 +317,104 @@ export function useWorkProjectInvoices(projectId: string): UseQueryResult<WorkPr
295
317
  });
296
318
  }
297
319
 
320
+ // ── Authed client's case-file documents ──────────────────────────────────────
321
+
322
+ /**
323
+ * The documents on one of the client's projects that they may see: what the
324
+ * firm shared with them, and what they uploaded themselves:
325
+ * GET /public/work/projects/:id/documents. Tier-1 primitive.
326
+ */
327
+ export function useWorkSharedDocuments(projectId: string): UseQueryResult<WorkSharedDocument[]> {
328
+ const { client, profileId } = useForge();
329
+ return useQuery<WorkSharedDocument[]>({
330
+ queryKey: keys.sharedDocuments(profileId, projectId),
331
+ queryFn: async () => {
332
+ const res = await client.get(`/public/work/projects/${projectId}/documents`, { params: { profileId } });
333
+ return res.data as WorkSharedDocument[];
334
+ },
335
+ enabled: !!profileId && !!client && !!projectId,
336
+ });
337
+ }
338
+
339
+ /**
340
+ * A sixty-second, single-use GET at one shared file's bytes:
341
+ * GET /public/work/projects/:id/documents/:documentId/content. Meant to be
342
+ * followed once, by the click that asked for it; never cache or render the URL.
343
+ */
344
+ export function useWorkOpenSharedDocument(projectId: string) {
345
+ const { client, profileId } = useForge();
346
+ return useMutation<{ url: string }, unknown, string>({
347
+ mutationFn: async (documentId: string) => {
348
+ const res = await client.get(`/public/work/projects/${projectId}/documents/${documentId}/content`, {
349
+ params: { profileId },
350
+ });
351
+ return res.data as { url: string };
352
+ },
353
+ });
354
+ }
355
+
356
+ const mediaTypeFor = (file: File): "image" | "video" | "audio" | "document" | "other" => {
357
+ const mime = file.type || "";
358
+ if (mime.startsWith("image/")) return "image";
359
+ if (mime.startsWith("video/")) return "video";
360
+ if (mime.startsWith("audio/")) return "audio";
361
+ if (mime.startsWith("text/") || mime.includes("pdf") || mime.includes("word") || mime.includes("document")) {
362
+ return "document";
363
+ }
364
+ return "other";
365
+ };
366
+
367
+ /**
368
+ * Put a file into the client's matter, in three steps that must stay in this
369
+ * order:
370
+ *
371
+ * 1. sign a PUT into the firm's PRIVATE bucket
372
+ * (POST /public/work/projects/:id/documents/upload-url);
373
+ * 2. send the bytes straight to object storage, with the Content-Type the
374
+ * server SIGNED (derived from the extension allowlist, not the browser);
375
+ * 3. register the document (POST /public/work/projects/:id/documents), which
376
+ * writes the row, shares it back to the client and queues extraction.
377
+ *
378
+ * A failure at any step is a failure of the whole upload. On success the
379
+ * shared-documents list is refetched so the new file appears at once.
380
+ */
381
+ export function useUploadWorkDocument(projectId: string) {
382
+ const { client, profileId } = useForge();
383
+ const queryClient = useQueryClient();
384
+ return useMutation<WorkSharedDocument, unknown, { file: File; title?: string }>({
385
+ mutationFn: async ({ file, title }) => {
386
+ const { data: signed } = await client.post<{
387
+ presignedUrl: string;
388
+ key: string;
389
+ requiredHeaders?: Record<string, string>;
390
+ }>(`/public/work/projects/${projectId}/documents/upload-url`, { fileName: file.name }, { params: { profileId } });
391
+ await axios.put(signed.presignedUrl, file, {
392
+ headers: signed.requiredHeaders ?? { "Content-Type": file.type || "application/octet-stream" },
393
+ });
394
+ const { data } = await client.post(
395
+ `/public/work/projects/${projectId}/documents`,
396
+ {
397
+ storageKey: signed.key,
398
+ fileName: file.name,
399
+ fileSize: file.size,
400
+ mediaType: mediaTypeFor(file),
401
+ ...(title ? { title } : {}),
402
+ },
403
+ { params: { profileId } },
404
+ );
405
+ return data as WorkSharedDocument;
406
+ },
407
+ onSuccess: () => {
408
+ void queryClient.invalidateQueries({ queryKey: keys.sharedDocuments(profileId, projectId) });
409
+ },
410
+ });
411
+ }
412
+
298
413
  // ── Client invite accept (public, no auth) ───────────────────────────────────
299
414
 
300
415
  /**
301
416
  * Fetch the branded context for an invite link (GET /public/work/invite).
302
- * Public — no bearer token needed; the link's token IS the credential.
417
+ * Public: no bearer token needed; the link's token IS the credential.
303
418
  * `null`/errored when the token or project is missing/invalid/expired.
304
419
  */
305
420
  export function useWorkInvite(
@@ -321,7 +436,7 @@ export function useWorkInvite(
321
436
  /**
322
437
  * Accept an invite (POST /public/work/invite/accept): set a password and
323
438
  * receive a fresh portal session token. The caller persists the token +
324
- * navigates. Public — no bearer token needed.
439
+ * navigates. Public: no bearer token needed.
325
440
  */
326
441
  export function useAcceptWorkInvite() {
327
442
  const { client } = useForge();
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";
@@ -22,7 +22,6 @@ import {
22
22
  useCancelMembership,
23
23
  useMembershipAccess,
24
24
  useOpenBillingPortal,
25
- useCommunitySpaces,
26
25
  useUpdateAccount,
27
26
  useChangePassword,
28
27
  useExportAccountData,
@@ -98,14 +97,14 @@ export interface AccountDashboardProps {
98
97
  */
99
98
  onNavigateMembership?: () => void;
100
99
  /**
101
- * Navigate to the community spaces, the member home's Spaces tab (host maps
102
- * 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`).
103
102
  *
104
- * Optional, and drawn only when the signed-in member has at least one space
105
- * they can actually open: `/public/community/spaces` returns open spaces plus
106
- * the spaces gated to a tier they hold, so an empty list is the server saying
107
- * there is no door here. Without that check the account page would offer a
108
- * 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.
109
108
  */
110
109
  onNavigateCommunity?: () => void;
111
110
  /**
@@ -211,8 +210,8 @@ export function AccountDashboard({
211
210
  const { data: siteConfig } = useSiteConfig();
212
211
  /**
213
212
  * Does this fan hold any rental at all? That is the whole gate on the Rentals
214
- * tab, on the same principle as the community link above: the server's own
215
- * answer decides, and a surface with nothing behind it is not drawn.
213
+ * tab: the server's own answer decides, and a surface with nothing behind it
214
+ * is not drawn.
216
215
  *
217
216
  * Film rentals are off by default and per profile, so on most sites this read
218
217
  * comes back empty for ever and the tab never appears. It is the SAME query
@@ -305,12 +304,12 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
305
304
  const tr = useForgeT();
306
305
  const { formatAmount, formatDate, currency, onNavigateMembership, onNavigateCommunity } = ctx;
307
306
  const cancelMembership = useCancelMembership();
308
- // The way into the community, which the account page had no link to at all.
309
- // The list is already access-filtered by the server (open spaces plus the
310
- // ones gated to a tier this member holds), so its emptiness is the whole
311
- // gate: no spaces, no link, and nobody is sent at a locked door.
312
- const { data: communitySpaces } = useCommunitySpaces();
313
- 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;
314
313
  const [isCancelling, setIsCancelling] = useState(false);
315
314
  /**
316
315
  * Cancelling is destructive and irreversible from here, so it asks first.
@@ -426,7 +425,7 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
426
425
  what the membership is FOR, not another thing to do to the plan. */}
427
426
  {canOpenCommunity && (
428
427
  <button onClick={() => onNavigateCommunity?.()} style={{ ...ghostButton, marginRight: "auto" }}>
429
- {tr("forge.account_dashboard.community_spaces")}
428
+ {tr("forge.account_dashboard.member_area")}
430
429
  </button>
431
430
  )}
432
431
  {message.showUpdatePayment && (
@@ -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