@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
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
|
+
import { Check } from "lucide-react";
|
|
2
3
|
import {
|
|
3
4
|
usePublicAuth,
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
useUserOrdersPaged,
|
|
6
|
+
useMyTicketsPaged,
|
|
6
7
|
useCancelMyTicket,
|
|
7
8
|
isMyTicketRequestOpenOrEnded,
|
|
8
9
|
myTicketRequestState,
|
|
@@ -12,14 +13,19 @@ import {
|
|
|
12
13
|
useMyWaitlistPlaces,
|
|
13
14
|
useLeaveEventWaitlist,
|
|
14
15
|
type WaitlistPlace,
|
|
15
|
-
|
|
16
|
+
useMyBookingsPaged,
|
|
16
17
|
useRescheduleBooking,
|
|
17
18
|
useCancelMyBooking,
|
|
18
19
|
useCoachingAvailability,
|
|
19
20
|
type MyBooking,
|
|
20
21
|
useSavedPosts,
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
useNotificationMatrix,
|
|
23
|
+
useUpdateNotificationMatrix,
|
|
24
|
+
usePushSubscription,
|
|
25
|
+
useForge,
|
|
26
|
+
type NotificationMatrix,
|
|
27
|
+
type NotificationChannel,
|
|
28
|
+
type DigestCadence,
|
|
23
29
|
useMyCourses,
|
|
24
30
|
useMyFilmRentals,
|
|
25
31
|
type MyCourse,
|
|
@@ -36,13 +42,13 @@ import {
|
|
|
36
42
|
useCancelAccountDeletion,
|
|
37
43
|
useAccountDeletionStatus,
|
|
38
44
|
OrderStatus,
|
|
39
|
-
type NotificationPreferences,
|
|
40
45
|
} from "../../index";
|
|
41
46
|
import { useForgeT, type ForgeT } from "../../i18n";
|
|
42
47
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
43
48
|
import { useSiteConfig } from "../../data/queries/useWebsite";
|
|
44
49
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
45
50
|
import { Loading } from "./Loading";
|
|
51
|
+
import { CallHelpHint } from "../media/CallHelpHint";
|
|
46
52
|
import { bookingCallWindow } from "../media/bookingWindow";
|
|
47
53
|
import { formatCountdown } from "./EventWaitlist";
|
|
48
54
|
import { AddToCalendar } from "./AddToCalendar";
|
|
@@ -149,47 +155,116 @@ const defaultFormatDate = (value: string | number | Date): string =>
|
|
|
149
155
|
|
|
150
156
|
// ---- shared styling helpers -------------------------------------------------
|
|
151
157
|
|
|
158
|
+
/**
|
|
159
|
+
* The panels' look: the client portal's. No box around a section and no box
|
|
160
|
+
* around each item; items are rows divided by hairlines, and the theme's
|
|
161
|
+
* primary is kept for actions and for state the person has to act on.
|
|
162
|
+
*/
|
|
152
163
|
function useCardStyles() {
|
|
153
164
|
const t = useThemeTokens();
|
|
165
|
+
const control: React.CSSProperties = {
|
|
166
|
+
display: "inline-flex",
|
|
167
|
+
alignItems: "center",
|
|
168
|
+
justifyContent: "center",
|
|
169
|
+
gap: 6,
|
|
170
|
+
minHeight: 38,
|
|
171
|
+
padding: "8px 16px",
|
|
172
|
+
borderRadius: t.cornerRadius,
|
|
173
|
+
fontFamily: t.fontFamily,
|
|
174
|
+
fontSize: 14,
|
|
175
|
+
fontWeight: 600,
|
|
176
|
+
cursor: "pointer",
|
|
177
|
+
textDecoration: "none",
|
|
178
|
+
whiteSpace: "nowrap",
|
|
179
|
+
};
|
|
154
180
|
return {
|
|
155
181
|
t,
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
182
|
+
/** A section: no chrome of its own, the page title already names it. */
|
|
183
|
+
card: { color: t.text } as React.CSSProperties,
|
|
184
|
+
/** The list a section's items sit in. */
|
|
185
|
+
list: { display: "flex", flexDirection: "column", borderTop: `1px solid ${t.border}` } as React.CSSProperties,
|
|
186
|
+
/** One item: a row, divided from the next by a hairline. */
|
|
187
|
+
row: { padding: "20px 0", borderBottom: `1px solid ${t.border}` } as React.CSSProperties,
|
|
188
|
+
/** Secondary text under an item's title. */
|
|
189
|
+
meta: { fontSize: 13, color: t.muted, lineHeight: 1.5 } as React.CSSProperties,
|
|
163
190
|
button: {
|
|
164
|
-
|
|
165
|
-
borderRadius: t.cornerRadius,
|
|
191
|
+
...control,
|
|
166
192
|
background: t.primary,
|
|
167
|
-
color: t.
|
|
168
|
-
border:
|
|
169
|
-
fontWeight: 600,
|
|
170
|
-
cursor: "pointer",
|
|
193
|
+
color: t.textPrimary,
|
|
194
|
+
border: `1px solid ${t.primary}`,
|
|
171
195
|
} as React.CSSProperties,
|
|
172
196
|
ghostButton: {
|
|
173
|
-
|
|
174
|
-
borderRadius: t.cornerRadius,
|
|
197
|
+
...control,
|
|
175
198
|
background: "transparent",
|
|
176
199
|
color: t.text,
|
|
177
|
-
border: `1px solid ${t.
|
|
178
|
-
cursor: "pointer",
|
|
200
|
+
border: `1px solid ${t.border}`,
|
|
179
201
|
} as React.CSSProperties,
|
|
180
202
|
input: {
|
|
181
203
|
width: "100%",
|
|
182
204
|
height: 40,
|
|
183
205
|
padding: "8px 12px",
|
|
184
206
|
borderRadius: t.cornerRadius,
|
|
185
|
-
border: `1px solid ${t.
|
|
207
|
+
border: `1px solid ${t.border}`,
|
|
186
208
|
background: "transparent",
|
|
187
209
|
color: t.text,
|
|
210
|
+
fontFamily: t.fontFamily,
|
|
211
|
+
fontSize: 15,
|
|
188
212
|
outline: "none",
|
|
213
|
+
boxSizing: "border-box",
|
|
189
214
|
} as React.CSSProperties,
|
|
215
|
+
/** An error the person must read: the theme's text, weighted, never a hard-coded red. */
|
|
216
|
+
alert: { fontSize: 14, fontWeight: 600, color: t.text, marginTop: 12 } as React.CSSProperties,
|
|
190
217
|
};
|
|
191
218
|
}
|
|
192
219
|
|
|
220
|
+
/** An empty section says what will appear and stays quiet about it. */
|
|
221
|
+
/** "Show more" under a list that loads a page at a time. Draws nothing once everything is loaded. */
|
|
222
|
+
function ShowMore({
|
|
223
|
+
hasNextPage,
|
|
224
|
+
isFetchingNextPage,
|
|
225
|
+
onMore,
|
|
226
|
+
}: {
|
|
227
|
+
hasNextPage?: boolean;
|
|
228
|
+
isFetchingNextPage: boolean;
|
|
229
|
+
onMore: () => void;
|
|
230
|
+
}) {
|
|
231
|
+
const { ghostButton } = useCardStyles();
|
|
232
|
+
const tr = useForgeT();
|
|
233
|
+
if (!hasNextPage) return null;
|
|
234
|
+
return (
|
|
235
|
+
<div style={{ marginTop: 20 }}>
|
|
236
|
+
<button
|
|
237
|
+
type="button"
|
|
238
|
+
data-testid="account-show-more"
|
|
239
|
+
onClick={onMore}
|
|
240
|
+
disabled={isFetchingNextPage}
|
|
241
|
+
style={{ ...ghostButton, cursor: isFetchingNextPage ? "wait" : "pointer" }}
|
|
242
|
+
>
|
|
243
|
+
{isFetchingNextPage ? tr("forge.account_dashboard.loading_more") : tr("forge.account_dashboard.show_more")}
|
|
244
|
+
</button>
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function EmptySection({ children }: { children: React.ReactNode }) {
|
|
250
|
+
const t = useThemeTokens();
|
|
251
|
+
return (
|
|
252
|
+
<div
|
|
253
|
+
data-testid="account-section-empty"
|
|
254
|
+
style={{
|
|
255
|
+
padding: "36px 24px",
|
|
256
|
+
border: `1px dashed ${t.border}`,
|
|
257
|
+
borderRadius: t.cornerRadius,
|
|
258
|
+
textAlign: "center",
|
|
259
|
+
color: t.muted,
|
|
260
|
+
fontSize: 15,
|
|
261
|
+
}}
|
|
262
|
+
>
|
|
263
|
+
{children}
|
|
264
|
+
</div>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
193
268
|
// ---- shared context passed down to tabs ------------------------------------
|
|
194
269
|
|
|
195
270
|
interface TabContext {
|
|
@@ -275,15 +350,16 @@ export function AccountDashboard({
|
|
|
275
350
|
const hasCourses = (courses?.length ?? 0) > 0;
|
|
276
351
|
const visibleTabs = ACCOUNT_TABS.filter(
|
|
277
352
|
(key) =>
|
|
278
|
-
(key !== "rentals" || hasRentals || tab === "rentals") &&
|
|
279
|
-
(key !== "courses" || hasCourses || tab === "courses"),
|
|
353
|
+
(key !== "rentals" || hasRentals || tab === "rentals") && (key !== "courses" || hasCourses || tab === "courses"),
|
|
280
354
|
);
|
|
281
355
|
|
|
282
356
|
if (!user) return <Loading fullPage />;
|
|
283
357
|
|
|
284
358
|
return (
|
|
285
359
|
<div className={className} style={{ maxWidth: 900, margin: "0 auto", color: t.text, ...style }}>
|
|
286
|
-
<div
|
|
360
|
+
<div
|
|
361
|
+
style={{ marginBottom: 24, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 16 }}
|
|
362
|
+
>
|
|
287
363
|
<div>
|
|
288
364
|
<h1 style={{ fontSize: 28, fontWeight: 800, fontFamily: t.headingFontFamily }}>
|
|
289
365
|
{tr("forge.account_dashboard.title")}
|
|
@@ -292,7 +368,14 @@ export function AccountDashboard({
|
|
|
292
368
|
</div>
|
|
293
369
|
<button
|
|
294
370
|
onClick={() => logout()}
|
|
295
|
-
style={{
|
|
371
|
+
style={{
|
|
372
|
+
padding: "8px 14px",
|
|
373
|
+
borderRadius: t.cornerRadius,
|
|
374
|
+
background: "transparent",
|
|
375
|
+
color: t.text,
|
|
376
|
+
border: `1px solid ${t.primary}40`,
|
|
377
|
+
cursor: "pointer",
|
|
378
|
+
}}
|
|
296
379
|
>
|
|
297
380
|
{tr("forge.account_dashboard.log_out")}
|
|
298
381
|
</button>
|
|
@@ -333,6 +416,69 @@ export function AccountDashboard({
|
|
|
333
416
|
);
|
|
334
417
|
}
|
|
335
418
|
|
|
419
|
+
// ---- one section, outside the dashboard -----------------------------------
|
|
420
|
+
|
|
421
|
+
export type AccountSectionProps = Omit<AccountDashboardProps, "tab" | "onTabChange" | "className" | "style"> & {
|
|
422
|
+
/** Which section to draw. Same keys as the dashboard's tabs. */
|
|
423
|
+
section: AccountTabKey;
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* One of the dashboard's sections on its own, with no header and no tab strip.
|
|
428
|
+
*
|
|
429
|
+
* The client portal draws its own navigation (a rail on desktop, a bottom bar
|
|
430
|
+
* on mobile) and gives each section its own URL, so it needs the panels
|
|
431
|
+
* without the dashboard's chrome. The panels are the same components the
|
|
432
|
+
* dashboard renders, so the two can never drift apart.
|
|
433
|
+
*/
|
|
434
|
+
export function AccountSection({
|
|
435
|
+
section,
|
|
436
|
+
navigate,
|
|
437
|
+
onNavigateFilm,
|
|
438
|
+
onNavigateCourse,
|
|
439
|
+
formatAmount,
|
|
440
|
+
formatDate,
|
|
441
|
+
currency,
|
|
442
|
+
}: AccountSectionProps) {
|
|
443
|
+
const { user } = usePublicAuth();
|
|
444
|
+
const { data: siteConfig } = useSiteConfig();
|
|
445
|
+
const { data: courses } = useMyCourses({ accountId: user?.id });
|
|
446
|
+
const fmtAmount = useAmountFormatter(formatAmount);
|
|
447
|
+
const ctx: TabContext = {
|
|
448
|
+
formatAmount: fmtAmount,
|
|
449
|
+
formatDate: formatDate ?? defaultFormatDate,
|
|
450
|
+
currency: currency ?? siteConfig?.currency,
|
|
451
|
+
navigate,
|
|
452
|
+
onNavigateFilm,
|
|
453
|
+
onNavigateCourse,
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
if (!user) return <Loading />;
|
|
457
|
+
|
|
458
|
+
switch (section) {
|
|
459
|
+
case "membership":
|
|
460
|
+
return <MembershipTab ctx={ctx} />;
|
|
461
|
+
case "orders":
|
|
462
|
+
return <OrdersTab ctx={ctx} />;
|
|
463
|
+
case "tickets":
|
|
464
|
+
return <TicketsTab ctx={ctx} />;
|
|
465
|
+
case "waitlist":
|
|
466
|
+
return <WaitlistTab ctx={ctx} />;
|
|
467
|
+
case "bookings":
|
|
468
|
+
return <BookingsTab ctx={ctx} />;
|
|
469
|
+
case "rentals":
|
|
470
|
+
return <RentalsTab ctx={ctx} />;
|
|
471
|
+
case "courses":
|
|
472
|
+
return <CoursesTab ctx={ctx} courses={courses ?? []} />;
|
|
473
|
+
case "saved":
|
|
474
|
+
return <SavedTab />;
|
|
475
|
+
case "account":
|
|
476
|
+
return <AccountTab ctx={ctx} />;
|
|
477
|
+
case "notifications":
|
|
478
|
+
return <NotificationsTab />;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
336
482
|
// ---- Membership tab ---------------------------------------------------------
|
|
337
483
|
|
|
338
484
|
/**
|
|
@@ -343,14 +489,10 @@ export function AccountDashboard({
|
|
|
343
489
|
* fan here; this is for someone who wants everything in one place.
|
|
344
490
|
*/
|
|
345
491
|
function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
346
|
-
const {
|
|
347
|
-
const tr = useForgeT();
|
|
492
|
+
const { card } = useCardStyles();
|
|
348
493
|
return (
|
|
349
494
|
<div style={card}>
|
|
350
|
-
<
|
|
351
|
-
{tr("forge.account_dashboard.membership_title")}
|
|
352
|
-
</h2>
|
|
353
|
-
<MyMembershipsList navigate={ctx.navigate} formatAmount={ctx.formatAmount} />
|
|
495
|
+
<MyMembershipsList navigate={ctx.navigate} formatAmount={ctx.formatAmount} layout="rows" />
|
|
354
496
|
<MembershipApplicationsSection ctx={ctx} />
|
|
355
497
|
</div>
|
|
356
498
|
);
|
|
@@ -379,10 +521,12 @@ const APPLICATION_STATUS_LABEL: Record<MembershipApplicationStatus, string> = {
|
|
|
379
521
|
function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
|
|
380
522
|
const { user } = usePublicAuth();
|
|
381
523
|
const tr = useForgeT();
|
|
382
|
-
const { t, ghostButton, button } = useCardStyles();
|
|
524
|
+
const { t, ghostButton, button, list, row, meta, alert } = useCardStyles();
|
|
383
525
|
const { formatAmount, formatDate, currency } = ctx;
|
|
384
526
|
const { data: applications } = useMyMembershipApplications(user?.id);
|
|
385
|
-
const { data: communities } = usePublicCommunities({
|
|
527
|
+
const { data: communities } = usePublicCommunities({
|
|
528
|
+
enabled: !!applications?.some((a) => a.status === "payment_failed"),
|
|
529
|
+
});
|
|
386
530
|
/** An approved application whose card failed is finished on its community's About page, on that tier. */
|
|
387
531
|
const completeHref = (application: MembershipApplication) => {
|
|
388
532
|
const communityId = (application as MembershipApplication & { communityId?: string }).communityId;
|
|
@@ -415,24 +559,28 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
|
|
|
415
559
|
};
|
|
416
560
|
|
|
417
561
|
return (
|
|
418
|
-
<div style={{ marginTop:
|
|
419
|
-
<
|
|
562
|
+
<div style={{ marginTop: 40 }}>
|
|
563
|
+
<h2 style={{ fontSize: 18, fontWeight: 600, margin: "0 0 12px", fontFamily: t.headingFontFamily }}>
|
|
420
564
|
{tr("forge.account_dashboard.applications_title")}
|
|
421
|
-
</
|
|
422
|
-
<ul
|
|
423
|
-
data-testid="membership-applications"
|
|
424
|
-
style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 12 }}
|
|
425
|
-
>
|
|
565
|
+
</h2>
|
|
566
|
+
<ul data-testid="membership-applications" style={{ ...list, listStyle: "none", padding: 0, margin: 0 }}>
|
|
426
567
|
{applications.map((application) => (
|
|
427
568
|
<li
|
|
428
569
|
key={application.id}
|
|
429
|
-
style={{
|
|
570
|
+
style={{
|
|
571
|
+
...row,
|
|
572
|
+
display: "flex",
|
|
573
|
+
gap: 12,
|
|
574
|
+
justifyContent: "space-between",
|
|
575
|
+
alignItems: "center",
|
|
576
|
+
flexWrap: "wrap",
|
|
577
|
+
}}
|
|
430
578
|
>
|
|
431
579
|
<div style={{ minWidth: 0 }}>
|
|
432
|
-
<p style={{ fontWeight: 600 }}>{application.membershipTierName ?? ""}</p>
|
|
433
|
-
<p style={{
|
|
580
|
+
<p style={{ fontWeight: 600, margin: 0 }}>{application.membershipTierName ?? ""}</p>
|
|
581
|
+
<p style={{ ...meta, margin: "2px 0 0" }}>{tr(APPLICATION_STATUS_LABEL[application.status])}</p>
|
|
434
582
|
{application.amount != null && application.billingCycle && (
|
|
435
|
-
<p style={{
|
|
583
|
+
<p style={{ ...meta, margin: 0 }}>
|
|
436
584
|
{tr("forge.account_dashboard.membership_price", {
|
|
437
585
|
amount: formatAmount(application.amount, application.currency || currency),
|
|
438
586
|
cycle: application.billingCycle,
|
|
@@ -440,35 +588,28 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
|
|
|
440
588
|
</p>
|
|
441
589
|
)}
|
|
442
590
|
{application.status === "payment_failed" && application.paymentWindowExpiresAt && (
|
|
443
|
-
<p style={{ fontSize: 13, fontWeight: 600 }}>
|
|
591
|
+
<p style={{ fontSize: 13, fontWeight: 600, margin: 0 }}>
|
|
444
592
|
{tr("forge.account_dashboard.application_complete_by", {
|
|
445
593
|
date: formatDate(application.paymentWindowExpiresAt),
|
|
446
594
|
})}
|
|
447
595
|
</p>
|
|
448
596
|
)}
|
|
449
597
|
{application.status === "declined" && application.decisionNote && (
|
|
450
|
-
<p style={{
|
|
598
|
+
<p style={{ ...meta, margin: 0, maxWidth: 480 }}>{application.decisionNote}</p>
|
|
451
599
|
)}
|
|
452
|
-
<p style={{
|
|
600
|
+
<p style={{ ...meta, margin: 0 }}>
|
|
453
601
|
{tr("forge.account_dashboard.application_submitted_on", { date: formatDate(application.createdAt) })}
|
|
454
602
|
</p>
|
|
455
603
|
</div>
|
|
456
604
|
{canWithdrawMembershipApplication(application.status) && (
|
|
457
|
-
<button
|
|
458
|
-
onClick={() => void onWithdraw(application)}
|
|
459
|
-
disabled={withdraw.isPending}
|
|
460
|
-
style={ghostButton}
|
|
461
|
-
>
|
|
605
|
+
<button onClick={() => void onWithdraw(application)} disabled={withdraw.isPending} style={ghostButton}>
|
|
462
606
|
{withdraw.isPending && withdraw.variables?.applicationId === application.id
|
|
463
607
|
? tr("forge.account_dashboard.application_withdrawing")
|
|
464
608
|
: tr("forge.account_dashboard.application_withdraw")}
|
|
465
609
|
</button>
|
|
466
610
|
)}
|
|
467
611
|
{application.status === "payment_failed" && (
|
|
468
|
-
<a
|
|
469
|
-
href={completeHref(application)}
|
|
470
|
-
style={{ ...button, textDecoration: "none" }}
|
|
471
|
-
>
|
|
612
|
+
<a href={completeHref(application)} style={button}>
|
|
472
613
|
{tr("forge.account_dashboard.application_complete")}
|
|
473
614
|
</a>
|
|
474
615
|
)}
|
|
@@ -476,7 +617,7 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
|
|
|
476
617
|
))}
|
|
477
618
|
</ul>
|
|
478
619
|
{error && (
|
|
479
|
-
<p role="alert" style={
|
|
620
|
+
<p role="alert" style={alert}>
|
|
480
621
|
{error}
|
|
481
622
|
</p>
|
|
482
623
|
)}
|
|
@@ -508,15 +649,15 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
|
|
|
508
649
|
*/
|
|
509
650
|
function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
510
651
|
const { user } = usePublicAuth();
|
|
511
|
-
const { t, card } = useCardStyles();
|
|
652
|
+
const { t, card, list, row, meta, ghostButton } = useCardStyles();
|
|
512
653
|
const tr = useForgeT();
|
|
513
654
|
const { formatAmount, formatDate, currency } = ctx;
|
|
514
|
-
const { data, isLoading } =
|
|
655
|
+
const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = useMyTicketsPaged(user?.id);
|
|
515
656
|
const cancelTicket = useCancelMyTicket();
|
|
516
657
|
const [pendingId, setPendingId] = useState<string | null>(null);
|
|
517
658
|
const [error, setError] = useState<string | null>(null);
|
|
518
659
|
|
|
519
|
-
const tickets = data?.data ?? [];
|
|
660
|
+
const tickets = data?.pages.flatMap((page) => page.data) ?? [];
|
|
520
661
|
|
|
521
662
|
const onCancel = async (ticket: MyTicket) => {
|
|
522
663
|
setError(null);
|
|
@@ -535,18 +676,15 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
535
676
|
|
|
536
677
|
return (
|
|
537
678
|
<div style={card}>
|
|
538
|
-
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
|
|
539
|
-
{tr("forge.account_dashboard.tickets_title")}
|
|
540
|
-
</h2>
|
|
541
679
|
{error && (
|
|
542
|
-
<
|
|
680
|
+
<p role="alert" style={{ fontSize: 14, fontWeight: 600, margin: "0 0 16px" }}>
|
|
543
681
|
{error}
|
|
544
|
-
</
|
|
682
|
+
</p>
|
|
545
683
|
)}
|
|
546
684
|
{isLoading ? (
|
|
547
685
|
<Loading />
|
|
548
686
|
) : tickets.length > 0 ? (
|
|
549
|
-
<div style={
|
|
687
|
+
<div style={list}>
|
|
550
688
|
{tickets.map((ticket) => {
|
|
551
689
|
const cancelled = !!ticket.selfCancelledAt;
|
|
552
690
|
// Absent on an older API build, where every row WAS a purchase.
|
|
@@ -562,24 +700,23 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
562
700
|
const requestState = myTicketRequestState(ticket);
|
|
563
701
|
const isRequestRow = isMyTicketRequestOpenOrEnded(ticket);
|
|
564
702
|
const requestEnded = isRequestRow && requestState !== "pending";
|
|
703
|
+
// A show that has happened needs no calendar entry, wallet pass or
|
|
704
|
+
// transfer: those controls are for getting INTO it.
|
|
705
|
+
const eventOver = new Date(ticket.eventEndDateTime ?? ticket.eventDateTime).getTime() < Date.now();
|
|
706
|
+
const heldPassIds = myTicketHeldPassIds(ticket);
|
|
565
707
|
return (
|
|
566
708
|
<div
|
|
567
709
|
key={ticket.id}
|
|
568
|
-
data-testid={requestState ? `my-ticket-request-${requestState}` :
|
|
569
|
-
style={{
|
|
570
|
-
border: `1px solid ${t.primary}20`,
|
|
571
|
-
borderRadius: t.cornerRadius,
|
|
572
|
-
padding: 16,
|
|
573
|
-
opacity: cancelled || requestEnded ? 0.6 : 1,
|
|
574
|
-
}}
|
|
710
|
+
data-testid={requestState ? `my-ticket-request-${requestState}` : "my-ticket"}
|
|
711
|
+
style={{ ...row, opacity: cancelled || requestEnded ? 0.6 : 1 }}
|
|
575
712
|
>
|
|
576
|
-
<div style={{ display: "flex", justifyContent: "space-between", marginBottom:
|
|
713
|
+
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8, gap: 12 }}>
|
|
577
714
|
<div>
|
|
578
|
-
<div style={{ fontWeight: 600 }}>{ticket.eventTitle}</div>
|
|
579
|
-
<div style={
|
|
715
|
+
<div style={{ fontWeight: 600, fontSize: 16 }}>{ticket.eventTitle}</div>
|
|
716
|
+
<div style={meta}>{formatDate(ticket.eventDateTime)}</div>
|
|
580
717
|
</div>
|
|
581
718
|
{requestState && (
|
|
582
|
-
<span style={{ fontSize: 13,
|
|
719
|
+
<span style={{ fontSize: 13, fontWeight: 600, textAlign: "right" }}>
|
|
583
720
|
{requestState === "pending"
|
|
584
721
|
? tr("forge.account_dashboard.request_pending")
|
|
585
722
|
: requestState === "approved"
|
|
@@ -592,20 +729,29 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
592
729
|
</span>
|
|
593
730
|
)}
|
|
594
731
|
{cancelled && !requestState && (
|
|
595
|
-
<span style={{ fontSize: 13, color: t.
|
|
732
|
+
<span style={{ fontSize: 13, color: t.muted }}>{tr("forge.account_dashboard.cancelled")}</span>
|
|
596
733
|
)}
|
|
597
734
|
{/* Says WHY this is in the list at all. Without it a
|
|
598
735
|
transferred ticket looks like a purchase the buyer cannot
|
|
599
736
|
remember making, with no prices on it. */}
|
|
600
737
|
{!cancelled && gifted && (
|
|
601
|
-
<span style={{ fontSize: 13, color: t.
|
|
738
|
+
<span style={{ fontSize: 13, color: t.muted }}>
|
|
602
739
|
{tr("forge.account_dashboard.ticket_transferred")}
|
|
603
740
|
</span>
|
|
604
741
|
)}
|
|
605
742
|
</div>
|
|
606
743
|
|
|
607
744
|
{ticket.items.map((item) => (
|
|
608
|
-
<div
|
|
745
|
+
<div
|
|
746
|
+
key={item.id}
|
|
747
|
+
style={{
|
|
748
|
+
display: "flex",
|
|
749
|
+
justifyContent: "space-between",
|
|
750
|
+
fontSize: 14,
|
|
751
|
+
padding: "2px 0",
|
|
752
|
+
color: t.muted,
|
|
753
|
+
}}
|
|
754
|
+
>
|
|
609
755
|
<span>
|
|
610
756
|
{tr("forge.account_dashboard.ticket_line", {
|
|
611
757
|
title: item.ticketTitle ?? tr("forge.account_dashboard.ticket_fallback_title"),
|
|
@@ -622,7 +768,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
622
768
|
))}
|
|
623
769
|
|
|
624
770
|
{!gifted && (
|
|
625
|
-
<div style={{ display: "flex", justifyContent: "space-between", fontWeight:
|
|
771
|
+
<div style={{ display: "flex", justifyContent: "space-between", fontWeight: 600, marginTop: 6 }}>
|
|
626
772
|
{/* Nothing has been taken on a pending request, and never
|
|
627
773
|
will be on one that ended: the label says which. */}
|
|
628
774
|
<span>
|
|
@@ -630,7 +776,9 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
630
776
|
? tr("forge.account_dashboard.total_on_approval")
|
|
631
777
|
: tr("forge.account_dashboard.total")}
|
|
632
778
|
</span>
|
|
633
|
-
<span style={{
|
|
779
|
+
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
|
780
|
+
{formatAmount(Number(ticket.totalAmount), ticket.currency || currency)}
|
|
781
|
+
</span>
|
|
634
782
|
</div>
|
|
635
783
|
)}
|
|
636
784
|
|
|
@@ -663,7 +811,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
663
811
|
for a called-off show, and the component draws nothing. */}
|
|
664
812
|
{/* Not offered on a request: the fan has not been admitted to
|
|
665
813
|
anything yet, and a calendar entry says they have. */}
|
|
666
|
-
{!isRequestRow && (
|
|
814
|
+
{!isRequestRow && !eventOver && (
|
|
667
815
|
<AddToCalendar
|
|
668
816
|
variant="compact"
|
|
669
817
|
title={ticket.eventTitle}
|
|
@@ -686,7 +834,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
686
834
|
Eligibility (cancelled order, rotating QR) is the server's
|
|
687
835
|
call, exactly as it is for cancellation above.
|
|
688
836
|
*/}
|
|
689
|
-
<WalletPassButtons passIds={
|
|
837
|
+
{!eventOver && <WalletPassButtons passIds={heldPassIds} variant="compact" style={{ marginTop: 12 }} />}
|
|
690
838
|
|
|
691
839
|
{/*
|
|
692
840
|
Transfer (2.2). The email a transfer sends points at
|
|
@@ -707,11 +855,20 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
707
855
|
own `pendingTransfer`: which is what lets a sender withdraw
|
|
708
856
|
from a device that is not the one they sent from.
|
|
709
857
|
*/}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
style={{ marginTop: 12 }}
|
|
714
|
-
|
|
858
|
+
{/* Folded behind one toggle: a ticket for four people otherwise
|
|
859
|
+
draws four transfer boxes before anything else on the row. */}
|
|
860
|
+
{!eventOver && heldPassIds.length > 0 && (
|
|
861
|
+
<details style={{ marginTop: 12 }}>
|
|
862
|
+
<summary style={{ cursor: "pointer", fontSize: 14, fontWeight: 600, color: t.text }}>
|
|
863
|
+
{tr("forge.account_dashboard.ticket_transfer_toggle", { count: heldPassIds.length })}
|
|
864
|
+
</summary>
|
|
865
|
+
<TicketTransferPanel
|
|
866
|
+
passIds={heldPassIds}
|
|
867
|
+
passes={myTicketPasses(ticket)}
|
|
868
|
+
style={{ marginTop: 12 }}
|
|
869
|
+
/>
|
|
870
|
+
</details>
|
|
871
|
+
)}
|
|
715
872
|
|
|
716
873
|
{/* The terms they agreed to, in the artist's own words where given. */}
|
|
717
874
|
{ticket.cancellation.description && (
|
|
@@ -725,16 +882,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
725
882
|
<button
|
|
726
883
|
onClick={() => void onCancel(ticket)}
|
|
727
884
|
disabled={pendingId === ticket.id}
|
|
728
|
-
style={{
|
|
729
|
-
marginTop: 12,
|
|
730
|
-
padding: "8px 14px",
|
|
731
|
-
borderRadius: t.cornerRadius,
|
|
732
|
-
border: `1px solid ${t.primary}40`,
|
|
733
|
-
background: "transparent",
|
|
734
|
-
color: t.text,
|
|
735
|
-
cursor: pendingId === ticket.id ? "wait" : "pointer",
|
|
736
|
-
fontWeight: 600,
|
|
737
|
-
}}
|
|
885
|
+
style={{ ...ghostButton, marginTop: 12, cursor: pendingId === ticket.id ? "wait" : "pointer" }}
|
|
738
886
|
>
|
|
739
887
|
{pendingId === ticket.id
|
|
740
888
|
? tr("forge.account_dashboard.cancelling")
|
|
@@ -746,8 +894,9 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
746
894
|
})}
|
|
747
895
|
</div>
|
|
748
896
|
) : (
|
|
749
|
-
<
|
|
897
|
+
<EmptySection>{tr("forge.account_dashboard.tickets_empty")}</EmptySection>
|
|
750
898
|
)}
|
|
899
|
+
<ShowMore hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} onMore={() => void fetchNextPage()} />
|
|
751
900
|
</div>
|
|
752
901
|
);
|
|
753
902
|
}
|
|
@@ -770,26 +919,23 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
770
919
|
*/
|
|
771
920
|
function WaitlistTab({ ctx }: { ctx: TabContext }) {
|
|
772
921
|
const { user } = usePublicAuth();
|
|
773
|
-
const {
|
|
922
|
+
const { card, list } = useCardStyles();
|
|
774
923
|
const tr = useForgeT();
|
|
775
924
|
const { formatDate } = ctx;
|
|
776
925
|
const { places, isLoading } = useMyWaitlistPlaces(user?.id);
|
|
777
926
|
|
|
778
927
|
return (
|
|
779
928
|
<div style={card}>
|
|
780
|
-
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
|
|
781
|
-
{tr("forge.account_dashboard.waitlists_title")}
|
|
782
|
-
</h2>
|
|
783
929
|
{isLoading ? (
|
|
784
930
|
<Loading />
|
|
785
931
|
) : places.length > 0 ? (
|
|
786
|
-
<div style={
|
|
932
|
+
<div style={list}>
|
|
787
933
|
{places.map((place) => (
|
|
788
934
|
<WaitlistPlaceRow key={place.id} place={place} formatDate={formatDate} />
|
|
789
935
|
))}
|
|
790
936
|
</div>
|
|
791
937
|
) : (
|
|
792
|
-
<
|
|
938
|
+
<EmptySection>{tr("forge.account_dashboard.waitlists_empty")}</EmptySection>
|
|
793
939
|
)}
|
|
794
940
|
</div>
|
|
795
941
|
);
|
|
@@ -802,7 +948,7 @@ function WaitlistPlaceRow({
|
|
|
802
948
|
place: WaitlistPlace;
|
|
803
949
|
formatDate: (value: string | number | Date) => string;
|
|
804
950
|
}) {
|
|
805
|
-
const { t } = useCardStyles();
|
|
951
|
+
const { t, row, meta, ghostButton, alert } = useCardStyles();
|
|
806
952
|
const tr = useForgeT();
|
|
807
953
|
const leave = useLeaveEventWaitlist(place.eventId);
|
|
808
954
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -822,19 +968,13 @@ function WaitlistPlaceRow({
|
|
|
822
968
|
const deadline = place.offer.claimExpiresAt ? new Date(place.offer.claimExpiresAt) : null;
|
|
823
969
|
|
|
824
970
|
return (
|
|
825
|
-
<div
|
|
826
|
-
style={{
|
|
827
|
-
border: `1px solid ${t.primary}${place.offer.offerOpen ? "80" : "20"}`,
|
|
828
|
-
borderRadius: t.cornerRadius,
|
|
829
|
-
padding: 16,
|
|
830
|
-
}}
|
|
831
|
-
>
|
|
971
|
+
<div style={row}>
|
|
832
972
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
|
|
833
973
|
<div>
|
|
834
|
-
<div style={{ fontWeight: 600 }}>
|
|
974
|
+
<div style={{ fontWeight: 600, fontSize: 16 }}>
|
|
835
975
|
{place.event?.title ?? tr("forge.account_dashboard.waitlist_event_fallback")}
|
|
836
976
|
</div>
|
|
837
|
-
<div style={
|
|
977
|
+
<div style={meta}>
|
|
838
978
|
{tr(
|
|
839
979
|
place.quantity === 1
|
|
840
980
|
? "forge.account_dashboard.waitlist_ticket_line_one"
|
|
@@ -845,21 +985,17 @@ function WaitlistPlaceRow({
|
|
|
845
985
|
},
|
|
846
986
|
)}
|
|
847
987
|
</div>
|
|
848
|
-
{place.event?.dateTime && (
|
|
849
|
-
<div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(place.event.dateTime)}</div>
|
|
850
|
-
)}
|
|
988
|
+
{place.event?.dateTime && <div style={meta}>{formatDate(place.event.dateTime)}</div>}
|
|
851
989
|
</div>
|
|
852
990
|
{place.offer.offerOpen ? (
|
|
853
|
-
<span style={{ fontWeight: 700, color: t.primary }}>
|
|
854
|
-
{tr("forge.account_dashboard.waitlist_offer_open")}
|
|
855
|
-
</span>
|
|
991
|
+
<span style={{ fontWeight: 700, color: t.primary }}>{tr("forge.account_dashboard.waitlist_offer_open")}</span>
|
|
856
992
|
) : place.position != null ? (
|
|
857
|
-
<span style={{ fontSize: 14,
|
|
993
|
+
<span style={{ fontSize: 14, color: t.muted }}>
|
|
858
994
|
{tr("forge.account_dashboard.waitlist_position_label")}{" "}
|
|
859
|
-
<strong style={{ color: t.
|
|
995
|
+
<strong style={{ color: t.text }}>#{place.position}</strong>
|
|
860
996
|
</span>
|
|
861
997
|
) : (
|
|
862
|
-
<span style={{ fontSize: 14,
|
|
998
|
+
<span style={{ fontSize: 14, color: t.muted }}>{tr("forge.account_dashboard.waitlist_on_list")}</span>
|
|
863
999
|
)}
|
|
864
1000
|
</div>
|
|
865
1001
|
|
|
@@ -867,9 +1003,9 @@ function WaitlistPlaceRow({
|
|
|
867
1003
|
<div
|
|
868
1004
|
style={{
|
|
869
1005
|
marginTop: 12,
|
|
870
|
-
padding: "10px
|
|
871
|
-
|
|
872
|
-
|
|
1006
|
+
padding: "10px 14px",
|
|
1007
|
+
borderLeft: `3px solid ${t.primary}`,
|
|
1008
|
+
background: t.surface,
|
|
873
1009
|
fontSize: 14,
|
|
874
1010
|
}}
|
|
875
1011
|
>
|
|
@@ -891,21 +1027,16 @@ function WaitlistPlaceRow({
|
|
|
891
1027
|
</div>
|
|
892
1028
|
)}
|
|
893
1029
|
|
|
894
|
-
{error &&
|
|
1030
|
+
{error && (
|
|
1031
|
+
<p role="alert" style={alert}>
|
|
1032
|
+
{error}
|
|
1033
|
+
</p>
|
|
1034
|
+
)}
|
|
895
1035
|
|
|
896
1036
|
<button
|
|
897
1037
|
onClick={() => void onLeave()}
|
|
898
1038
|
disabled={leave.isPending}
|
|
899
|
-
style={{
|
|
900
|
-
marginTop: 12,
|
|
901
|
-
padding: "8px 14px",
|
|
902
|
-
borderRadius: t.cornerRadius,
|
|
903
|
-
border: `1px solid ${t.primary}40`,
|
|
904
|
-
background: "transparent",
|
|
905
|
-
color: t.text,
|
|
906
|
-
cursor: leave.isPending ? "wait" : "pointer",
|
|
907
|
-
fontWeight: 600,
|
|
908
|
-
}}
|
|
1039
|
+
style={{ ...ghostButton, marginTop: 12, cursor: leave.isPending ? "wait" : "pointer" }}
|
|
909
1040
|
>
|
|
910
1041
|
{leave.isPending
|
|
911
1042
|
? tr("forge.account_dashboard.waitlist_leaving")
|
|
@@ -940,28 +1071,26 @@ function WaitlistPlaceRow({
|
|
|
940
1071
|
*/
|
|
941
1072
|
function BookingsTab({ ctx }: { ctx: TabContext }) {
|
|
942
1073
|
const { user } = usePublicAuth();
|
|
943
|
-
const {
|
|
1074
|
+
const { card, list } = useCardStyles();
|
|
944
1075
|
const tr = useForgeT();
|
|
945
|
-
const { data, isLoading } =
|
|
1076
|
+
const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = useMyBookingsPaged(user?.id);
|
|
946
1077
|
|
|
947
|
-
const bookings = data?.data ?? [];
|
|
1078
|
+
const bookings = data?.pages.flatMap((page) => page.data) ?? [];
|
|
948
1079
|
|
|
949
1080
|
return (
|
|
950
1081
|
<div style={card}>
|
|
951
|
-
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
|
|
952
|
-
{tr("forge.account_dashboard.sessions_title")}
|
|
953
|
-
</h2>
|
|
954
1082
|
{isLoading ? (
|
|
955
1083
|
<Loading />
|
|
956
1084
|
) : bookings.length > 0 ? (
|
|
957
|
-
<div style={
|
|
1085
|
+
<div style={list}>
|
|
958
1086
|
{bookings.map((booking) => (
|
|
959
1087
|
<BookingRow key={booking.id} booking={booking} ctx={ctx} />
|
|
960
1088
|
))}
|
|
961
1089
|
</div>
|
|
962
1090
|
) : (
|
|
963
|
-
<
|
|
1091
|
+
<EmptySection>{tr("forge.account_dashboard.sessions_empty")}</EmptySection>
|
|
964
1092
|
)}
|
|
1093
|
+
<ShowMore hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} onMore={() => void fetchNextPage()} />
|
|
965
1094
|
</div>
|
|
966
1095
|
);
|
|
967
1096
|
}
|
|
@@ -1005,20 +1134,14 @@ function formatSessionWindow(booking: MyBooking, tr: ForgeT): string {
|
|
|
1005
1134
|
* apply here: which is the same discipline `canReschedule` follows two blocks
|
|
1006
1135
|
* down.
|
|
1007
1136
|
*/
|
|
1008
|
-
function BookingLocationLine({
|
|
1009
|
-
location,
|
|
1010
|
-
primary,
|
|
1011
|
-
}: {
|
|
1012
|
-
location: MyBooking["location"];
|
|
1013
|
-
primary: string;
|
|
1014
|
-
}) {
|
|
1137
|
+
function BookingLocationLine({ location, primary }: { location: MyBooking["location"]; primary: string }) {
|
|
1015
1138
|
const tr = useForgeT();
|
|
1016
1139
|
if (!location) return null;
|
|
1017
1140
|
|
|
1018
1141
|
if (location.type === "online") {
|
|
1019
1142
|
return (
|
|
1020
1143
|
<div style={{ fontSize: 13, marginTop: 4 }}>
|
|
1021
|
-
<span style={{ opacity: 0.7 }}>{tr("forge.account_dashboard.booking_join_label")}
|
|
1144
|
+
<span style={{ opacity: 0.7 }}>{tr("forge.account_dashboard.booking_join_label")} </span>
|
|
1022
1145
|
<a
|
|
1023
1146
|
href={location.url}
|
|
1024
1147
|
target="_blank"
|
|
@@ -1043,7 +1166,7 @@ function BookingLocationLine({
|
|
|
1043
1166
|
if (location.type === "video") {
|
|
1044
1167
|
return (
|
|
1045
1168
|
<div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
|
|
1046
|
-
<span style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.booking_join_label")}
|
|
1169
|
+
<span style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.booking_join_label")} </span>
|
|
1047
1170
|
{tr("forge.account_dashboard.booking_video_call")}
|
|
1048
1171
|
</div>
|
|
1049
1172
|
);
|
|
@@ -1051,30 +1174,29 @@ function BookingLocationLine({
|
|
|
1051
1174
|
|
|
1052
1175
|
return (
|
|
1053
1176
|
<div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
|
|
1054
|
-
<span style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.booking_where_label")}
|
|
1177
|
+
<span style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.booking_where_label")} </span>
|
|
1055
1178
|
{location.address}
|
|
1056
1179
|
</div>
|
|
1057
1180
|
);
|
|
1058
1181
|
}
|
|
1059
1182
|
|
|
1060
1183
|
function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
|
|
1061
|
-
const { t } = useCardStyles();
|
|
1184
|
+
const { t, row, meta, ghostButton, alert } = useCardStyles();
|
|
1062
1185
|
const tr = useForgeT();
|
|
1063
1186
|
const { formatAmount, currency } = ctx;
|
|
1064
1187
|
const cancelBooking = useCancelMyBooking();
|
|
1065
1188
|
const [picking, setPicking] = useState(false);
|
|
1066
1189
|
const [error, setError] = useState<string | null>(null);
|
|
1067
1190
|
const cancelled = booking.status === "canceled" || !!booking.selfCancelledAt;
|
|
1191
|
+
// A session that is over keeps its record, not its way in: no calendar
|
|
1192
|
+
// entry and no "the room has closed" line on something that already happened.
|
|
1193
|
+
const endedAt = new Date(booking.sessionEndTime).getTime();
|
|
1194
|
+
const sessionOver = endedAt < Date.now();
|
|
1195
|
+
// "The room has closed" still helps the day someone missed it; after that it
|
|
1196
|
+
// is noise on a list of history.
|
|
1197
|
+
const callNoteExpired = endedAt < Date.now() - 24 * 3600_000;
|
|
1068
1198
|
|
|
1069
|
-
const actionButton
|
|
1070
|
-
padding: "8px 14px",
|
|
1071
|
-
borderRadius: t.cornerRadius,
|
|
1072
|
-
border: `1px solid ${t.primary}40`,
|
|
1073
|
-
background: "transparent",
|
|
1074
|
-
color: t.text,
|
|
1075
|
-
cursor: "pointer",
|
|
1076
|
-
fontWeight: 600,
|
|
1077
|
-
};
|
|
1199
|
+
const actionButton = ghostButton;
|
|
1078
1200
|
|
|
1079
1201
|
const onCancel = async () => {
|
|
1080
1202
|
setError(null);
|
|
@@ -1092,31 +1214,22 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
|
|
|
1092
1214
|
};
|
|
1093
1215
|
|
|
1094
1216
|
return (
|
|
1095
|
-
<div
|
|
1096
|
-
style={{
|
|
1097
|
-
border: `1px solid ${t.primary}20`,
|
|
1098
|
-
borderRadius: t.cornerRadius,
|
|
1099
|
-
padding: 16,
|
|
1100
|
-
opacity: cancelled ? 0.6 : 1,
|
|
1101
|
-
}}
|
|
1102
|
-
>
|
|
1217
|
+
<div data-testid="my-booking" style={{ ...row, opacity: cancelled ? 0.6 : 1 }}>
|
|
1103
1218
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
|
|
1104
1219
|
<div>
|
|
1105
|
-
<div style={{ fontWeight: 600 }}>{booking.coachingProductTitle}</div>
|
|
1106
|
-
<div style={
|
|
1220
|
+
<div style={{ fontWeight: 600, fontSize: 16 }}>{booking.coachingProductTitle}</div>
|
|
1221
|
+
<div style={meta}>{formatSessionWindow(booking, tr)}</div>
|
|
1107
1222
|
{booking.coachingProductDurationMinutes != null && (
|
|
1108
|
-
<div style={
|
|
1223
|
+
<div style={meta}>
|
|
1109
1224
|
{tr("forge.account_dashboard.booking_duration", { minutes: booking.coachingProductDurationMinutes })}
|
|
1110
1225
|
</div>
|
|
1111
1226
|
)}
|
|
1112
1227
|
<BookingLocationLine location={booking.location} primary={t.primary} />
|
|
1113
1228
|
</div>
|
|
1114
1229
|
<div style={{ textAlign: "right" }}>
|
|
1115
|
-
{cancelled && (
|
|
1116
|
-
<div style={{ fontSize: 13, color: t.primary }}>{tr("forge.account_dashboard.cancelled")}</div>
|
|
1117
|
-
)}
|
|
1230
|
+
{cancelled && <div style={{ fontSize: 13, color: t.muted }}>{tr("forge.account_dashboard.cancelled")}</div>}
|
|
1118
1231
|
{booking.totalAmount != null && (
|
|
1119
|
-
<div style={{ fontWeight:
|
|
1232
|
+
<div style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
|
|
1120
1233
|
{formatAmount(booking.totalAmount, booking.currency || currency)}
|
|
1121
1234
|
</div>
|
|
1122
1235
|
)}
|
|
@@ -1125,16 +1238,18 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
|
|
|
1125
1238
|
|
|
1126
1239
|
{/* Diarise it: including Apple, which needs the platform's own `.ics`.
|
|
1127
1240
|
Null unless the session is confirmed, and the component draws nothing. */}
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1241
|
+
{!sessionOver && (
|
|
1242
|
+
<AddToCalendar
|
|
1243
|
+
variant="compact"
|
|
1244
|
+
title={booking.coachingProductTitle}
|
|
1245
|
+
start={booking.sessionStartTime}
|
|
1246
|
+
end={booking.sessionEndTime}
|
|
1247
|
+
defaultDurationMinutes={booking.coachingProductDurationMinutes ?? undefined}
|
|
1248
|
+
icsUrl={booking.calendarUrl}
|
|
1249
|
+
webcalUrl={booking.calendarWebcalUrl}
|
|
1250
|
+
style={{ marginTop: 12 }}
|
|
1251
|
+
/>
|
|
1252
|
+
)}
|
|
1138
1253
|
|
|
1139
1254
|
{/* The terms they booked under, in the artist's own words where given. */}
|
|
1140
1255
|
{booking.cancellation.description && (
|
|
@@ -1145,9 +1260,11 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
|
|
|
1145
1260
|
)}
|
|
1146
1261
|
{/* A reschedule rule of its own. When it follows the cancellation window
|
|
1147
1262
|
the sentence above already says it, so nothing is repeated. */}
|
|
1148
|
-
{booking.reschedulePolicy &&
|
|
1149
|
-
|
|
1150
|
-
|
|
1263
|
+
{booking.reschedulePolicy &&
|
|
1264
|
+
!booking.reschedulePolicy.followsCancellation &&
|
|
1265
|
+
booking.reschedulePolicy.description && (
|
|
1266
|
+
<div style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>{booking.reschedulePolicy.description}</div>
|
|
1267
|
+
)}
|
|
1151
1268
|
|
|
1152
1269
|
{/*
|
|
1153
1270
|
The action row. Both controls are drawn from the SERVER's flags:
|
|
@@ -1155,7 +1272,7 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
|
|
|
1155
1272
|
re-derived in the browser, which would give two answers to one question.
|
|
1156
1273
|
*/}
|
|
1157
1274
|
{error && (
|
|
1158
|
-
<p style={
|
|
1275
|
+
<p style={alert} role="alert">
|
|
1159
1276
|
{error}
|
|
1160
1277
|
</p>
|
|
1161
1278
|
)}
|
|
@@ -1183,7 +1300,7 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
|
|
|
1183
1300
|
|
|
1184
1301
|
{/* The way INTO a video session: a Join control while the room is open,
|
|
1185
1302
|
the call itself once pressed. Nothing for any other kind of session. */}
|
|
1186
|
-
{!cancelled && <BookingCallEntry booking={booking} />}
|
|
1303
|
+
{!cancelled && !callNoteExpired && <BookingCallEntry booking={booking} />}
|
|
1187
1304
|
</div>
|
|
1188
1305
|
);
|
|
1189
1306
|
}
|
|
@@ -1322,7 +1439,15 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
|
|
|
1322
1439
|
const open = (slots ?? []).filter((slot) => slot.id !== booking.coachingBookingSlotId);
|
|
1323
1440
|
|
|
1324
1441
|
return (
|
|
1325
|
-
<div
|
|
1442
|
+
<div
|
|
1443
|
+
style={{
|
|
1444
|
+
marginTop: 12,
|
|
1445
|
+
padding: 16,
|
|
1446
|
+
borderRadius: t.cornerRadius,
|
|
1447
|
+
border: `1px solid ${t.border}`,
|
|
1448
|
+
background: t.surface,
|
|
1449
|
+
}}
|
|
1450
|
+
>
|
|
1326
1451
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12 }}>
|
|
1327
1452
|
<strong style={{ fontSize: 14 }}>{tr("forge.account_dashboard.reschedule_title")}</strong>
|
|
1328
1453
|
<button
|
|
@@ -1330,7 +1455,7 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
|
|
|
1330
1455
|
style={{
|
|
1331
1456
|
padding: "4px 10px",
|
|
1332
1457
|
borderRadius: t.cornerRadius,
|
|
1333
|
-
border: `1px solid ${t.
|
|
1458
|
+
border: `1px solid ${t.border}`,
|
|
1334
1459
|
background: "transparent",
|
|
1335
1460
|
color: t.text,
|
|
1336
1461
|
cursor: "pointer",
|
|
@@ -1342,7 +1467,7 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
|
|
|
1342
1467
|
</div>
|
|
1343
1468
|
|
|
1344
1469
|
{error && (
|
|
1345
|
-
<p style={{
|
|
1470
|
+
<p style={{ fontSize: 14, fontWeight: 600, marginTop: 10 }} role="alert">
|
|
1346
1471
|
{error}
|
|
1347
1472
|
</p>
|
|
1348
1473
|
)}
|
|
@@ -1362,8 +1487,8 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
|
|
|
1362
1487
|
style={{
|
|
1363
1488
|
padding: "8px 12px",
|
|
1364
1489
|
borderRadius: t.cornerRadius,
|
|
1365
|
-
border: `1px solid ${t.
|
|
1366
|
-
background:
|
|
1490
|
+
border: `1px solid ${t.border}`,
|
|
1491
|
+
background: t.background,
|
|
1367
1492
|
color: t.text,
|
|
1368
1493
|
cursor: reschedule.isPending ? "wait" : "pointer",
|
|
1369
1494
|
fontSize: 13,
|
|
@@ -1384,9 +1509,7 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
|
|
|
1384
1509
|
})}
|
|
1385
1510
|
</div>
|
|
1386
1511
|
) : (
|
|
1387
|
-
<p style={{ fontSize: 13, opacity: 0.7, marginTop: 12 }}>
|
|
1388
|
-
{tr("forge.account_dashboard.reschedule_empty")}
|
|
1389
|
-
</p>
|
|
1512
|
+
<p style={{ fontSize: 13, opacity: 0.7, marginTop: 12 }}>{tr("forge.account_dashboard.reschedule_empty")}</p>
|
|
1390
1513
|
)}
|
|
1391
1514
|
</div>
|
|
1392
1515
|
);
|
|
@@ -1408,15 +1531,10 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
|
|
|
1408
1531
|
* That decision lives in one place, `FilmRentalList`, and both surfaces read it.
|
|
1409
1532
|
*/
|
|
1410
1533
|
function RentalsTab({ ctx }: { ctx: TabContext }) {
|
|
1411
|
-
const {
|
|
1412
|
-
const tr = useForgeT();
|
|
1413
|
-
|
|
1534
|
+
const { card } = useCardStyles();
|
|
1414
1535
|
return (
|
|
1415
1536
|
<div style={card}>
|
|
1416
|
-
<
|
|
1417
|
-
{tr("forge.account_dashboard.rentals_title")}
|
|
1418
|
-
</h2>
|
|
1419
|
-
<FilmRentalList navigate={ctx.onNavigateFilm} />
|
|
1537
|
+
<FilmRentalList navigate={ctx.onNavigateFilm} layout="rows" />
|
|
1420
1538
|
</div>
|
|
1421
1539
|
);
|
|
1422
1540
|
}
|
|
@@ -1432,46 +1550,50 @@ function RentalsTab({ ctx }: { ctx: TabContext }) {
|
|
|
1432
1550
|
* the question this whole tab exists to stop someone asking.
|
|
1433
1551
|
*/
|
|
1434
1552
|
function CoursesTab({ ctx, courses }: { ctx: TabContext; courses: MyCourse[] }) {
|
|
1435
|
-
const {
|
|
1553
|
+
const { card, list, row, meta, button, ghostButton } = useCardStyles();
|
|
1436
1554
|
const tr = useForgeT();
|
|
1437
1555
|
|
|
1438
1556
|
return (
|
|
1439
1557
|
<div style={card}>
|
|
1440
|
-
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
|
|
1441
|
-
{tr("forge.account_dashboard.courses_title")}
|
|
1442
|
-
</h2>
|
|
1443
|
-
|
|
1444
1558
|
{courses.length === 0 ? (
|
|
1445
|
-
<
|
|
1559
|
+
<EmptySection>{tr("forge.account_dashboard.courses_empty")}</EmptySection>
|
|
1446
1560
|
) : (
|
|
1447
|
-
<div style={
|
|
1561
|
+
<div style={list}>
|
|
1448
1562
|
{courses.map((course) => (
|
|
1449
|
-
<
|
|
1563
|
+
<div
|
|
1450
1564
|
key={course.accessId}
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
style={{
|
|
1454
|
-
textAlign: "left",
|
|
1455
|
-
background: t.surface,
|
|
1456
|
-
border: `1px solid ${t.border}`,
|
|
1457
|
-
borderRadius: t.cornerRadius,
|
|
1458
|
-
padding: 16,
|
|
1459
|
-
cursor: ctx.onNavigateCourse ? "pointer" : "default",
|
|
1460
|
-
color: t.text,
|
|
1461
|
-
}}
|
|
1565
|
+
data-testid="my-course"
|
|
1566
|
+
style={{ ...row, display: "flex", alignItems: "center", gap: 16, flexWrap: "wrap" }}
|
|
1462
1567
|
>
|
|
1463
|
-
<div style={{
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
{
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1568
|
+
<div style={{ flex: "1 1 220px", minWidth: 0 }}>
|
|
1569
|
+
<div style={{ fontWeight: 600, fontSize: 16 }}>{course.title}</div>
|
|
1570
|
+
{/* No description. It is rich text: as a string it shows its own
|
|
1571
|
+
markup, as HTML it drags a sales page's formatting into a list
|
|
1572
|
+
row. The row carries the title and the state instead. */}
|
|
1573
|
+
<div style={meta}>
|
|
1574
|
+
{course.completedAt
|
|
1575
|
+
? tr("forge.account_dashboard.courses_completed")
|
|
1576
|
+
: course.currentLessonId
|
|
1577
|
+
? tr("forge.account_dashboard.courses_continue")
|
|
1578
|
+
: tr("forge.account_dashboard.courses_start")}
|
|
1579
|
+
</div>
|
|
1473
1580
|
</div>
|
|
1474
|
-
|
|
1581
|
+
{ctx.onNavigateCourse && (
|
|
1582
|
+
<button
|
|
1583
|
+
type="button"
|
|
1584
|
+
onClick={() => ctx.onNavigateCourse?.(course)}
|
|
1585
|
+
// Finished courses are history, so only an unfinished one
|
|
1586
|
+
// gets the filled button.
|
|
1587
|
+
style={course.completedAt ? ghostButton : button}
|
|
1588
|
+
>
|
|
1589
|
+
{course.completedAt
|
|
1590
|
+
? tr("forge.account_dashboard.course_open_review")
|
|
1591
|
+
: course.currentLessonId
|
|
1592
|
+
? tr("forge.account_dashboard.course_open_continue")
|
|
1593
|
+
: tr("forge.account_dashboard.course_open_start")}
|
|
1594
|
+
</button>
|
|
1595
|
+
)}
|
|
1596
|
+
</div>
|
|
1475
1597
|
))}
|
|
1476
1598
|
</div>
|
|
1477
1599
|
)}
|
|
@@ -1483,38 +1605,61 @@ function CoursesTab({ ctx, courses }: { ctx: TabContext; courses: MyCourse[] })
|
|
|
1483
1605
|
|
|
1484
1606
|
function OrdersTab({ ctx }: { ctx: TabContext }) {
|
|
1485
1607
|
const { user } = usePublicAuth();
|
|
1486
|
-
const { t, card } = useCardStyles();
|
|
1608
|
+
const { t, card, list, row, meta } = useCardStyles();
|
|
1487
1609
|
const tr = useForgeT();
|
|
1488
1610
|
const { formatAmount, formatDate, currency } = ctx;
|
|
1489
|
-
const { data
|
|
1611
|
+
const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = useUserOrdersPaged(user?.id);
|
|
1612
|
+
const orders = data?.pages.flat();
|
|
1490
1613
|
|
|
1491
1614
|
const formatStatus = (s: OrderStatus) => s.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
|
1492
1615
|
|
|
1493
1616
|
return (
|
|
1494
1617
|
<div style={card}>
|
|
1495
|
-
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
|
|
1496
|
-
{tr("forge.account_dashboard.orders_title")}
|
|
1497
|
-
</h2>
|
|
1498
1618
|
{isLoading ? (
|
|
1499
1619
|
<Loading />
|
|
1500
1620
|
) : orders && orders.length > 0 ? (
|
|
1501
|
-
<div style={
|
|
1621
|
+
<div style={list}>
|
|
1502
1622
|
{orders
|
|
1503
1623
|
.filter((o) => ![OrderStatus.InitiatedPayment, OrderStatus.PaymentFailed].includes(o.status))
|
|
1504
1624
|
.map((order) => (
|
|
1505
|
-
<div key={order.id} style={
|
|
1506
|
-
<div
|
|
1625
|
+
<div key={order.id} data-testid="my-order" style={row}>
|
|
1626
|
+
<div
|
|
1627
|
+
style={{
|
|
1628
|
+
display: "flex",
|
|
1629
|
+
justifyContent: "space-between",
|
|
1630
|
+
alignItems: "flex-start",
|
|
1631
|
+
gap: 12,
|
|
1632
|
+
marginBottom: 8,
|
|
1633
|
+
}}
|
|
1634
|
+
>
|
|
1507
1635
|
<div>
|
|
1508
|
-
|
|
1509
|
-
|
|
1636
|
+
{/* The first block of the id is enough to tell orders apart
|
|
1637
|
+
and to quote in an email; the whole uuid is noise. */}
|
|
1638
|
+
<div style={{ fontWeight: 600, fontSize: 16 }} title={order.id}>
|
|
1639
|
+
{tr("forge.account_dashboard.order_number", { order_id: order.id.slice(0, 8).toUpperCase() })}
|
|
1640
|
+
</div>
|
|
1641
|
+
<div className="fg:flex fg:flex-wrap fg:gap-x-3" style={meta}>
|
|
1642
|
+
<span>{formatDate(order.createdAt)}</span>
|
|
1643
|
+
<span>{formatStatus(order.status)}</span>
|
|
1510
1644
|
</div>
|
|
1511
|
-
<div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(order.createdAt)}</div>
|
|
1512
1645
|
</div>
|
|
1513
|
-
<span style={{
|
|
1646
|
+
<span style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
|
|
1647
|
+
{formatAmount(order.totalAmount, order.currency || currency)}
|
|
1648
|
+
</span>
|
|
1514
1649
|
</div>
|
|
1515
1650
|
{order.deliveryGroups.map((group) =>
|
|
1516
1651
|
group.items.map((item, i) => (
|
|
1517
|
-
<div
|
|
1652
|
+
<div
|
|
1653
|
+
key={`${item.productVariantId}-${i}`}
|
|
1654
|
+
style={{
|
|
1655
|
+
display: "flex",
|
|
1656
|
+
justifyContent: "space-between",
|
|
1657
|
+
gap: 12,
|
|
1658
|
+
fontSize: 14,
|
|
1659
|
+
padding: "2px 0",
|
|
1660
|
+
color: t.muted,
|
|
1661
|
+
}}
|
|
1662
|
+
>
|
|
1518
1663
|
<span>
|
|
1519
1664
|
{tr("forge.account_dashboard.order_line", { title: item.title, quantity: item.quantity })}
|
|
1520
1665
|
{/* Which version. Two versions of one release carry the
|
|
@@ -1524,20 +1669,19 @@ function OrdersTab({ ctx }: { ctx: TabContext }) {
|
|
|
1524
1669
|
the sale, not re-resolved. */}
|
|
1525
1670
|
<CartLineOptions item={item} accent={t.primary} size="small" style={{ marginTop: 2 }} />
|
|
1526
1671
|
</span>
|
|
1527
|
-
<span
|
|
1672
|
+
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
|
1673
|
+
{formatAmount(item.price * item.quantity, order.currency || currency)}
|
|
1674
|
+
</span>
|
|
1528
1675
|
</div>
|
|
1529
1676
|
)),
|
|
1530
1677
|
)}
|
|
1531
|
-
<div style={{ display: "flex", justifyContent: "space-between", fontWeight: 700, marginTop: 12, paddingTop: 12, borderTop: `1px solid ${t.primary}20` }}>
|
|
1532
|
-
<span>{tr("forge.account_dashboard.total")}</span>
|
|
1533
|
-
<span style={{ color: t.primary }}>{formatAmount(order.totalAmount, order.currency || currency)}</span>
|
|
1534
|
-
</div>
|
|
1535
1678
|
</div>
|
|
1536
1679
|
))}
|
|
1537
1680
|
</div>
|
|
1538
1681
|
) : (
|
|
1539
|
-
<
|
|
1682
|
+
<EmptySection>{tr("forge.account_dashboard.orders_empty")}</EmptySection>
|
|
1540
1683
|
)}
|
|
1684
|
+
<ShowMore hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} onMore={() => void fetchNextPage()} />
|
|
1541
1685
|
</div>
|
|
1542
1686
|
);
|
|
1543
1687
|
}
|
|
@@ -1546,31 +1690,28 @@ function OrdersTab({ ctx }: { ctx: TabContext }) {
|
|
|
1546
1690
|
|
|
1547
1691
|
function SavedTab() {
|
|
1548
1692
|
const { user } = usePublicAuth();
|
|
1549
|
-
const {
|
|
1693
|
+
const { card, list, row, meta } = useCardStyles();
|
|
1550
1694
|
const tr = useForgeT();
|
|
1551
1695
|
const { data, isLoading } = useSavedPosts(user?.id);
|
|
1552
1696
|
const posts = data?.data;
|
|
1553
1697
|
|
|
1554
1698
|
return (
|
|
1555
1699
|
<div style={card}>
|
|
1556
|
-
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
|
|
1557
|
-
{tr("forge.account_dashboard.saved_title")}
|
|
1558
|
-
</h2>
|
|
1559
1700
|
{isLoading ? (
|
|
1560
1701
|
<Loading />
|
|
1561
1702
|
) : posts && posts.length > 0 ? (
|
|
1562
|
-
<div style={
|
|
1703
|
+
<div style={list}>
|
|
1563
1704
|
{posts.map((post) => (
|
|
1564
|
-
<div key={post.id} style={
|
|
1565
|
-
<p style={{ fontSize:
|
|
1566
|
-
<p style={{
|
|
1705
|
+
<div key={post.id} style={row}>
|
|
1706
|
+
<p style={{ fontSize: 15, margin: "0 0 4px" }}>{post.caption}</p>
|
|
1707
|
+
<p style={{ ...meta, margin: 0 }}>
|
|
1567
1708
|
{tr("forge.account_dashboard.saved_post_stats", { likes: post.likes, comments: post.comments })}
|
|
1568
1709
|
</p>
|
|
1569
1710
|
</div>
|
|
1570
1711
|
))}
|
|
1571
1712
|
</div>
|
|
1572
1713
|
) : (
|
|
1573
|
-
<
|
|
1714
|
+
<EmptySection>{tr("forge.account_dashboard.saved_empty")}</EmptySection>
|
|
1574
1715
|
)}
|
|
1575
1716
|
</div>
|
|
1576
1717
|
);
|
|
@@ -1580,7 +1721,7 @@ function SavedTab() {
|
|
|
1580
1721
|
|
|
1581
1722
|
function AccountTab({ ctx }: { ctx: TabContext }) {
|
|
1582
1723
|
const { user, refetchUser, logout } = usePublicAuth();
|
|
1583
|
-
const { t,
|
|
1724
|
+
const { t, button, ghostButton, input, meta } = useCardStyles();
|
|
1584
1725
|
const tr = useForgeT();
|
|
1585
1726
|
const { formatDate } = ctx;
|
|
1586
1727
|
|
|
@@ -1643,15 +1784,23 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
|
|
|
1643
1784
|
}
|
|
1644
1785
|
};
|
|
1645
1786
|
|
|
1646
|
-
|
|
1647
|
-
|
|
1787
|
+
// Each part of the page is a titled block divided by a hairline: the
|
|
1788
|
+
// portal's sections, not four boxes stacked on top of each other.
|
|
1789
|
+
const card: React.CSSProperties = { padding: "28px 0", borderBottom: `1px solid ${t.border}` };
|
|
1790
|
+
const sectionTitle: React.CSSProperties = {
|
|
1791
|
+
fontSize: 18,
|
|
1792
|
+
fontWeight: 600,
|
|
1793
|
+
margin: "0 0 16px",
|
|
1794
|
+
fontFamily: t.headingFontFamily,
|
|
1795
|
+
};
|
|
1796
|
+
const label: React.CSSProperties = { fontSize: 14, fontWeight: 600, display: "block", marginBottom: 6 };
|
|
1648
1797
|
|
|
1649
1798
|
return (
|
|
1650
|
-
<div style={{ display: "flex", flexDirection: "column",
|
|
1799
|
+
<div style={{ display: "flex", flexDirection: "column", borderTop: `1px solid ${t.border}`, color: t.text }}>
|
|
1651
1800
|
{/* Profile */}
|
|
1652
1801
|
<form onSubmit={onUpdateProfile} style={card}>
|
|
1653
1802
|
<h2 style={sectionTitle}>{tr("forge.account_dashboard.profile_section_title")}</h2>
|
|
1654
|
-
<div
|
|
1803
|
+
<div className="fg:flex fg:flex-col fg:gap-3 fg:sm:flex-row" style={{ marginBottom: 12 }}>
|
|
1655
1804
|
<div style={{ flex: 1 }}>
|
|
1656
1805
|
<label style={label}>{tr("forge.account_dashboard.first_name")}</label>
|
|
1657
1806
|
<input value={firstName} onChange={(e) => setFirstName(e.target.value)} style={input} />
|
|
@@ -1661,7 +1810,7 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
|
|
|
1661
1810
|
<input value={lastName} onChange={(e) => setLastName(e.target.value)} style={input} />
|
|
1662
1811
|
</div>
|
|
1663
1812
|
</div>
|
|
1664
|
-
<p style={{
|
|
1813
|
+
<p style={{ ...meta, margin: "0 0 16px" }}>
|
|
1665
1814
|
{tr("forge.account_dashboard.email_readonly", { email: user?.email ?? "" })}
|
|
1666
1815
|
</p>
|
|
1667
1816
|
{profileMsg && <p style={{ fontSize: 13, marginBottom: 12 }}>{profileMsg}</p>}
|
|
@@ -1673,20 +1822,39 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
|
|
|
1673
1822
|
{/* Password */}
|
|
1674
1823
|
<form onSubmit={onChangePassword} style={card}>
|
|
1675
1824
|
<h2 style={sectionTitle}>{tr("forge.account_dashboard.password_section_title")}</h2>
|
|
1676
|
-
{passwordMsg &&
|
|
1825
|
+
{passwordMsg && (
|
|
1826
|
+
<p role="alert" style={{ fontSize: 14, fontWeight: 600, margin: "0 0 12px" }}>
|
|
1827
|
+
{passwordMsg}
|
|
1828
|
+
</p>
|
|
1829
|
+
)}
|
|
1677
1830
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
1678
1831
|
<div>
|
|
1679
1832
|
<label style={label}>{tr("forge.account_dashboard.current_password")}</label>
|
|
1680
|
-
<input
|
|
1833
|
+
<input
|
|
1834
|
+
type="password"
|
|
1835
|
+
value={currentPassword}
|
|
1836
|
+
onChange={(e) => setCurrentPassword(e.target.value)}
|
|
1837
|
+
style={input}
|
|
1838
|
+
/>
|
|
1681
1839
|
</div>
|
|
1682
|
-
<div
|
|
1840
|
+
<div className="fg:flex fg:flex-col fg:gap-3 fg:sm:flex-row">
|
|
1683
1841
|
<div style={{ flex: 1 }}>
|
|
1684
1842
|
<label style={label}>{tr("forge.account_dashboard.new_password")}</label>
|
|
1685
|
-
<input
|
|
1843
|
+
<input
|
|
1844
|
+
type="password"
|
|
1845
|
+
value={newPassword}
|
|
1846
|
+
onChange={(e) => setNewPassword(e.target.value)}
|
|
1847
|
+
style={input}
|
|
1848
|
+
/>
|
|
1686
1849
|
</div>
|
|
1687
1850
|
<div style={{ flex: 1 }}>
|
|
1688
1851
|
<label style={label}>{tr("forge.account_dashboard.confirm_password")}</label>
|
|
1689
|
-
<input
|
|
1852
|
+
<input
|
|
1853
|
+
type="password"
|
|
1854
|
+
value={confirmPassword}
|
|
1855
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
1856
|
+
style={input}
|
|
1857
|
+
/>
|
|
1690
1858
|
</div>
|
|
1691
1859
|
</div>
|
|
1692
1860
|
</div>
|
|
@@ -1698,17 +1866,15 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
|
|
|
1698
1866
|
{/* Data rights */}
|
|
1699
1867
|
<div style={card}>
|
|
1700
1868
|
<h2 style={sectionTitle}>{tr("forge.account_dashboard.data_section_title")}</h2>
|
|
1701
|
-
<p style={{
|
|
1702
|
-
{tr("forge.account_dashboard.data_export_description")}
|
|
1703
|
-
</p>
|
|
1869
|
+
<p style={{ ...meta, margin: "0 0 12px" }}>{tr("forge.account_dashboard.data_export_description")}</p>
|
|
1704
1870
|
<button onClick={onExport} style={ghostButton}>
|
|
1705
1871
|
{tr("forge.account_dashboard.export_data")}
|
|
1706
1872
|
</button>
|
|
1707
1873
|
</div>
|
|
1708
1874
|
|
|
1709
1875
|
{/* Delete account */}
|
|
1710
|
-
<div style={{ ...card,
|
|
1711
|
-
<h2 style={
|
|
1876
|
+
<div style={{ ...card, borderBottom: "none" }}>
|
|
1877
|
+
<h2 style={sectionTitle}>{tr("forge.account_dashboard.delete_section_title")}</h2>
|
|
1712
1878
|
{scheduledAt ? (
|
|
1713
1879
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
1714
1880
|
<p style={{ fontSize: 14 }}>
|
|
@@ -1722,7 +1888,7 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
|
|
|
1722
1888
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
1723
1889
|
<p style={{ fontSize: 14 }}>{tr("forge.account_dashboard.deletion_confirm_body")}</p>
|
|
1724
1890
|
<div style={{ display: "flex", gap: 12 }}>
|
|
1725
|
-
<button onClick={() => requestDeletion.mutate()} style={
|
|
1891
|
+
<button onClick={() => requestDeletion.mutate()} style={button}>
|
|
1726
1892
|
{tr("forge.account_dashboard.deletion_confirm")}
|
|
1727
1893
|
</button>
|
|
1728
1894
|
<button onClick={() => setConfirmDelete(false)} style={ghostButton}>
|
|
@@ -1731,10 +1897,7 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
|
|
|
1731
1897
|
</div>
|
|
1732
1898
|
</div>
|
|
1733
1899
|
) : (
|
|
1734
|
-
<button
|
|
1735
|
-
onClick={() => setConfirmDelete(true)}
|
|
1736
|
-
style={{ padding: "8px 14px", borderRadius: t.cornerRadius, background: "#ef444411", color: "#ef4444", border: "1px solid #ef444433", cursor: "pointer" }}
|
|
1737
|
-
>
|
|
1900
|
+
<button onClick={() => setConfirmDelete(true)} style={ghostButton}>
|
|
1738
1901
|
{tr("forge.account_dashboard.request_deletion")}
|
|
1739
1902
|
</button>
|
|
1740
1903
|
)}
|
|
@@ -1745,85 +1908,282 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
|
|
|
1745
1908
|
|
|
1746
1909
|
// ---- Notifications tab ------------------------------------------------------
|
|
1747
1910
|
|
|
1911
|
+
/**
|
|
1912
|
+
* Which notifications reach this person, and on which channel.
|
|
1913
|
+
*
|
|
1914
|
+
* The server keeps a matrix of notification type by channel (in-app, push,
|
|
1915
|
+
* email) with defaults per type (`services/notification/matrix.ts`). This draws
|
|
1916
|
+
* it as a few plain groups rather than a grid, and offers only the channels a
|
|
1917
|
+
* type can actually use: a chat message never writes an in-app row, and a
|
|
1918
|
+
* reminder or "live now" never emails, so those switches are not drawn rather
|
|
1919
|
+
* than drawn dead.
|
|
1920
|
+
*
|
|
1921
|
+
* Work notifications (task comments, files a client uploads) go to the
|
|
1922
|
+
* creator's team, never to a client, so they have no place here.
|
|
1923
|
+
*/
|
|
1924
|
+
const NOTIFICATION_GROUPS: Array<{ titleKey: string; types: Array<{ type: string; labelKey: string }> }> = [
|
|
1925
|
+
{
|
|
1926
|
+
titleKey: "forge.account_dashboard.notify_group_messages",
|
|
1927
|
+
types: [
|
|
1928
|
+
{ type: "dm", labelKey: "forge.account_dashboard.notify_type_dm" },
|
|
1929
|
+
{ type: "mention", labelKey: "forge.account_dashboard.notify_type_mention" },
|
|
1930
|
+
{ type: "reply", labelKey: "forge.account_dashboard.notify_type_reply" },
|
|
1931
|
+
{ type: "chat_message", labelKey: "forge.account_dashboard.notify_type_chat_message" },
|
|
1932
|
+
// Replies from the creator's team in the portal's Messages. Drawn once the
|
|
1933
|
+
// server's matrix knows the type (rows without defaults are skipped).
|
|
1934
|
+
{ type: "client_message", labelKey: "forge.account_dashboard.notify_type_client_message" },
|
|
1935
|
+
],
|
|
1936
|
+
},
|
|
1937
|
+
{
|
|
1938
|
+
titleKey: "forge.account_dashboard.notify_group_community",
|
|
1939
|
+
types: [
|
|
1940
|
+
{ type: "new_post", labelKey: "forge.account_dashboard.notify_type_new_post" },
|
|
1941
|
+
{ type: "new_community_post", labelKey: "forge.account_dashboard.notify_type_new_community_post" },
|
|
1942
|
+
{ type: "reaction", labelKey: "forge.account_dashboard.notify_type_reaction" },
|
|
1943
|
+
{ type: "mod", labelKey: "forge.account_dashboard.notify_type_mod" },
|
|
1944
|
+
],
|
|
1945
|
+
},
|
|
1946
|
+
{
|
|
1947
|
+
titleKey: "forge.account_dashboard.notify_group_events",
|
|
1948
|
+
types: [
|
|
1949
|
+
{ type: "event_reminder", labelKey: "forge.account_dashboard.notify_type_event_reminder" },
|
|
1950
|
+
{ type: "stream_live", labelKey: "forge.account_dashboard.notify_type_stream_live" },
|
|
1951
|
+
],
|
|
1952
|
+
},
|
|
1953
|
+
];
|
|
1954
|
+
|
|
1955
|
+
const NOTIFICATION_CHANNELS: Array<{ channel: NotificationChannel; labelKey: string }> = [
|
|
1956
|
+
{ channel: "inapp", labelKey: "forge.account_dashboard.notify_channel_inapp" },
|
|
1957
|
+
{ channel: "push", labelKey: "forge.account_dashboard.notify_channel_push" },
|
|
1958
|
+
{ channel: "email", labelKey: "forge.account_dashboard.notify_channel_email" },
|
|
1959
|
+
];
|
|
1960
|
+
|
|
1961
|
+
const DIGEST_OPTIONS: Array<{ value: DigestCadence; labelKey: string }> = [
|
|
1962
|
+
{ value: "daily", labelKey: "forge.account_dashboard.notify_digest_daily" },
|
|
1963
|
+
{ value: "hourly", labelKey: "forge.account_dashboard.notify_digest_hourly" },
|
|
1964
|
+
{ value: "inapp_only", labelKey: "forge.account_dashboard.notify_digest_inapp_only" },
|
|
1965
|
+
{ value: "off", labelKey: "forge.account_dashboard.notify_digest_off" },
|
|
1966
|
+
];
|
|
1967
|
+
|
|
1968
|
+
/** Whether a channel means anything for a type, from the server's own defaults. */
|
|
1969
|
+
function channelApplies(
|
|
1970
|
+
defaults: NotificationMatrix["defaults"] | undefined,
|
|
1971
|
+
type: string,
|
|
1972
|
+
channel: NotificationChannel,
|
|
1973
|
+
): boolean {
|
|
1974
|
+
const def = defaults?.[type];
|
|
1975
|
+
if (!def) return false;
|
|
1976
|
+
if (channel === "inapp") return def.inapp;
|
|
1977
|
+
if (channel === "email") return def.emailMode !== "none";
|
|
1978
|
+
return true;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
/**
|
|
1982
|
+
* Push on THIS device: the switch that makes every "Push" choice below mean
|
|
1983
|
+
* anything. A row in the panel's own style rather than `PushOptIn`'s box, and
|
|
1984
|
+
* first, because a member who has not allowed push here receives none of it.
|
|
1985
|
+
* Draws nothing where the browser cannot do push at all.
|
|
1986
|
+
*/
|
|
1987
|
+
function PushDeviceRow() {
|
|
1988
|
+
const { t, row, meta, button, ghostButton } = useCardStyles();
|
|
1989
|
+
const tr = useForgeT();
|
|
1990
|
+
const { profileId } = useForge();
|
|
1991
|
+
const { isSupported, isSubscribed, isLoading, subscribe, unsubscribe } = usePushSubscription(profileId ?? "");
|
|
1992
|
+
if (!isSupported || !profileId) return null;
|
|
1993
|
+
return (
|
|
1994
|
+
<section style={{ marginBottom: 36 }} data-testid="notify-push-device">
|
|
1995
|
+
<div
|
|
1996
|
+
style={{
|
|
1997
|
+
...row,
|
|
1998
|
+
borderTop: `1px solid ${t.border}`,
|
|
1999
|
+
display: "flex",
|
|
2000
|
+
alignItems: "center",
|
|
2001
|
+
gap: 16,
|
|
2002
|
+
flexWrap: "wrap",
|
|
2003
|
+
}}
|
|
2004
|
+
>
|
|
2005
|
+
<div style={{ flex: "1 1 220px" }}>
|
|
2006
|
+
<div style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.notify_push_device_title")}</div>
|
|
2007
|
+
<div style={meta}>
|
|
2008
|
+
{isSubscribed
|
|
2009
|
+
? tr("forge.account_dashboard.notify_push_device_on")
|
|
2010
|
+
: tr("forge.account_dashboard.notify_push_device_off")}
|
|
2011
|
+
</div>
|
|
2012
|
+
</div>
|
|
2013
|
+
{isSubscribed ? (
|
|
2014
|
+
<button type="button" style={ghostButton} disabled={isLoading} onClick={() => void unsubscribe()}>
|
|
2015
|
+
{tr("forge.account_dashboard.notify_push_device_turn_off")}
|
|
2016
|
+
</button>
|
|
2017
|
+
) : (
|
|
2018
|
+
<button type="button" style={button} disabled={isLoading} onClick={() => void subscribe()}>
|
|
2019
|
+
{tr("forge.account_dashboard.notify_push_device_turn_on")}
|
|
2020
|
+
</button>
|
|
2021
|
+
)}
|
|
2022
|
+
</div>
|
|
2023
|
+
</section>
|
|
2024
|
+
);
|
|
2025
|
+
}
|
|
2026
|
+
|
|
1748
2027
|
function NotificationsTab() {
|
|
1749
|
-
const { t, card } = useCardStyles();
|
|
2028
|
+
const { t, card, list, row, alert } = useCardStyles();
|
|
1750
2029
|
const tr = useForgeT();
|
|
1751
|
-
const { data } =
|
|
1752
|
-
const
|
|
2030
|
+
const { data, isLoading } = useNotificationMatrix();
|
|
2031
|
+
const update = useUpdateNotificationMatrix();
|
|
2032
|
+
// Optimistic: the switch moves on click, and moves back if the save fails.
|
|
2033
|
+
const [pending, setPending] = useState<Record<string, boolean>>({});
|
|
2034
|
+
const [cadence, setCadence] = useState<DigestCadence | null>(null);
|
|
2035
|
+
const [error, setError] = useState<string | null>(null);
|
|
1753
2036
|
|
|
1754
|
-
|
|
2037
|
+
if (isLoading || !data) return <Loading />;
|
|
1755
2038
|
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
2039
|
+
const isOn = (key: string) => (key in pending ? pending[key]! : !!data.preferences[key]);
|
|
2040
|
+
const currentCadence = cadence ?? data.digestCadence;
|
|
2041
|
+
|
|
2042
|
+
const toggle = async (key: string) => {
|
|
2043
|
+
const value = !isOn(key);
|
|
2044
|
+
setError(null);
|
|
2045
|
+
setPending((prev) => ({ ...prev, [key]: value }));
|
|
2046
|
+
try {
|
|
2047
|
+
await update.mutateAsync({ preferences: { [key]: value } });
|
|
2048
|
+
} catch {
|
|
2049
|
+
setError(tr("forge.account_dashboard.notify_save_error"));
|
|
2050
|
+
} finally {
|
|
2051
|
+
setPending((prev) => {
|
|
2052
|
+
const next = { ...prev };
|
|
2053
|
+
delete next[key];
|
|
2054
|
+
return next;
|
|
1761
2055
|
});
|
|
1762
2056
|
}
|
|
1763
|
-
}
|
|
2057
|
+
};
|
|
1764
2058
|
|
|
1765
|
-
const
|
|
1766
|
-
|
|
1767
|
-
|
|
2059
|
+
const chooseCadence = async (value: DigestCadence) => {
|
|
2060
|
+
setError(null);
|
|
2061
|
+
setCadence(value);
|
|
1768
2062
|
try {
|
|
1769
|
-
await
|
|
2063
|
+
await update.mutateAsync({ digestCadence: value });
|
|
1770
2064
|
} catch {
|
|
1771
|
-
|
|
2065
|
+
setError(tr("forge.account_dashboard.notify_save_error"));
|
|
2066
|
+
} finally {
|
|
2067
|
+
setCadence(null);
|
|
1772
2068
|
}
|
|
1773
2069
|
};
|
|
1774
2070
|
|
|
1775
|
-
const
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
<div style={{ fontWeight: 600 }}>{title}</div>
|
|
1779
|
-
<div style={{ fontSize: 13, opacity: 0.7 }}>{desc}</div>
|
|
1780
|
-
</div>
|
|
2071
|
+
const chip = (key: string, label: string, typeLabel: string) => {
|
|
2072
|
+
const on = isOn(key);
|
|
2073
|
+
return (
|
|
1781
2074
|
<button
|
|
1782
|
-
|
|
2075
|
+
key={key}
|
|
2076
|
+
type="button"
|
|
2077
|
+
data-testid={`notify-${key}`}
|
|
2078
|
+
aria-pressed={on}
|
|
2079
|
+
aria-label={tr("forge.account_dashboard.notify_toggle_label", { type: typeLabel, channel: label })}
|
|
2080
|
+
onClick={() => void toggle(key)}
|
|
1783
2081
|
style={{
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
2082
|
+
display: "inline-flex",
|
|
2083
|
+
alignItems: "center",
|
|
2084
|
+
gap: 6,
|
|
2085
|
+
minHeight: 34,
|
|
2086
|
+
padding: "6px 12px",
|
|
1787
2087
|
borderRadius: 999,
|
|
1788
|
-
|
|
2088
|
+
// On is an outline with a tick, not a filled pill: ten rows of
|
|
2089
|
+
// filled primary would shout, and primary fill is kept for actions.
|
|
2090
|
+
border: `1px solid ${on ? t.primary : t.border}`,
|
|
2091
|
+
background: "transparent",
|
|
2092
|
+
color: on ? t.text : t.muted,
|
|
2093
|
+
fontFamily: t.fontFamily,
|
|
2094
|
+
fontSize: 13,
|
|
2095
|
+
fontWeight: 600,
|
|
1789
2096
|
cursor: "pointer",
|
|
1790
|
-
background: preferences[k] ? t.primary : `${t.text}40`,
|
|
1791
2097
|
}}
|
|
1792
|
-
aria-pressed={preferences[k]}
|
|
1793
2098
|
>
|
|
1794
|
-
<
|
|
1795
|
-
|
|
1796
|
-
position: "absolute",
|
|
1797
|
-
top: 4,
|
|
1798
|
-
left: preferences[k] ? 24 : 4,
|
|
1799
|
-
width: 16,
|
|
1800
|
-
height: 16,
|
|
1801
|
-
borderRadius: "50%",
|
|
1802
|
-
background: "#fff",
|
|
1803
|
-
transition: "left 0.15s",
|
|
1804
|
-
}}
|
|
1805
|
-
/>
|
|
2099
|
+
{on ? <Check size={14} aria-hidden style={{ color: t.primary }} /> : null}
|
|
2100
|
+
{label}
|
|
1806
2101
|
</button>
|
|
1807
|
-
|
|
1808
|
-
|
|
2102
|
+
);
|
|
2103
|
+
};
|
|
1809
2104
|
|
|
1810
2105
|
return (
|
|
1811
|
-
<div style={card}>
|
|
1812
|
-
<
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
2106
|
+
<div style={card} data-testid="notification-settings">
|
|
2107
|
+
<PushDeviceRow />
|
|
2108
|
+
{error && (
|
|
2109
|
+
<p role="alert" style={{ ...alert, marginTop: 0, marginBottom: 16 }}>
|
|
2110
|
+
{error}
|
|
2111
|
+
</p>
|
|
2112
|
+
)}
|
|
2113
|
+
|
|
2114
|
+
{NOTIFICATION_GROUPS.map((group) => {
|
|
2115
|
+
const types = group.types.filter((entry) => data.defaults?.[entry.type]);
|
|
2116
|
+
if (types.length === 0) return null;
|
|
2117
|
+
return (
|
|
2118
|
+
<section key={group.titleKey} style={{ marginBottom: 36 }}>
|
|
2119
|
+
<h2 style={{ fontSize: 18, fontWeight: 600, margin: "0 0 8px", fontFamily: t.headingFontFamily }}>
|
|
2120
|
+
{tr(group.titleKey)}
|
|
2121
|
+
</h2>
|
|
2122
|
+
<div style={list}>
|
|
2123
|
+
{types.map((entry) => {
|
|
2124
|
+
const typeLabel = tr(entry.labelKey);
|
|
2125
|
+
return (
|
|
2126
|
+
<div
|
|
2127
|
+
key={entry.type}
|
|
2128
|
+
data-testid={`notify-row-${entry.type}`}
|
|
2129
|
+
style={{ ...row, padding: "14px 0", display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}
|
|
2130
|
+
>
|
|
2131
|
+
<span style={{ flex: "1 1 180px", fontWeight: 600 }}>{typeLabel}</span>
|
|
2132
|
+
<span style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
|
2133
|
+
{NOTIFICATION_CHANNELS.filter(({ channel }) => channelApplies(data.defaults, entry.type, channel)).map(
|
|
2134
|
+
({ channel, labelKey }) => chip(`${entry.type}.${channel}`, tr(labelKey), typeLabel),
|
|
2135
|
+
)}
|
|
2136
|
+
</span>
|
|
2137
|
+
</div>
|
|
2138
|
+
);
|
|
2139
|
+
})}
|
|
2140
|
+
</div>
|
|
2141
|
+
</section>
|
|
2142
|
+
);
|
|
2143
|
+
})}
|
|
2144
|
+
|
|
2145
|
+
{/* How often the email that is NOT urgent arrives. Direct messages,
|
|
2146
|
+
mentions and reminders always email at once, which is why this
|
|
2147
|
+
explains itself behind the hint rather than in a paragraph. */}
|
|
2148
|
+
<section>
|
|
2149
|
+
<h2
|
|
2150
|
+
style={{ fontSize: 18, fontWeight: 600, margin: "0 0 12px", fontFamily: t.headingFontFamily, display: "flex", alignItems: "center", gap: 6 }}
|
|
2151
|
+
>
|
|
2152
|
+
{tr("forge.account_dashboard.notify_digest_title")}
|
|
2153
|
+
<CallHelpHint label={tr("forge.account_dashboard.notify_digest_help_label")}>
|
|
2154
|
+
{tr("forge.account_dashboard.notify_digest_help")}
|
|
2155
|
+
</CallHelpHint>
|
|
2156
|
+
</h2>
|
|
2157
|
+
<div role="radiogroup" aria-label={tr("forge.account_dashboard.notify_digest_title")} style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
2158
|
+
{DIGEST_OPTIONS.map((option) => {
|
|
2159
|
+
const active = currentCadence === option.value;
|
|
2160
|
+
return (
|
|
2161
|
+
<button
|
|
2162
|
+
key={option.value}
|
|
2163
|
+
type="button"
|
|
2164
|
+
role="radio"
|
|
2165
|
+
aria-checked={active}
|
|
2166
|
+
data-testid={`notify-digest-${option.value}`}
|
|
2167
|
+
onClick={() => void chooseCadence(option.value)}
|
|
2168
|
+
style={{
|
|
2169
|
+
minHeight: 38,
|
|
2170
|
+
padding: "8px 14px",
|
|
2171
|
+
borderRadius: t.cornerRadius,
|
|
2172
|
+
border: `1px solid ${active ? t.primary : t.border}`,
|
|
2173
|
+
background: active ? t.primary : "transparent",
|
|
2174
|
+
color: active ? t.textPrimary : t.text,
|
|
2175
|
+
fontFamily: t.fontFamily,
|
|
2176
|
+
fontSize: 14,
|
|
2177
|
+
fontWeight: 600,
|
|
2178
|
+
cursor: "pointer",
|
|
2179
|
+
}}
|
|
2180
|
+
>
|
|
2181
|
+
{tr(option.labelKey)}
|
|
2182
|
+
</button>
|
|
2183
|
+
);
|
|
2184
|
+
})}
|
|
2185
|
+
</div>
|
|
2186
|
+
</section>
|
|
1827
2187
|
</div>
|
|
1828
2188
|
);
|
|
1829
2189
|
}
|