@tribe-nest/forge 3.60.0 → 3.61.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 +2 -1
- package/src/contexts/CartContext.tsx +34 -1
- package/src/contexts/PublicAuthContext.tsx +15 -3
- package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
- package/src/data/queries/useAccount.ts +33 -2
- package/src/data/queries/useAnalytics.ts +6 -6
- package/src/data/queries/useCallChat.ts +2 -2
- package/src/data/queries/useChat.ts +10 -1
- package/src/data/queries/useCheckouts.ts +2 -1
- package/src/data/queries/useCoachingAvailability.ts +10 -1
- package/src/data/queries/useCommunities.ts +2 -0
- package/src/data/queries/useCommunity.ts +362 -18
- package/src/data/queries/useCommunityAgent.ts +247 -0
- package/src/data/queries/useCommunityCalendar.ts +150 -0
- package/src/data/queries/useCommunityClassroom.ts +75 -0
- package/src/data/queries/useCommunityMembers.ts +108 -0
- package/src/data/queries/useCommunityModeration.ts +327 -0
- package/src/data/queries/useCommunityWiki.ts +137 -0
- package/src/data/queries/useCourses.ts +2 -0
- package/src/data/queries/useEmailSignIn.ts +79 -0
- package/src/data/queries/useEvents.ts +6 -1
- package/src/data/queries/useForms.ts +5 -1
- package/src/data/queries/useLeadMagnet.ts +6 -2
- package/src/data/queries/useMyBookings.ts +24 -1
- package/src/data/queries/useMyTickets.ts +24 -1
- package/src/data/queries/useNotifications.ts +6 -2
- package/src/data/queries/useOrders.ts +2 -1
- package/src/data/queries/usePWA.ts +111 -35
- package/src/data/queries/useSiteApp.ts +130 -0
- package/src/data/queries/useSubscriptions.ts +6 -1
- package/src/i18n/de.json +746 -1
- package/src/i18n/en.json +746 -1
- package/src/index.ts +17 -0
- package/src/runtime/pages.spec.ts +5 -0
- package/src/runtime/pages.ts +32 -6
- package/src/types/models.ts +11 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
- package/src/ui/analytics/PageMetaPixel.tsx +23 -8
- package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
- package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
- package/src/ui/analytics/useMetaEvents.ts +78 -0
- package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
- package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
- package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
- package/src/ui/headless/_tests/communityHarness.tsx +10 -3
- package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
- package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
- package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
- package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
- package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
- package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
- package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
- package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
- package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
- package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
- package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
- package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
- package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
- package/src/ui/headless/auth/useSignupForm.ts +20 -1
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
- package/src/ui/headless/chat/useChatRoom.ts +35 -1
- package/src/ui/headless/communities/index.ts +41 -0
- package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
- package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
- package/src/ui/headless/communities/useCommunityHome.ts +3 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
- package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
- package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
- package/src/ui/headless/community/communityPoll.ts +297 -0
- package/src/ui/headless/community/countries.ts +123 -0
- package/src/ui/headless/community/index.ts +81 -1
- package/src/ui/headless/community/linkPreview.ts +71 -0
- package/src/ui/headless/community/richLexical.ts +27 -2
- package/src/ui/headless/community/useCommunityAgent.ts +285 -0
- package/src/ui/headless/community/useCommunityMembers.ts +175 -0
- package/src/ui/headless/community/useCommunityModeration.ts +300 -0
- package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
- package/src/ui/headless/community/useCommunityThread.ts +16 -3
- package/src/ui/headless/community/useMentionComposer.ts +17 -9
- package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
- package/src/ui/headless/downloads/index.ts +8 -0
- package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
- package/src/ui/headless/index.ts +13 -0
- package/src/ui/headless/memberHome/index.ts +10 -0
- package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
- package/src/ui/headless/portal/index.ts +57 -0
- package/src/ui/headless/portal/usePortal.ts +274 -0
- package/src/ui/headless/portal/usePortalFiles.ts +68 -0
- package/src/ui/headless/portal/usePortalMessages.ts +189 -0
- package/src/ui/headless/portal/usePortalPreview.ts +142 -0
- package/src/ui/headless/siteApp/appBar.ts +102 -0
- package/src/ui/headless/siteApp/appHome.ts +344 -0
- package/src/ui/headless/siteApp/appPrompts.ts +229 -0
- package/src/ui/headless/siteApp/index.ts +58 -0
- package/src/ui/headless/siteApp/useAppHome.ts +210 -0
- package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
- package/src/ui/headless/work/index.ts +2 -0
- package/src/ui/headless/work/useWorkPortal.ts +37 -26
- package/src/ui/index.ts +11 -0
- package/src/ui/styled/AccountDashboard.tsx +712 -352
- package/src/ui/styled/AiAgentWidget.tsx +13 -1
- package/src/ui/styled/ChatRoom.tsx +126 -16
- package/src/ui/styled/Checkout.tsx +8 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingDetail.tsx +2 -0
- package/src/ui/styled/CourseConfirmation.tsx +5 -0
- package/src/ui/styled/CourseDetail.tsx +2 -0
- package/src/ui/styled/DownloadPage.tsx +263 -0
- package/src/ui/styled/EventConfirmation.tsx +10 -0
- package/src/ui/styled/EventDetail.tsx +10 -0
- package/src/ui/styled/FilmLibrary.tsx +22 -9
- package/src/ui/styled/MusicLinkPage.tsx +5 -5
- package/src/ui/styled/ProductDetail.tsx +12 -0
- package/src/ui/styled/PwaRegistration.tsx +5 -1
- package/src/ui/styled/SignupForm.tsx +18 -3
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
- package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
- package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
- package/src/ui/styled/app/AppBar.tsx +298 -0
- package/src/ui/styled/app/AppHome.tsx +1139 -0
- package/src/ui/styled/app/AppHomeMark.tsx +66 -0
- package/src/ui/styled/app/AppPrompts.tsx +464 -0
- package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
- package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
- package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
- package/src/ui/styled/app/index.ts +7 -0
- package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
- package/src/ui/styled/chat/MessageBubble.tsx +43 -6
- package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
- package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
- package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
- package/src/ui/styled/communities/CommunityHome.tsx +134 -11
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
- package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
- package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
- package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
- package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
- package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
- package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
- package/src/ui/styled/communities/index.ts +9 -0
- package/src/ui/styled/communities/shared.tsx +39 -11
- package/src/ui/styled/community/CommunityAgent.tsx +286 -0
- package/src/ui/styled/community/CommunityComposer.tsx +266 -15
- package/src/ui/styled/community/CommunityFeed.tsx +50 -1
- package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
- package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
- package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
- package/src/ui/styled/community/CommunityMembers.tsx +387 -0
- package/src/ui/styled/community/CommunityModeration.tsx +587 -0
- package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
- package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
- package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
- package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
- package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
- package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
- package/src/ui/styled/community/CommunityRichText.tsx +177 -32
- package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
- package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
- package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
- package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
- package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
- package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
- package/src/ui/styled/community/index.ts +37 -2
- package/src/ui/styled/forge-utilities.css +234 -0
- package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
- package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
- package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
- package/src/ui/styled/members/index.ts +8 -0
- package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
- package/src/ui/styled/portal/PortalActivity.tsx +167 -0
- package/src/ui/styled/portal/PortalBilling.tsx +197 -0
- package/src/ui/styled/portal/PortalFiles.tsx +279 -0
- package/src/ui/styled/portal/PortalHome.tsx +401 -0
- package/src/ui/styled/portal/PortalMessages.tsx +300 -0
- package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
- package/src/ui/styled/portal/PortalProjects.tsx +407 -0
- package/src/ui/styled/portal/PortalShell.tsx +397 -0
- package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
- package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
- package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
- package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
- package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
- package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
- package/src/ui/styled/portal/index.ts +13 -0
- package/src/ui/styled/portal/portalNav.ts +260 -0
- package/src/ui/styled/portal/shared.tsx +206 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
- package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
- package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
- package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
- package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
- package/src/ui/styled/work/WorkReportBody.tsx +223 -248
- package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
- package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
- package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
- package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
- package/src/ui/styled/work/index.ts +1 -1
- package/src/utils/_tests/metaEvents.spec.ts +264 -0
- package/src/utils/landing.ts +19 -6
- package/src/utils/metaEvents.ts +302 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { useInfiniteQuery, useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query";
|
|
2
|
+
import { useForge } from "../../../provider/ForgeProvider";
|
|
3
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Headless data layer for the client portal (`/i/account/*`).
|
|
7
|
+
*
|
|
8
|
+
* Speaks `/public/portal/*`, which answers for the signed-in account on this
|
|
9
|
+
* profile only: every list is "mine", and nothing here takes an account id. The
|
|
10
|
+
* styled portal is built on exactly these, so a creator can build a different
|
|
11
|
+
* portal on the same data.
|
|
12
|
+
*
|
|
13
|
+
* Contract: docs/proposals/unified-client-portal.md, "Backend contract".
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ── Types (mirror PortalService responses) ───────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export type PortalSectionKey =
|
|
19
|
+
| "projects"
|
|
20
|
+
| "invoices"
|
|
21
|
+
| "documents"
|
|
22
|
+
| "bookings"
|
|
23
|
+
| "memberships"
|
|
24
|
+
| "courses"
|
|
25
|
+
| "films"
|
|
26
|
+
| "orders"
|
|
27
|
+
| "tickets"
|
|
28
|
+
| "waitlist"
|
|
29
|
+
| "saved"
|
|
30
|
+
/** 1 when the person may message the creator, else 0. */
|
|
31
|
+
| "messages"
|
|
32
|
+
| "files";
|
|
33
|
+
|
|
34
|
+
export type PortalAttentionItem =
|
|
35
|
+
| {
|
|
36
|
+
kind: "invoice_due" | "invoice_overdue";
|
|
37
|
+
id: string;
|
|
38
|
+
invoiceNumber: string;
|
|
39
|
+
total: string;
|
|
40
|
+
currency: string;
|
|
41
|
+
dueDate: string | null;
|
|
42
|
+
projectId: string | null;
|
|
43
|
+
projectName: string | null;
|
|
44
|
+
}
|
|
45
|
+
| { kind: "document_to_sign"; id: string; title: string; token: string; sentAt: string; expiresAt: string | null }
|
|
46
|
+
| {
|
|
47
|
+
kind: "approval_requested";
|
|
48
|
+
id: string;
|
|
49
|
+
milestoneId: string;
|
|
50
|
+
milestoneName: string;
|
|
51
|
+
projectId: string;
|
|
52
|
+
projectName: string;
|
|
53
|
+
requestedAt: string;
|
|
54
|
+
note: string | null;
|
|
55
|
+
}
|
|
56
|
+
| { kind: "membership_payment_failed"; id: string; communityName: string; communitySlug: string }
|
|
57
|
+
| {
|
|
58
|
+
kind: "message_unread";
|
|
59
|
+
/** The conversation's id. */
|
|
60
|
+
id: string;
|
|
61
|
+
unreadCount: number;
|
|
62
|
+
lastMessageAt: string;
|
|
63
|
+
preview: string | null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export interface PortalSummary {
|
|
67
|
+
sections: Record<PortalSectionKey, number>;
|
|
68
|
+
/** The newest thing that happened in each group. Orders the navigation. */
|
|
69
|
+
lastActivity: { work: string | null; library: string | null };
|
|
70
|
+
/** Already ranked by the server: overdue first, then approvals, documents, due. */
|
|
71
|
+
attention: PortalAttentionItem[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface PortalInvoice {
|
|
75
|
+
id: string;
|
|
76
|
+
invoiceNumber: string;
|
|
77
|
+
status: string;
|
|
78
|
+
total: string;
|
|
79
|
+
currency: string;
|
|
80
|
+
issueDate: string;
|
|
81
|
+
dueDate: string | null;
|
|
82
|
+
paidAt: string | null;
|
|
83
|
+
projectId: string | null;
|
|
84
|
+
projectName: string | null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface PortalDocument {
|
|
88
|
+
documentId: string;
|
|
89
|
+
title: string;
|
|
90
|
+
kind: string;
|
|
91
|
+
documentStatus: string;
|
|
92
|
+
recipientStatus: string;
|
|
93
|
+
needsAction: boolean;
|
|
94
|
+
/** The recipient's own access token: the key to `/i/documents/$token`. */
|
|
95
|
+
token: string;
|
|
96
|
+
sentAt: string;
|
|
97
|
+
completedAt: string | null;
|
|
98
|
+
expiresAt: string | null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type PortalActivityKind =
|
|
102
|
+
| "invoice_issued"
|
|
103
|
+
| "invoice_paid"
|
|
104
|
+
| "document_sent"
|
|
105
|
+
| "document_signed"
|
|
106
|
+
| "milestone_completed"
|
|
107
|
+
| "comment_added"
|
|
108
|
+
| "approval_requested"
|
|
109
|
+
| "approval_decided"
|
|
110
|
+
| "order_placed"
|
|
111
|
+
| "booking_confirmed"
|
|
112
|
+
| "message_received";
|
|
113
|
+
|
|
114
|
+
export interface PortalActivityItem {
|
|
115
|
+
id: string;
|
|
116
|
+
kind: PortalActivityKind;
|
|
117
|
+
occurredAt: string;
|
|
118
|
+
title: string;
|
|
119
|
+
projectId: string | null;
|
|
120
|
+
projectName: string | null;
|
|
121
|
+
entityId: string;
|
|
122
|
+
detail: string | null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface PortalActivityPage {
|
|
126
|
+
items: PortalActivityItem[];
|
|
127
|
+
nextBefore: string | null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type ApprovalStatus = "pending" | "approved" | "changes_requested" | "cancelled";
|
|
131
|
+
|
|
132
|
+
export interface WorkApprovalRequest {
|
|
133
|
+
id: string;
|
|
134
|
+
milestoneId: string;
|
|
135
|
+
milestoneName: string;
|
|
136
|
+
status: ApprovalStatus;
|
|
137
|
+
note: string | null;
|
|
138
|
+
requestedAt: string;
|
|
139
|
+
decidedAt: string | null;
|
|
140
|
+
decisionComment: string | null;
|
|
141
|
+
decidedByName: string | null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export type ApprovalDecision = "approve" | "request_changes";
|
|
145
|
+
|
|
146
|
+
// ── Keys ─────────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
const keys = {
|
|
149
|
+
summary: (profileId?: string, accountId?: string) => ["portal-summary", profileId, accountId] as const,
|
|
150
|
+
invoices: (profileId?: string, accountId?: string, status?: string) =>
|
|
151
|
+
["portal-invoices", profileId, accountId, status] as const,
|
|
152
|
+
documents: (profileId?: string, accountId?: string) => ["portal-documents", profileId, accountId] as const,
|
|
153
|
+
activity: (profileId?: string, accountId?: string, limit?: number) =>
|
|
154
|
+
["portal-activity", profileId, accountId, limit] as const,
|
|
155
|
+
approvals: (profileId?: string, projectId?: string) => ["work-approvals", profileId, projectId] as const,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// ── Hooks ────────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
/** What needs the person, and which sections they have anything in. */
|
|
161
|
+
export function usePortalSummary(): UseQueryResult<PortalSummary> {
|
|
162
|
+
const { client, profileId } = useForge();
|
|
163
|
+
const { user } = usePublicAuth();
|
|
164
|
+
return useQuery({
|
|
165
|
+
queryKey: keys.summary(profileId, user?.id),
|
|
166
|
+
queryFn: async () => {
|
|
167
|
+
const res = await client.get("/public/portal/summary", { params: { profileId } });
|
|
168
|
+
return res.data as PortalSummary;
|
|
169
|
+
},
|
|
170
|
+
enabled: !!profileId && !!client && !!user,
|
|
171
|
+
// The rail and the bottom bar read this on every portal page; a person
|
|
172
|
+
// moving between sections should not watch the navigation reload.
|
|
173
|
+
staleTime: 30_000,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Every invoice the creator has sent this person, newest first. */
|
|
178
|
+
export function usePortalInvoices(status: "open" | "paid" | "all" = "all", pageSize = 50) {
|
|
179
|
+
const { client, profileId } = useForge();
|
|
180
|
+
const { user } = usePublicAuth();
|
|
181
|
+
return useQuery({
|
|
182
|
+
queryKey: keys.invoices(profileId, user?.id, `${status}:${pageSize}`),
|
|
183
|
+
queryFn: async () => {
|
|
184
|
+
const res = await client.get("/public/portal/invoices", { params: { profileId, status, page: 1, pageSize } });
|
|
185
|
+
return res.data as { data: PortalInvoice[]; total: number; page: number; pageSize: number };
|
|
186
|
+
},
|
|
187
|
+
enabled: !!profileId && !!client && !!user,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Documents sent to this person for signature or acceptance. */
|
|
192
|
+
export function usePortalDocuments(): UseQueryResult<PortalDocument[]> {
|
|
193
|
+
const { client, profileId } = useForge();
|
|
194
|
+
const { user } = usePublicAuth();
|
|
195
|
+
return useQuery({
|
|
196
|
+
queryKey: keys.documents(profileId, user?.id),
|
|
197
|
+
queryFn: async () => {
|
|
198
|
+
const res = await client.get("/public/portal/documents", { params: { profileId } });
|
|
199
|
+
return (res.data as { data: PortalDocument[] }).data;
|
|
200
|
+
},
|
|
201
|
+
enabled: !!profileId && !!client && !!user,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The activity timeline, newest first, one page at a time. */
|
|
206
|
+
export function usePortalActivity(limit = 20) {
|
|
207
|
+
const { client, profileId } = useForge();
|
|
208
|
+
const { user } = usePublicAuth();
|
|
209
|
+
return useInfiniteQuery({
|
|
210
|
+
queryKey: keys.activity(profileId, user?.id, limit),
|
|
211
|
+
initialPageParam: undefined as string | undefined,
|
|
212
|
+
queryFn: async ({ pageParam }) => {
|
|
213
|
+
const res = await client.get("/public/portal/activity", {
|
|
214
|
+
params: { profileId, limit, ...(pageParam ? { before: pageParam } : {}) },
|
|
215
|
+
});
|
|
216
|
+
return res.data as PortalActivityPage;
|
|
217
|
+
},
|
|
218
|
+
getNextPageParam: (last) => last.nextBefore ?? undefined,
|
|
219
|
+
enabled: !!profileId && !!client && !!user,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface WorkApprovalRequests {
|
|
224
|
+
/** Newest first, every status: the project's approval history. */
|
|
225
|
+
requests: WorkApprovalRequest[];
|
|
226
|
+
/**
|
|
227
|
+
* Whether THIS client may decide. A view-only seat reads the same history but
|
|
228
|
+
* must not be offered Approve, which the decision endpoint would refuse.
|
|
229
|
+
*/
|
|
230
|
+
canApprove: boolean;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Every approval the creator has asked of this client on one project, and whether they may decide. */
|
|
234
|
+
export function useWorkApprovalRequests(projectId: string): UseQueryResult<WorkApprovalRequests> {
|
|
235
|
+
const { client, profileId } = useForge();
|
|
236
|
+
return useQuery({
|
|
237
|
+
queryKey: keys.approvals(profileId, projectId),
|
|
238
|
+
queryFn: async () => {
|
|
239
|
+
const res = await client.get(`/public/work/projects/${projectId}/approval-requests`, { params: { profileId } });
|
|
240
|
+
const body = res.data as { data: WorkApprovalRequest[]; canApprove?: boolean };
|
|
241
|
+
// An API build older than the flag decided nothing here; treat it as
|
|
242
|
+
// "may approve", which is exactly how the page behaved before.
|
|
243
|
+
return { requests: body.data, canApprove: body.canApprove ?? true };
|
|
244
|
+
},
|
|
245
|
+
enabled: !!profileId && !!client && !!projectId,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Approve a milestone, or send it back with a comment.
|
|
251
|
+
*
|
|
252
|
+
* Approving completes the milestone, so the project report, the summary and the
|
|
253
|
+
* activity timeline all change with it, and all three are refetched.
|
|
254
|
+
*/
|
|
255
|
+
export function useDecideApproval(projectId: string) {
|
|
256
|
+
const { client, profileId } = useForge();
|
|
257
|
+
const queryClient = useQueryClient();
|
|
258
|
+
return useMutation({
|
|
259
|
+
mutationFn: async (input: { requestId: string; decision: ApprovalDecision; comment?: string }) => {
|
|
260
|
+
const res = await client.post(
|
|
261
|
+
`/public/work/projects/${projectId}/approval-requests/${input.requestId}/decision`,
|
|
262
|
+
{ decision: input.decision, comment: input.comment },
|
|
263
|
+
{ params: { profileId } },
|
|
264
|
+
);
|
|
265
|
+
return res.data as WorkApprovalRequest;
|
|
266
|
+
},
|
|
267
|
+
onSuccess: () => {
|
|
268
|
+
void queryClient.invalidateQueries({ queryKey: keys.approvals(profileId, projectId) });
|
|
269
|
+
void queryClient.invalidateQueries({ queryKey: ["portal-summary"] });
|
|
270
|
+
void queryClient.invalidateQueries({ queryKey: ["portal-activity"] });
|
|
271
|
+
void queryClient.invalidateQueries({ queryKey: ["work-portal-report", profileId, projectId] });
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
2
|
+
import { useForge } from "../../../provider/ForgeProvider";
|
|
3
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every file this person has with the creator (`GET /public/portal/files`):
|
|
7
|
+
* project files shared with them, attachments on their project tasks, signed
|
|
8
|
+
* documents, invoice PDFs and their paid downloads, newest first across all.
|
|
9
|
+
*
|
|
10
|
+
* A row never carries a signed address. `open` says which endpoint serves it,
|
|
11
|
+
* and that endpoint checks ownership again on the click.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type PortalFileSource = "project" | "task" | "document" | "invoice" | "purchase";
|
|
15
|
+
export type PortalFileSourceFilter = PortalFileSource | "all";
|
|
16
|
+
|
|
17
|
+
export type PortalFileOpen =
|
|
18
|
+
/** Fetch a 60-second URL through `useWorkOpenSharedDocument(projectId)`. */
|
|
19
|
+
| { kind: "project_document"; projectId: string; documentId: string }
|
|
20
|
+
/** A public media address, safe to link directly. */
|
|
21
|
+
| { kind: "task_attachment"; projectId: string; taskId: string; url: string }
|
|
22
|
+
/** The recipient token: `GET /public/documents/:token/download`. */
|
|
23
|
+
| { kind: "signed_document"; token: string }
|
|
24
|
+
/** `GET /public/invoices/:id/pdf`. */
|
|
25
|
+
| { kind: "invoice_pdf"; invoiceId: string }
|
|
26
|
+
/** A paid download: the download page `/i/downloads/<token>`, which lets the owner straight through. */
|
|
27
|
+
| { kind: "purchase_download"; token: string };
|
|
28
|
+
|
|
29
|
+
export interface PortalFile {
|
|
30
|
+
id: string;
|
|
31
|
+
source: PortalFileSource;
|
|
32
|
+
title: string;
|
|
33
|
+
filename: string | null;
|
|
34
|
+
mimeType: string | null;
|
|
35
|
+
size: number | null;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
projectId: string | null;
|
|
38
|
+
projectName: string | null;
|
|
39
|
+
open: PortalFileOpen;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PortalFilesPage {
|
|
43
|
+
data: PortalFile[];
|
|
44
|
+
total: number;
|
|
45
|
+
page: number;
|
|
46
|
+
pageSize: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const portalFilesKey = (profileId?: string, accountId?: string, source?: string, pageSize?: number) =>
|
|
50
|
+
["portal-files", profileId, accountId, source, pageSize] as const;
|
|
51
|
+
|
|
52
|
+
/** Paged newest first. `fetchNextPage` loads the next `pageSize` rows. */
|
|
53
|
+
export function usePortalFiles(source: PortalFileSourceFilter = "all", pageSize = 30) {
|
|
54
|
+
const { client, profileId } = useForge();
|
|
55
|
+
const { user } = usePublicAuth();
|
|
56
|
+
return useInfiniteQuery({
|
|
57
|
+
queryKey: portalFilesKey(profileId, user?.id, source, pageSize),
|
|
58
|
+
initialPageParam: 1,
|
|
59
|
+
queryFn: async ({ pageParam }) => {
|
|
60
|
+
const res = await client.get("/public/portal/files", {
|
|
61
|
+
params: { profileId, source, page: pageParam, pageSize },
|
|
62
|
+
});
|
|
63
|
+
return res.data as PortalFilesPage;
|
|
64
|
+
},
|
|
65
|
+
getNextPageParam: (last) => (last.page * last.pageSize < last.total ? last.page + 1 : undefined),
|
|
66
|
+
enabled: !!profileId && !!client && !!user,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { useEffect, useMemo } from "react";
|
|
2
|
+
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { useForge } from "../../../provider/ForgeProvider";
|
|
4
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
5
|
+
import { useChatSocket } from "../../../data/queries/useChat";
|
|
6
|
+
import { useChatAttachmentUpload } from "../chat/useChatAttachmentUpload";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Messages in the client portal (`/i/account/messages`): one conversation
|
|
10
|
+
* between this person and the creator (`/public/portal/messages`).
|
|
11
|
+
*
|
|
12
|
+
* The creator's replies arrive as the BRAND: `senderName` on a team message is
|
|
13
|
+
* the creator's name, never the team member who wrote it. Realtime rides the
|
|
14
|
+
* community chat socket (`chat:<threadId>`); when the socket is not connected,
|
|
15
|
+
* the open page re-reads every 10 seconds while it is visible.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface PortalMessageAttachment {
|
|
19
|
+
url: string;
|
|
20
|
+
fileName: string;
|
|
21
|
+
fileSize: number;
|
|
22
|
+
fileType: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PortalMessage {
|
|
26
|
+
id: string;
|
|
27
|
+
content: string | null;
|
|
28
|
+
attachments: PortalMessageAttachment[];
|
|
29
|
+
createdAt: string;
|
|
30
|
+
/** A reply from the creator's side. */
|
|
31
|
+
fromTeam: boolean;
|
|
32
|
+
/** The caller's own message. */
|
|
33
|
+
isMine: boolean;
|
|
34
|
+
/** The creator's name on a reply, the person's first name on their own. */
|
|
35
|
+
senderName: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PortalThreadSummary {
|
|
39
|
+
id: string;
|
|
40
|
+
createdAt: string;
|
|
41
|
+
unreadCount: number;
|
|
42
|
+
lastMessageAt: string | null;
|
|
43
|
+
lastPreview: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PortalThreadState {
|
|
47
|
+
/** May this person write (start a conversation, or keep writing in theirs)? */
|
|
48
|
+
allowed: boolean;
|
|
49
|
+
creator: { name: string };
|
|
50
|
+
/** Null until the first message. */
|
|
51
|
+
thread: PortalThreadSummary | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PortalMessagesPage {
|
|
55
|
+
/** Oldest first inside the page. */
|
|
56
|
+
data: PortalMessage[];
|
|
57
|
+
/** Pass as `before` for the page before this one; null at the start. */
|
|
58
|
+
nextCursor: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** How often an open Messages page re-reads while the socket is not connected. */
|
|
62
|
+
export const PORTAL_MESSAGES_POLL_MS = 10_000;
|
|
63
|
+
|
|
64
|
+
/** The endpoint that signs a portal attachment upload. */
|
|
65
|
+
export const PORTAL_MESSAGES_PRESIGN_PATH = "/public/portal/messages/attachments/presigned-url";
|
|
66
|
+
|
|
67
|
+
export const portalThreadKey = (profileId?: string, accountId?: string) =>
|
|
68
|
+
["portal-messages-thread", profileId, accountId] as const;
|
|
69
|
+
export const portalMessagesKey = (profileId?: string, accountId?: string) =>
|
|
70
|
+
["portal-messages", profileId, accountId] as const;
|
|
71
|
+
|
|
72
|
+
/** The conversation's header: whether the person may write, the creator, and the thread. */
|
|
73
|
+
export function usePortalThread() {
|
|
74
|
+
const { client, profileId } = useForge();
|
|
75
|
+
const { user } = usePublicAuth();
|
|
76
|
+
return useQuery({
|
|
77
|
+
queryKey: portalThreadKey(profileId, user?.id),
|
|
78
|
+
queryFn: async () =>
|
|
79
|
+
(await client.get("/public/portal/messages", { params: { profileId } })).data as PortalThreadState,
|
|
80
|
+
enabled: !!profileId && !!client && !!user,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The conversation, newest page first. Flatten with `portalMessagesInOrder`. */
|
|
85
|
+
export function usePortalMessages(limit = 30) {
|
|
86
|
+
const { client, profileId } = useForge();
|
|
87
|
+
const { user } = usePublicAuth();
|
|
88
|
+
return useInfiniteQuery({
|
|
89
|
+
queryKey: portalMessagesKey(profileId, user?.id),
|
|
90
|
+
initialPageParam: undefined as string | undefined,
|
|
91
|
+
queryFn: async ({ pageParam }) =>
|
|
92
|
+
(
|
|
93
|
+
await client.get("/public/portal/messages/messages", {
|
|
94
|
+
params: { profileId, limit, ...(pageParam ? { before: pageParam } : {}) },
|
|
95
|
+
})
|
|
96
|
+
).data as PortalMessagesPage,
|
|
97
|
+
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
|
98
|
+
enabled: !!profileId && !!client && !!user,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Every loaded message, oldest first: the pages are newest first, each oldest first inside. */
|
|
103
|
+
export function portalMessagesInOrder(pages: PortalMessagesPage[] | undefined): PortalMessage[] {
|
|
104
|
+
return [...(pages ?? [])].reverse().flatMap((page) => page.data);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Send one message. The first one opens the conversation. */
|
|
108
|
+
export function useSendPortalMessage() {
|
|
109
|
+
const { client, profileId } = useForge();
|
|
110
|
+
const { user } = usePublicAuth();
|
|
111
|
+
const queryClient = useQueryClient();
|
|
112
|
+
return useMutation({
|
|
113
|
+
mutationFn: async (input: { content: string; attachments: PortalMessageAttachment[] }) =>
|
|
114
|
+
(await client.post("/public/portal/messages/messages", { profileId, ...input })).data as PortalMessage,
|
|
115
|
+
onSuccess: () => {
|
|
116
|
+
queryClient.invalidateQueries({ queryKey: portalMessagesKey(profileId, user?.id) });
|
|
117
|
+
queryClient.invalidateQueries({ queryKey: portalThreadKey(profileId, user?.id) });
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Mark the creator's replies read. */
|
|
123
|
+
export function useMarkPortalMessagesRead() {
|
|
124
|
+
const { client, profileId } = useForge();
|
|
125
|
+
const { user } = usePublicAuth();
|
|
126
|
+
const queryClient = useQueryClient();
|
|
127
|
+
return useMutation({
|
|
128
|
+
mutationFn: async () =>
|
|
129
|
+
(await client.post("/public/portal/messages/read", { profileId })).data as {
|
|
130
|
+
marked: number;
|
|
131
|
+
unreadCount: number;
|
|
132
|
+
},
|
|
133
|
+
onSuccess: (result) => {
|
|
134
|
+
if (!result?.marked) return;
|
|
135
|
+
queryClient.invalidateQueries({ queryKey: portalThreadKey(profileId, user?.id) });
|
|
136
|
+
// The portal home's "needs you" list carries the unread count too.
|
|
137
|
+
queryClient.invalidateQueries({ queryKey: ["portal-summary"] });
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Stage and upload attachments through the portal's own signing endpoint. */
|
|
143
|
+
export function usePortalMessageAttachments() {
|
|
144
|
+
return useChatAttachmentUpload({ presignPath: PORTAL_MESSAGES_PRESIGN_PATH });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Follow the conversation live. Joins `chat:<threadId>` on the chat socket and
|
|
149
|
+
* re-reads on every new message; while the socket is down, re-reads every 10
|
|
150
|
+
* seconds as long as the page is visible.
|
|
151
|
+
*/
|
|
152
|
+
export function usePortalMessagesLive(threadId: string | null | undefined) {
|
|
153
|
+
const { profileId } = useForge();
|
|
154
|
+
const { user } = usePublicAuth();
|
|
155
|
+
const queryClient = useQueryClient();
|
|
156
|
+
const socket = useChatSocket(!!threadId && !!user);
|
|
157
|
+
const keys = useMemo(
|
|
158
|
+
() => [portalMessagesKey(profileId, user?.id), portalThreadKey(profileId, user?.id)],
|
|
159
|
+
[profileId, user?.id],
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!threadId) return undefined;
|
|
164
|
+
const refresh = () => keys.forEach((queryKey) => queryClient.invalidateQueries({ queryKey }));
|
|
165
|
+
const join = () => socket?.emit("chatJoinChannel", { channelId: threadId });
|
|
166
|
+
const onMessage = (envelope: { event: string; data: { channelId?: string } }) => {
|
|
167
|
+
if (envelope?.data?.channelId !== threadId) return;
|
|
168
|
+
if (envelope.event === "chatNewMessage") refresh();
|
|
169
|
+
};
|
|
170
|
+
if (socket) {
|
|
171
|
+
if (socket.connected) join();
|
|
172
|
+
socket.on("connect", join);
|
|
173
|
+
socket.on("message", onMessage);
|
|
174
|
+
}
|
|
175
|
+
const poll = setInterval(() => {
|
|
176
|
+
if (socket?.connected) return;
|
|
177
|
+
if (typeof document !== "undefined" && document.visibilityState !== "visible") return;
|
|
178
|
+
refresh();
|
|
179
|
+
}, PORTAL_MESSAGES_POLL_MS);
|
|
180
|
+
return () => {
|
|
181
|
+
clearInterval(poll);
|
|
182
|
+
if (socket) {
|
|
183
|
+
socket.off("connect", join);
|
|
184
|
+
socket.off("message", onMessage);
|
|
185
|
+
socket.emit("chatLeaveChannel", { channelId: threadId });
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}, [threadId, socket, keys, queryClient]);
|
|
189
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useEffect, useState } from "react";
|
|
3
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import { useForge } from "../../../provider/ForgeProvider";
|
|
5
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
6
|
+
import {
|
|
7
|
+
PUBLIC_ACCESS_TOKEN_KEY,
|
|
8
|
+
PUBLIC_REFRESH_TOKEN_KEY,
|
|
9
|
+
clearTokenPair,
|
|
10
|
+
storeTokenPair,
|
|
11
|
+
} from "../../../client/tokenStorage";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Preview as client, the site half.
|
|
15
|
+
*
|
|
16
|
+
* A creator clicks "Preview as client" in the admin and lands on
|
|
17
|
+
* `/i/account?preview=<code>`. The code is single use and lives 60 seconds; it
|
|
18
|
+
* is the only thing in the URL. This file trades it for a read-only, 15-minute
|
|
19
|
+
* session for that client, and exposes what the portal needs to say so.
|
|
20
|
+
*
|
|
21
|
+
* Two hooks, on purpose:
|
|
22
|
+
*
|
|
23
|
+
* - `usePortalPreviewRedeem()` does the exchange. Mount it ONCE, on the portal
|
|
24
|
+
* page wrapper. Mounted twice, the second exchange would spend nothing (the
|
|
25
|
+
* code is stripped from the address bar before the first request leaves) but
|
|
26
|
+
* it is still one job with one owner.
|
|
27
|
+
* - `usePortalPreview()` is read-only state plus `exit()`. Mount it wherever the
|
|
28
|
+
* portal needs to know (the banner, a write button that wants to explain
|
|
29
|
+
* itself). The server refuses every write a preview makes regardless.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const PREVIEW_PARAM = "preview";
|
|
33
|
+
|
|
34
|
+
/** Codes this page has already tried, so a re-run effect never spends one twice. */
|
|
35
|
+
const attempted = new Set<string>();
|
|
36
|
+
let redeeming = false;
|
|
37
|
+
|
|
38
|
+
const readCode = (): string | null => {
|
|
39
|
+
if (typeof window === "undefined") return null;
|
|
40
|
+
return new URLSearchParams(window.location.search).get(PREVIEW_PARAM);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Take the code out of the address bar (and so out of history and any Referer) at once. */
|
|
44
|
+
const stripCode = () => {
|
|
45
|
+
if (typeof window === "undefined") return;
|
|
46
|
+
const url = new URL(window.location.href);
|
|
47
|
+
if (!url.searchParams.has(PREVIEW_PARAM)) return;
|
|
48
|
+
url.searchParams.delete(PREVIEW_PARAM);
|
|
49
|
+
window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* True while a preview code is in the URL or being exchanged. An auth guard
|
|
54
|
+
* checks this AT EFFECT TIME before sending a signed-out visitor to the login
|
|
55
|
+
* page: the visitor is about to be signed in as the client.
|
|
56
|
+
*/
|
|
57
|
+
export const isPortalPreviewPending = (): boolean => redeeming || readCode() !== null;
|
|
58
|
+
|
|
59
|
+
export type PortalPreviewRedeemState = { pending: boolean; failed: boolean };
|
|
60
|
+
|
|
61
|
+
export function usePortalPreviewRedeem(): PortalPreviewRedeemState {
|
|
62
|
+
const { client, profileId, setToken } = useForge();
|
|
63
|
+
const { initialize } = usePublicAuth();
|
|
64
|
+
const queryClient = useQueryClient();
|
|
65
|
+
const [state, setState] = useState<PortalPreviewRedeemState>({ pending: false, failed: false });
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const code = readCode();
|
|
69
|
+
if (!code) return;
|
|
70
|
+
stripCode();
|
|
71
|
+
if (attempted.has(code) || !profileId) return;
|
|
72
|
+
attempted.add(code);
|
|
73
|
+
redeeming = true;
|
|
74
|
+
setState({ pending: true, failed: false });
|
|
75
|
+
|
|
76
|
+
client
|
|
77
|
+
.post("/public/sessions/preview/redeem", { code, profileId })
|
|
78
|
+
.then(async (res) => {
|
|
79
|
+
const { token } = res.data as { token: string };
|
|
80
|
+
// Replace whatever this browser held for the site. The REFRESH token goes
|
|
81
|
+
// too: left behind, an expired preview would refresh into the previous
|
|
82
|
+
// identity instead of ending.
|
|
83
|
+
clearTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY);
|
|
84
|
+
storeTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY, token);
|
|
85
|
+
setToken(token);
|
|
86
|
+
// Nothing cached for the previous identity may show under the client's name.
|
|
87
|
+
queryClient.clear();
|
|
88
|
+
await initialize();
|
|
89
|
+
redeeming = false;
|
|
90
|
+
setState({ pending: false, failed: false });
|
|
91
|
+
})
|
|
92
|
+
.catch(() => {
|
|
93
|
+
redeeming = false;
|
|
94
|
+
setState({ pending: false, failed: true });
|
|
95
|
+
});
|
|
96
|
+
}, [client, profileId, setToken, initialize, queryClient]);
|
|
97
|
+
|
|
98
|
+
return state;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type PortalPreview = {
|
|
102
|
+
/** True while this browser is viewing the portal as a client. */
|
|
103
|
+
active: boolean;
|
|
104
|
+
/** The client being previewed. */
|
|
105
|
+
name: string;
|
|
106
|
+
/** Who started the preview (the creator or admin). */
|
|
107
|
+
previewedByName: string;
|
|
108
|
+
/** ISO time the preview ends. It is never extended. */
|
|
109
|
+
expiresAt: string | null;
|
|
110
|
+
/** End the preview, forget the token, and go to the site's home page. */
|
|
111
|
+
exit: () => Promise<void>;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export function usePortalPreview(): PortalPreview {
|
|
115
|
+
const { client, setToken } = useForge();
|
|
116
|
+
const { user } = usePublicAuth();
|
|
117
|
+
const queryClient = useQueryClient();
|
|
118
|
+
const preview = user?.preview ?? null;
|
|
119
|
+
|
|
120
|
+
const exit = useCallback(async () => {
|
|
121
|
+
try {
|
|
122
|
+
await client.post("/public/sessions/preview/end");
|
|
123
|
+
} catch {
|
|
124
|
+
// Ending is best effort from here: the session dies at its expiry anyway,
|
|
125
|
+
// and the token is dropped from this browser below either way.
|
|
126
|
+
}
|
|
127
|
+
clearTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY);
|
|
128
|
+
setToken(null);
|
|
129
|
+
queryClient.clear();
|
|
130
|
+
if (typeof window !== "undefined") window.location.assign("/");
|
|
131
|
+
}, [client, setToken, queryClient]);
|
|
132
|
+
|
|
133
|
+
const name = [user?.firstName, user?.lastName].filter(Boolean).join(" ").trim() || user?.email || "";
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
active: !!preview,
|
|
137
|
+
name,
|
|
138
|
+
previewedByName: preview?.previewedByName ?? "",
|
|
139
|
+
expiresAt: preview?.expiresAt ?? null,
|
|
140
|
+
exit,
|
|
141
|
+
};
|
|
142
|
+
}
|