@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 +1 -1
- package/src/i18n/de.json +4 -1
- package/src/i18n/en.json +4 -1
- package/src/ui/format/membershipPriceLabel.ts +29 -0
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +136 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +18 -4
- package/src/ui/headless/work/index.ts +4 -0
- package/src/ui/headless/work/useWorkPortal.ts +125 -10
- package/src/ui/index.ts +1 -0
- package/src/ui/styled/AccountDashboard.tsx +16 -17
- 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/membershipTierCallout.spec.tsx +126 -0
- package/src/ui/styled/work/WorkProjectDocuments.tsx +224 -0
- package/src/ui/styled/work/WorkProjectReport.tsx +5 -3
- package/src/ui/styled/work/index.ts +1 -0
package/package.json
CHANGED
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
|
-
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
102
|
-
*
|
|
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
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
|
215
|
-
*
|
|
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
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
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;
|
|
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.
|
|
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
|