@tribe-nest/forge 3.59.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/_tests/testCommunity.tsx +28 -0
- package/src/contexts/CartContext.tsx +34 -1
- package/src/contexts/CommunityContext.tsx +192 -0
- 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 +52 -40
- package/src/data/queries/useCheckouts.ts +2 -1
- package/src/data/queries/useCoachingAvailability.ts +10 -1
- package/src/data/queries/useCommunities.ts +345 -0
- package/src/data/queries/useCommunity.ts +450 -102
- 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/useMembership.ts +32 -7
- package/src/data/queries/useMembershipCalls.ts +116 -7
- 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/usePostCollections.ts +28 -16
- package/src/data/queries/usePosts.ts +16 -14
- package/src/data/queries/useSiteApp.ts +130 -0
- package/src/data/queries/useSubscriptions.ts +28 -9
- package/src/i18n/de.json +891 -2
- package/src/i18n/en.json +891 -2
- package/src/index.ts +36 -7
- package/src/runtime/pages.spec.ts +36 -0
- package/src/runtime/pages.ts +52 -27
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
- 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/format/_tests/membershipGate.spec.ts +6 -6
- package/src/ui/format/membershipGate.ts +19 -15
- 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/communityGate.spec.tsx +80 -0
- package/src/ui/headless/_tests/communityHarness.tsx +212 -0
- 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/communityProvider.spec.tsx +151 -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/useCommunityInvite.spec.tsx +104 -0
- package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +596 -0
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +179 -0
- package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +295 -0
- package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
- 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 +30 -3
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
- package/src/ui/headless/chat/useChatRoom.ts +35 -1
- package/src/ui/headless/communities/CommunityGate.tsx +67 -0
- package/src/ui/headless/communities/index.ts +96 -0
- package/src/ui/headless/communities/paths.ts +36 -0
- package/src/ui/headless/communities/useCommunity.ts +140 -0
- package/src/ui/headless/communities/useCommunityAbout.ts +71 -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 +91 -0
- package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +494 -0
- package/src/ui/headless/communities/useCommunityMembership.ts +182 -0
- package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
- package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
- package/src/ui/headless/communities/useMyMemberships.ts +59 -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/useCommunityAttachmentUpload.ts +7 -3
- 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 +21 -4
- package/src/ui/headless/memberHome/index.ts +11 -1
- package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +84 -44
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
- package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
- package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
- 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/posts/upsell.ts +54 -8
- package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
- package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
- package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
- package/src/ui/headless/posts/usePostItem.ts +6 -5
- 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 +28 -12
- package/src/ui/styled/AccountDashboard.tsx +754 -602
- 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/MembershipCheckout.tsx +37 -17
- package/src/ui/styled/MembershipTierCallout.tsx +20 -1
- package/src/ui/styled/MembershipTiers.tsx +49 -11
- package/src/ui/styled/MusicLinkPage.tsx +5 -5
- package/src/ui/styled/Paywall.tsx +1 -1
- 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 +6 -6
- package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
- package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
- package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
- package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
- package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
- package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
- 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/CommunitiesList.tsx +88 -0
- package/src/ui/styled/communities/CommunityAbout.tsx +145 -0
- 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 +298 -0
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +544 -0
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +249 -0
- package/src/ui/styled/communities/CommunityPending.tsx +49 -0
- package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
- package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
- package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +115 -0
- 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/CommunityPages.spec.tsx +102 -0
- package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
- package/src/ui/styled/communities/index.ts +19 -0
- package/src/ui/styled/communities/shared.tsx +177 -0
- 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/CommunityComposer.spec.tsx +4 -1
- 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/_tests/CommunityPostDetail.spec.tsx +4 -1
- package/src/ui/styled/community/index.ts +37 -2
- package/src/ui/styled/forge-utilities.css +253 -0
- package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
- package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +41 -54
- package/src/ui/styled/members/MemberHomeLayout.tsx +327 -29
- package/src/ui/styled/members/MemberPostCard.tsx +9 -5
- package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
- package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
- package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
- package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
- package/src/ui/styled/members/callTranscriptText.ts +67 -0
- 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/_tests/safeRedirect.spec.ts +2 -2
- package/src/utils/landing.ts +19 -6
- package/src/utils/metaEvents.ts +302 -0
- package/src/ui/headless/membership/MembershipGate.tsx +0 -44
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
|
@@ -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,25 +13,28 @@ 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,
|
|
26
|
-
useCancelMembership,
|
|
27
|
-
useMembershipAccess,
|
|
28
32
|
useMyMembershipApplications,
|
|
29
33
|
useWithdrawMembershipApplication,
|
|
30
34
|
useConfirmMembershipApplication,
|
|
31
35
|
type MembershipApplication,
|
|
32
36
|
type MembershipApplicationStatus,
|
|
33
|
-
|
|
37
|
+
usePublicCommunities,
|
|
34
38
|
useUpdateAccount,
|
|
35
39
|
useChangePassword,
|
|
36
40
|
useExportAccountData,
|
|
@@ -38,13 +42,13 @@ import {
|
|
|
38
42
|
useCancelAccountDeletion,
|
|
39
43
|
useAccountDeletionStatus,
|
|
40
44
|
OrderStatus,
|
|
41
|
-
type NotificationPreferences,
|
|
42
45
|
} from "../../index";
|
|
43
46
|
import { useForgeT, type ForgeT } from "../../i18n";
|
|
44
47
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
45
48
|
import { useSiteConfig } from "../../data/queries/useWebsite";
|
|
46
49
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
47
50
|
import { Loading } from "./Loading";
|
|
51
|
+
import { CallHelpHint } from "../media/CallHelpHint";
|
|
48
52
|
import { bookingCallWindow } from "../media/bookingWindow";
|
|
49
53
|
import { formatCountdown } from "./EventWaitlist";
|
|
50
54
|
import { AddToCalendar } from "./AddToCalendar";
|
|
@@ -53,7 +57,8 @@ import { TicketTransferPanel } from "./TicketTransfer";
|
|
|
53
57
|
import { CartLineOptions } from "./CartLineOptions";
|
|
54
58
|
import { FilmRentalList } from "./FilmLibrary";
|
|
55
59
|
import { canWithdrawMembershipApplication } from "../headless/membership/membershipApplication";
|
|
56
|
-
import {
|
|
60
|
+
import { communityAboutPath, communitiesListPath } from "../headless/communities/paths";
|
|
61
|
+
import { MyMembershipsList } from "./communities/MyMembershipsList";
|
|
57
62
|
|
|
58
63
|
export const ACCOUNT_TABS = [
|
|
59
64
|
"membership",
|
|
@@ -63,7 +68,7 @@ export const ACCOUNT_TABS = [
|
|
|
63
68
|
// holding for this artist's shows? (2.6)
|
|
64
69
|
"waitlist",
|
|
65
70
|
// Sessions bought from this artist (S.6 / bookings § 1.4). A booking is not an
|
|
66
|
-
// "order"
|
|
71
|
+
// "order" (the Orders tab covers product orders only) so it needs its own
|
|
67
72
|
// tab or it is unreachable, which is what it was.
|
|
68
73
|
"bookings",
|
|
69
74
|
// Films and series this fan has rented. Next to the other purchase surfaces
|
|
@@ -109,21 +114,10 @@ export interface AccountDashboardProps {
|
|
|
109
114
|
/** Called when the visitor selects a different tab. Host maps this to navigation. */
|
|
110
115
|
onTabChange?: (tab: AccountTabKey) => void;
|
|
111
116
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
117
|
+
* SPA navigation adapter for the Memberships tab (each membership opens its
|
|
118
|
+
* community's home). Defaults to a full-page visit.
|
|
114
119
|
*/
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Navigate to the member area, the member home (host maps this to its router,
|
|
118
|
-
* e.g. `/i/members`).
|
|
119
|
-
*
|
|
120
|
-
* Optional, and drawn for every signed-in visitor, membership or none. It used
|
|
121
|
-
* to be gated on `/public/community/spaces` returning a space they could open,
|
|
122
|
-
* which hid the member area from exactly the people who had not joined yet:
|
|
123
|
-
* the member home itself shows the locked posts and the way to buy access, so
|
|
124
|
-
* the door is never a dead end.
|
|
125
|
-
*/
|
|
126
|
-
onNavigateCommunity?: () => void;
|
|
120
|
+
navigate?: (href: string) => void;
|
|
127
121
|
/**
|
|
128
122
|
* Navigate to a film route from the Rentals tab (watch, resume, rent again).
|
|
129
123
|
*
|
|
@@ -161,55 +155,123 @@ const defaultFormatDate = (value: string | number | Date): string =>
|
|
|
161
155
|
|
|
162
156
|
// ---- shared styling helpers -------------------------------------------------
|
|
163
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
|
+
*/
|
|
164
163
|
function useCardStyles() {
|
|
165
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
|
+
};
|
|
166
180
|
return {
|
|
167
181
|
t,
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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,
|
|
175
190
|
button: {
|
|
176
|
-
|
|
177
|
-
borderRadius: t.cornerRadius,
|
|
191
|
+
...control,
|
|
178
192
|
background: t.primary,
|
|
179
|
-
color: t.
|
|
180
|
-
border:
|
|
181
|
-
fontWeight: 600,
|
|
182
|
-
cursor: "pointer",
|
|
193
|
+
color: t.textPrimary,
|
|
194
|
+
border: `1px solid ${t.primary}`,
|
|
183
195
|
} as React.CSSProperties,
|
|
184
196
|
ghostButton: {
|
|
185
|
-
|
|
186
|
-
borderRadius: t.cornerRadius,
|
|
197
|
+
...control,
|
|
187
198
|
background: "transparent",
|
|
188
199
|
color: t.text,
|
|
189
|
-
border: `1px solid ${t.
|
|
190
|
-
cursor: "pointer",
|
|
200
|
+
border: `1px solid ${t.border}`,
|
|
191
201
|
} as React.CSSProperties,
|
|
192
202
|
input: {
|
|
193
203
|
width: "100%",
|
|
194
204
|
height: 40,
|
|
195
205
|
padding: "8px 12px",
|
|
196
206
|
borderRadius: t.cornerRadius,
|
|
197
|
-
border: `1px solid ${t.
|
|
207
|
+
border: `1px solid ${t.border}`,
|
|
198
208
|
background: "transparent",
|
|
199
209
|
color: t.text,
|
|
210
|
+
fontFamily: t.fontFamily,
|
|
211
|
+
fontSize: 15,
|
|
200
212
|
outline: "none",
|
|
213
|
+
boxSizing: "border-box",
|
|
201
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,
|
|
202
217
|
};
|
|
203
218
|
}
|
|
204
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
|
+
|
|
205
268
|
// ---- shared context passed down to tabs ------------------------------------
|
|
206
269
|
|
|
207
270
|
interface TabContext {
|
|
208
271
|
formatAmount: (amount: number, currencyCode?: string) => string;
|
|
209
272
|
formatDate: (value: string | number | Date) => string;
|
|
210
273
|
currency?: string;
|
|
211
|
-
|
|
212
|
-
onNavigateCommunity?: () => void;
|
|
274
|
+
navigate?: (href: string) => void;
|
|
213
275
|
onNavigateFilm?: (href: string) => void;
|
|
214
276
|
/**
|
|
215
277
|
* Open a course the fan owns. Host-supplied, like every other navigation
|
|
@@ -232,8 +294,7 @@ interface TabContext {
|
|
|
232
294
|
export function AccountDashboard({
|
|
233
295
|
tab,
|
|
234
296
|
onTabChange,
|
|
235
|
-
|
|
236
|
-
onNavigateCommunity,
|
|
297
|
+
navigate,
|
|
237
298
|
onNavigateFilm,
|
|
238
299
|
onNavigateCourse,
|
|
239
300
|
formatAmount,
|
|
@@ -271,11 +332,10 @@ export function AccountDashboard({
|
|
|
271
332
|
const ctx: TabContext = {
|
|
272
333
|
formatAmount: fmtAmount,
|
|
273
334
|
formatDate: fmtDate,
|
|
274
|
-
// Fallback for records with no explicit currency
|
|
335
|
+
// Fallback for records with no explicit currency: the tenant's runtime
|
|
275
336
|
// settlement currency (single source), unless the host overrides it.
|
|
276
337
|
currency: currency ?? siteConfig?.currency,
|
|
277
|
-
|
|
278
|
-
onNavigateCommunity,
|
|
338
|
+
navigate,
|
|
279
339
|
onNavigateFilm,
|
|
280
340
|
onNavigateCourse,
|
|
281
341
|
};
|
|
@@ -290,15 +350,16 @@ export function AccountDashboard({
|
|
|
290
350
|
const hasCourses = (courses?.length ?? 0) > 0;
|
|
291
351
|
const visibleTabs = ACCOUNT_TABS.filter(
|
|
292
352
|
(key) =>
|
|
293
|
-
(key !== "rentals" || hasRentals || tab === "rentals") &&
|
|
294
|
-
(key !== "courses" || hasCourses || tab === "courses"),
|
|
353
|
+
(key !== "rentals" || hasRentals || tab === "rentals") && (key !== "courses" || hasCourses || tab === "courses"),
|
|
295
354
|
);
|
|
296
355
|
|
|
297
356
|
if (!user) return <Loading fullPage />;
|
|
298
357
|
|
|
299
358
|
return (
|
|
300
359
|
<div className={className} style={{ maxWidth: 900, margin: "0 auto", color: t.text, ...style }}>
|
|
301
|
-
<div
|
|
360
|
+
<div
|
|
361
|
+
style={{ marginBottom: 24, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 16 }}
|
|
362
|
+
>
|
|
302
363
|
<div>
|
|
303
364
|
<h1 style={{ fontSize: 28, fontWeight: 800, fontFamily: t.headingFontFamily }}>
|
|
304
365
|
{tr("forge.account_dashboard.title")}
|
|
@@ -307,7 +368,14 @@ export function AccountDashboard({
|
|
|
307
368
|
</div>
|
|
308
369
|
<button
|
|
309
370
|
onClick={() => logout()}
|
|
310
|
-
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
|
+
}}
|
|
311
379
|
>
|
|
312
380
|
{tr("forge.account_dashboard.log_out")}
|
|
313
381
|
</button>
|
|
@@ -348,224 +416,83 @@ export function AccountDashboard({
|
|
|
348
416
|
);
|
|
349
417
|
}
|
|
350
418
|
|
|
351
|
-
// ----
|
|
419
|
+
// ---- one section, outside the dashboard -----------------------------------
|
|
352
420
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const { formatAmount, formatDate, currency, onNavigateMembership, onNavigateCommunity } = ctx;
|
|
358
|
-
const cancelMembership = useCancelMembership();
|
|
359
|
-
// The way into the member area. Being signed in is the whole gate: a fan with
|
|
360
|
-
// no membership needs this door most, because the member home is where the
|
|
361
|
-
// locked posts and the offer to join actually live. The old gate asked
|
|
362
|
-
// `/public/community/spaces` for an openable space first, so a fan who had not
|
|
363
|
-
// joined saw no way in at all.
|
|
364
|
-
const canOpenCommunity = !!onNavigateCommunity && !!user;
|
|
365
|
-
const [isCancelling, setIsCancelling] = useState(false);
|
|
366
|
-
/**
|
|
367
|
-
* Cancelling is destructive and irreversible from here, so it asks first.
|
|
368
|
-
* CLAUDE.md puts a destructive confirmation in the category that must stay
|
|
369
|
-
* VISIBLE rather than behind a `?`, and until now one click on Cancel simply
|
|
370
|
-
* ended the membership. The client app has always asked (`EditorModal` in
|
|
371
|
-
* `MembershipTab.tsx`); this surface was ported without it.
|
|
372
|
-
*/
|
|
373
|
-
const [confirmingCancel, setConfirmingCancel] = useState(false);
|
|
374
|
-
const [cancelError, setCancelError] = useState<string | null>(null);
|
|
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
|
+
};
|
|
375
425
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
window.location.reload();
|
|
405
|
-
} catch (error) {
|
|
406
|
-
const detail = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
407
|
-
setCancelError(detail || tr("forge.account_dashboard.membership_cancel_error"));
|
|
408
|
-
} finally {
|
|
409
|
-
setIsCancelling(false);
|
|
410
|
-
}
|
|
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,
|
|
411
454
|
};
|
|
412
455
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
+
|
|
482
|
+
// ---- Membership tab ---------------------------------------------------------
|
|
423
483
|
|
|
484
|
+
/**
|
|
485
|
+
* "My memberships": one card per community the fan belongs to, each linking
|
|
486
|
+
* inside its community, where billing, cancel and downgrade live. A fan can
|
|
487
|
+
* hold a membership in several communities of one creator, so there is no
|
|
488
|
+
* single "your membership" any more. Nothing in a join or upgrade flow sends a
|
|
489
|
+
* fan here; this is for someone who wants everything in one place.
|
|
490
|
+
*/
|
|
491
|
+
function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
492
|
+
const { card } = useCardStyles();
|
|
424
493
|
return (
|
|
425
494
|
<div style={card}>
|
|
426
|
-
<
|
|
427
|
-
{tr("forge.account_dashboard.membership_title")}
|
|
428
|
-
</h2>
|
|
429
|
-
{membership ? (
|
|
430
|
-
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
431
|
-
<div>
|
|
432
|
-
<h3 style={{ fontWeight: 700 }}>{membership.membershipTier.name}</h3>
|
|
433
|
-
<p
|
|
434
|
-
style={{
|
|
435
|
-
fontSize: 13,
|
|
436
|
-
opacity: 0.85,
|
|
437
|
-
fontWeight: message.tone === "warning" ? 700 : 400,
|
|
438
|
-
color: message.tone === "warning" ? t.primary : undefined,
|
|
439
|
-
}}
|
|
440
|
-
>
|
|
441
|
-
{message.label}
|
|
442
|
-
</p>
|
|
443
|
-
{message.detail && (
|
|
444
|
-
<p style={{ fontSize: 13, opacity: 0.8, marginTop: 4, maxWidth: 480 }}>{message.detail}</p>
|
|
445
|
-
)}
|
|
446
|
-
{!!membership.subscriptionAmount && (
|
|
447
|
-
<p style={{ fontSize: 14, marginTop: 4 }}>
|
|
448
|
-
{tr("forge.account_dashboard.membership_price", {
|
|
449
|
-
amount: formatAmount(membership.subscriptionAmount, membership.subscriptionCurrency || currency),
|
|
450
|
-
cycle: membership.billingCycle,
|
|
451
|
-
})}
|
|
452
|
-
</p>
|
|
453
|
-
)}
|
|
454
|
-
</div>
|
|
455
|
-
{!!membership.membershipTier.benefits?.length && (
|
|
456
|
-
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 6 }}>
|
|
457
|
-
{membership.membershipTier.benefits.map((b) => (
|
|
458
|
-
<li key={b.id} style={{ display: "flex", gap: 8 }}>
|
|
459
|
-
<span style={{ color: t.primary }}>✓</span>
|
|
460
|
-
{b.title}
|
|
461
|
-
</li>
|
|
462
|
-
))}
|
|
463
|
-
</ul>
|
|
464
|
-
)}
|
|
465
|
-
{inTrial && membership.trialEndsAt && (
|
|
466
|
-
<div data-testid="membership-trial-state">
|
|
467
|
-
<p style={{ fontSize: 14, fontWeight: 600 }}>
|
|
468
|
-
{tr("forge.account_dashboard.membership_trial_until", { date: formatDate(membership.trialEndsAt) })}
|
|
469
|
-
</p>
|
|
470
|
-
<p style={{ fontSize: 14, opacity: 0.85, marginTop: 2 }}>
|
|
471
|
-
{access.cancelAtPeriodEnd
|
|
472
|
-
? tr("forge.account_dashboard.membership_trial_cancelled")
|
|
473
|
-
: membership.subscriptionAmount
|
|
474
|
-
? tr("forge.account_dashboard.membership_trial_then", {
|
|
475
|
-
amount: formatAmount(membership.subscriptionAmount, membership.subscriptionCurrency || currency),
|
|
476
|
-
cycle: cycleWord(membership.billingCycle),
|
|
477
|
-
date: formatDate(membership.trialEndsAt),
|
|
478
|
-
})
|
|
479
|
-
: null}
|
|
480
|
-
</p>
|
|
481
|
-
</div>
|
|
482
|
-
)}
|
|
483
|
-
{membership.endDate && !inTrial && (
|
|
484
|
-
<p style={{ fontSize: 14, opacity: 0.8 }}>
|
|
485
|
-
{access.billingState === "active"
|
|
486
|
-
? tr("forge.account_dashboard.membership_renews_on", { date: formatDate(membership.endDate) })
|
|
487
|
-
: tr("forge.account_dashboard.membership_ends_on", { date: formatDate(membership.endDate) })}
|
|
488
|
-
</p>
|
|
489
|
-
)}
|
|
490
|
-
{billingError && <p style={{ fontSize: 13, opacity: 0.9 }}>{billingError}</p>}
|
|
491
|
-
{cancelError && (
|
|
492
|
-
<p role="alert" style={{ fontSize: 13, opacity: 0.9, color: t.primary }}>
|
|
493
|
-
{cancelError}
|
|
494
|
-
</p>
|
|
495
|
-
)}
|
|
496
|
-
</div>
|
|
497
|
-
) : (
|
|
498
|
-
<p style={{ opacity: 0.8 }}>{tr("forge.account_dashboard.membership_empty")}</p>
|
|
499
|
-
)}
|
|
500
|
-
|
|
501
|
-
<div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 24, flexWrap: "wrap" }}>
|
|
502
|
-
{/* Kept hard left, apart from the billing actions: this is the door to
|
|
503
|
-
what the membership is FOR, not another thing to do to the plan. */}
|
|
504
|
-
{canOpenCommunity && (
|
|
505
|
-
<button onClick={() => onNavigateCommunity?.()} style={{ ...ghostButton, marginRight: "auto" }}>
|
|
506
|
-
{tr("forge.account_dashboard.member_area")}
|
|
507
|
-
</button>
|
|
508
|
-
)}
|
|
509
|
-
{message.showUpdatePayment && (
|
|
510
|
-
<button onClick={onUpdatePayment} disabled={openBillingPortal.isPending} style={button}>
|
|
511
|
-
{openBillingPortal.isPending
|
|
512
|
-
? tr("forge.account_dashboard.billing_portal_opening")
|
|
513
|
-
: tr("forge.account_dashboard.update_payment_method")}
|
|
514
|
-
</button>
|
|
515
|
-
)}
|
|
516
|
-
{/* Cancelling stays available for anyone who still HAS access — a
|
|
517
|
-
past_due member must not be trapped in a subscription they can see
|
|
518
|
-
but not leave. Hidden once it is already scheduled ("Ending"), where
|
|
519
|
-
the only honest label would be "Cancel again". */}
|
|
520
|
-
{access.hasAccess && !access.cancelAtPeriodEnd && (
|
|
521
|
-
<button onClick={() => setConfirmingCancel(true)} disabled={isCancelling} style={ghostButton}>
|
|
522
|
-
{isCancelling ? tr("forge.account_dashboard.cancelling") : tr("forge.account_dashboard.cancel")}
|
|
523
|
-
</button>
|
|
524
|
-
)}
|
|
525
|
-
<button onClick={() => onNavigateMembership?.()} style={button}>
|
|
526
|
-
{membership
|
|
527
|
-
? tr("forge.account_dashboard.membership_change")
|
|
528
|
-
: tr("forge.account_dashboard.membership_join")}
|
|
529
|
-
</button>
|
|
530
|
-
</div>
|
|
531
|
-
|
|
532
|
-
{/* Says WHICH of the two cancellations this is. A paid membership runs to
|
|
533
|
-
the end of the period already paid for; a free one stops now. A flat
|
|
534
|
-
"are you sure" tells a member who paid this month that they have lost
|
|
535
|
-
the rest of it, which is not true and reads as a threat. */}
|
|
536
|
-
{confirmingCancel && (
|
|
537
|
-
<div role="alertdialog" aria-modal="true" style={{ ...card, marginTop: 16 }}>
|
|
538
|
-
<p style={{ fontWeight: 700 }}>{tr("forge.account_dashboard.cancel_confirm_title")}</p>
|
|
539
|
-
<p style={{ fontSize: 14, opacity: 0.85, marginTop: 8 }}>
|
|
540
|
-
{inTrial && membership?.trialEndsAt
|
|
541
|
-
? tr("forge.account_dashboard.cancel_confirm_trial", { date: formatDate(membership.trialEndsAt) })
|
|
542
|
-
: membership?.subscriptionAmount
|
|
543
|
-
? membership.endDate
|
|
544
|
-
? tr("forge.account_dashboard.cancel_confirm_paid_until", { date: formatDate(membership.endDate) })
|
|
545
|
-
: tr("forge.account_dashboard.cancel_confirm_paid")
|
|
546
|
-
: tr("forge.account_dashboard.cancel_confirm_free")}
|
|
547
|
-
</p>
|
|
548
|
-
<div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 16 }}>
|
|
549
|
-
<button onClick={() => setConfirmingCancel(false)} disabled={isCancelling} style={ghostButton}>
|
|
550
|
-
{tr("forge.account_dashboard.cancel_confirm_keep")}
|
|
551
|
-
</button>
|
|
552
|
-
<button
|
|
553
|
-
onClick={() => {
|
|
554
|
-
// Closed FIRST, so the dialog cannot be double-submitted while
|
|
555
|
-
// the request is in flight. The row behind it already renders
|
|
556
|
-
// "Cancelling" from `isCancelling`.
|
|
557
|
-
setConfirmingCancel(false);
|
|
558
|
-
void onCancel();
|
|
559
|
-
}}
|
|
560
|
-
disabled={isCancelling}
|
|
561
|
-
style={button}
|
|
562
|
-
>
|
|
563
|
-
{tr("forge.account_dashboard.cancel_confirm_confirm")}
|
|
564
|
-
</button>
|
|
565
|
-
</div>
|
|
566
|
-
</div>
|
|
567
|
-
)}
|
|
568
|
-
|
|
495
|
+
<MyMembershipsList navigate={ctx.navigate} formatAmount={ctx.formatAmount} layout="rows" />
|
|
569
496
|
<MembershipApplicationsSection ctx={ctx} />
|
|
570
497
|
</div>
|
|
571
498
|
);
|
|
@@ -594,9 +521,18 @@ const APPLICATION_STATUS_LABEL: Record<MembershipApplicationStatus, string> = {
|
|
|
594
521
|
function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
|
|
595
522
|
const { user } = usePublicAuth();
|
|
596
523
|
const tr = useForgeT();
|
|
597
|
-
const { t, ghostButton, button } = useCardStyles();
|
|
524
|
+
const { t, ghostButton, button, list, row, meta, alert } = useCardStyles();
|
|
598
525
|
const { formatAmount, formatDate, currency } = ctx;
|
|
599
526
|
const { data: applications } = useMyMembershipApplications(user?.id);
|
|
527
|
+
const { data: communities } = usePublicCommunities({
|
|
528
|
+
enabled: !!applications?.some((a) => a.status === "payment_failed"),
|
|
529
|
+
});
|
|
530
|
+
/** An approved application whose card failed is finished on its community's About page, on that tier. */
|
|
531
|
+
const completeHref = (application: MembershipApplication) => {
|
|
532
|
+
const communityId = (application as MembershipApplication & { communityId?: string }).communityId;
|
|
533
|
+
const slug = communities?.find((community) => community.id === communityId)?.slug;
|
|
534
|
+
return slug ? communityAboutPath(slug, { tier: application.membershipTierId }) : communitiesListPath();
|
|
535
|
+
};
|
|
600
536
|
const withdraw = useWithdrawMembershipApplication();
|
|
601
537
|
const confirm = useConfirmMembershipApplication();
|
|
602
538
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -623,24 +559,28 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
|
|
|
623
559
|
};
|
|
624
560
|
|
|
625
561
|
return (
|
|
626
|
-
<div style={{ marginTop:
|
|
627
|
-
<
|
|
562
|
+
<div style={{ marginTop: 40 }}>
|
|
563
|
+
<h2 style={{ fontSize: 18, fontWeight: 600, margin: "0 0 12px", fontFamily: t.headingFontFamily }}>
|
|
628
564
|
{tr("forge.account_dashboard.applications_title")}
|
|
629
|
-
</
|
|
630
|
-
<ul
|
|
631
|
-
data-testid="membership-applications"
|
|
632
|
-
style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 12 }}
|
|
633
|
-
>
|
|
565
|
+
</h2>
|
|
566
|
+
<ul data-testid="membership-applications" style={{ ...list, listStyle: "none", padding: 0, margin: 0 }}>
|
|
634
567
|
{applications.map((application) => (
|
|
635
568
|
<li
|
|
636
569
|
key={application.id}
|
|
637
|
-
style={{
|
|
570
|
+
style={{
|
|
571
|
+
...row,
|
|
572
|
+
display: "flex",
|
|
573
|
+
gap: 12,
|
|
574
|
+
justifyContent: "space-between",
|
|
575
|
+
alignItems: "center",
|
|
576
|
+
flexWrap: "wrap",
|
|
577
|
+
}}
|
|
638
578
|
>
|
|
639
579
|
<div style={{ minWidth: 0 }}>
|
|
640
|
-
<p style={{ fontWeight: 600 }}>{application.membershipTierName ?? ""}</p>
|
|
641
|
-
<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>
|
|
642
582
|
{application.amount != null && application.billingCycle && (
|
|
643
|
-
<p style={{
|
|
583
|
+
<p style={{ ...meta, margin: 0 }}>
|
|
644
584
|
{tr("forge.account_dashboard.membership_price", {
|
|
645
585
|
amount: formatAmount(application.amount, application.currency || currency),
|
|
646
586
|
cycle: application.billingCycle,
|
|
@@ -648,35 +588,28 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
|
|
|
648
588
|
</p>
|
|
649
589
|
)}
|
|
650
590
|
{application.status === "payment_failed" && application.paymentWindowExpiresAt && (
|
|
651
|
-
<p style={{ fontSize: 13, fontWeight: 600 }}>
|
|
591
|
+
<p style={{ fontSize: 13, fontWeight: 600, margin: 0 }}>
|
|
652
592
|
{tr("forge.account_dashboard.application_complete_by", {
|
|
653
593
|
date: formatDate(application.paymentWindowExpiresAt),
|
|
654
594
|
})}
|
|
655
595
|
</p>
|
|
656
596
|
)}
|
|
657
597
|
{application.status === "declined" && application.decisionNote && (
|
|
658
|
-
<p style={{
|
|
598
|
+
<p style={{ ...meta, margin: 0, maxWidth: 480 }}>{application.decisionNote}</p>
|
|
659
599
|
)}
|
|
660
|
-
<p style={{
|
|
600
|
+
<p style={{ ...meta, margin: 0 }}>
|
|
661
601
|
{tr("forge.account_dashboard.application_submitted_on", { date: formatDate(application.createdAt) })}
|
|
662
602
|
</p>
|
|
663
603
|
</div>
|
|
664
604
|
{canWithdrawMembershipApplication(application.status) && (
|
|
665
|
-
<button
|
|
666
|
-
onClick={() => void onWithdraw(application)}
|
|
667
|
-
disabled={withdraw.isPending}
|
|
668
|
-
style={ghostButton}
|
|
669
|
-
>
|
|
605
|
+
<button onClick={() => void onWithdraw(application)} disabled={withdraw.isPending} style={ghostButton}>
|
|
670
606
|
{withdraw.isPending && withdraw.variables?.applicationId === application.id
|
|
671
607
|
? tr("forge.account_dashboard.application_withdrawing")
|
|
672
608
|
: tr("forge.account_dashboard.application_withdraw")}
|
|
673
609
|
</button>
|
|
674
610
|
)}
|
|
675
611
|
{application.status === "payment_failed" && (
|
|
676
|
-
<a
|
|
677
|
-
href={`/i/membership/checkout?membershipTierId=${encodeURIComponent(application.membershipTierId)}`}
|
|
678
|
-
style={{ ...button, textDecoration: "none" }}
|
|
679
|
-
>
|
|
612
|
+
<a href={completeHref(application)} style={button}>
|
|
680
613
|
{tr("forge.account_dashboard.application_complete")}
|
|
681
614
|
</a>
|
|
682
615
|
)}
|
|
@@ -684,7 +617,7 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
|
|
|
684
617
|
))}
|
|
685
618
|
</ul>
|
|
686
619
|
{error && (
|
|
687
|
-
<p role="alert" style={
|
|
620
|
+
<p role="alert" style={alert}>
|
|
688
621
|
{error}
|
|
689
622
|
</p>
|
|
690
623
|
)}
|
|
@@ -698,7 +631,7 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
|
|
|
698
631
|
* The buyer's event tickets, and cancelling one inside the policy they were
|
|
699
632
|
* sold under (S.6).
|
|
700
633
|
*
|
|
701
|
-
* Before this, a ticket buyer had no purchase surface at all
|
|
634
|
+
* Before this, a ticket buyer had no purchase surface at all: the confirmation
|
|
702
635
|
* email and a one-shot finalise page were the only artefacts of the sale.
|
|
703
636
|
*
|
|
704
637
|
* Eligibility is NOT recomputed here. `cancellation.canCancel` and its reason
|
|
@@ -709,22 +642,22 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
|
|
|
709
642
|
* ## Two kinds of row
|
|
710
643
|
*
|
|
711
644
|
* `role` says whether the caller PAID for this order or merely HOLDS a pass out
|
|
712
|
-
* of it
|
|
645
|
+
* of it: a ticket somebody transferred to them, which until now reached them
|
|
713
646
|
* nowhere at all. A holder's row is scrubbed server-side (no totals, no line
|
|
714
647
|
* prices, no other seats, no cancel), and this renders that difference rather
|
|
715
648
|
* than printing "$0.00" over somebody else's purchase.
|
|
716
649
|
*/
|
|
717
650
|
function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
718
651
|
const { user } = usePublicAuth();
|
|
719
|
-
const { t, card } = useCardStyles();
|
|
652
|
+
const { t, card, list, row, meta, ghostButton } = useCardStyles();
|
|
720
653
|
const tr = useForgeT();
|
|
721
654
|
const { formatAmount, formatDate, currency } = ctx;
|
|
722
|
-
const { data, isLoading } =
|
|
655
|
+
const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = useMyTicketsPaged(user?.id);
|
|
723
656
|
const cancelTicket = useCancelMyTicket();
|
|
724
657
|
const [pendingId, setPendingId] = useState<string | null>(null);
|
|
725
658
|
const [error, setError] = useState<string | null>(null);
|
|
726
659
|
|
|
727
|
-
const tickets = data?.data ?? [];
|
|
660
|
+
const tickets = data?.pages.flatMap((page) => page.data) ?? [];
|
|
728
661
|
|
|
729
662
|
const onCancel = async (ticket: MyTicket) => {
|
|
730
663
|
setError(null);
|
|
@@ -743,18 +676,15 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
743
676
|
|
|
744
677
|
return (
|
|
745
678
|
<div style={card}>
|
|
746
|
-
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
|
|
747
|
-
{tr("forge.account_dashboard.tickets_title")}
|
|
748
|
-
</h2>
|
|
749
679
|
{error && (
|
|
750
|
-
<
|
|
680
|
+
<p role="alert" style={{ fontSize: 14, fontWeight: 600, margin: "0 0 16px" }}>
|
|
751
681
|
{error}
|
|
752
|
-
</
|
|
682
|
+
</p>
|
|
753
683
|
)}
|
|
754
684
|
{isLoading ? (
|
|
755
685
|
<Loading />
|
|
756
686
|
) : tickets.length > 0 ? (
|
|
757
|
-
<div style={
|
|
687
|
+
<div style={list}>
|
|
758
688
|
{tickets.map((ticket) => {
|
|
759
689
|
const cancelled = !!ticket.selfCancelledAt;
|
|
760
690
|
// Absent on an older API build, where every row WAS a purchase.
|
|
@@ -770,24 +700,23 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
770
700
|
const requestState = myTicketRequestState(ticket);
|
|
771
701
|
const isRequestRow = isMyTicketRequestOpenOrEnded(ticket);
|
|
772
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);
|
|
773
707
|
return (
|
|
774
708
|
<div
|
|
775
709
|
key={ticket.id}
|
|
776
|
-
data-testid={requestState ? `my-ticket-request-${requestState}` :
|
|
777
|
-
style={{
|
|
778
|
-
border: `1px solid ${t.primary}20`,
|
|
779
|
-
borderRadius: t.cornerRadius,
|
|
780
|
-
padding: 16,
|
|
781
|
-
opacity: cancelled || requestEnded ? 0.6 : 1,
|
|
782
|
-
}}
|
|
710
|
+
data-testid={requestState ? `my-ticket-request-${requestState}` : "my-ticket"}
|
|
711
|
+
style={{ ...row, opacity: cancelled || requestEnded ? 0.6 : 1 }}
|
|
783
712
|
>
|
|
784
|
-
<div style={{ display: "flex", justifyContent: "space-between", marginBottom:
|
|
713
|
+
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8, gap: 12 }}>
|
|
785
714
|
<div>
|
|
786
|
-
<div style={{ fontWeight: 600 }}>{ticket.eventTitle}</div>
|
|
787
|
-
<div style={
|
|
715
|
+
<div style={{ fontWeight: 600, fontSize: 16 }}>{ticket.eventTitle}</div>
|
|
716
|
+
<div style={meta}>{formatDate(ticket.eventDateTime)}</div>
|
|
788
717
|
</div>
|
|
789
718
|
{requestState && (
|
|
790
|
-
<span style={{ fontSize: 13,
|
|
719
|
+
<span style={{ fontSize: 13, fontWeight: 600, textAlign: "right" }}>
|
|
791
720
|
{requestState === "pending"
|
|
792
721
|
? tr("forge.account_dashboard.request_pending")
|
|
793
722
|
: requestState === "approved"
|
|
@@ -800,20 +729,29 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
800
729
|
</span>
|
|
801
730
|
)}
|
|
802
731
|
{cancelled && !requestState && (
|
|
803
|
-
<span style={{ fontSize: 13, color: t.
|
|
732
|
+
<span style={{ fontSize: 13, color: t.muted }}>{tr("forge.account_dashboard.cancelled")}</span>
|
|
804
733
|
)}
|
|
805
734
|
{/* Says WHY this is in the list at all. Without it a
|
|
806
735
|
transferred ticket looks like a purchase the buyer cannot
|
|
807
736
|
remember making, with no prices on it. */}
|
|
808
737
|
{!cancelled && gifted && (
|
|
809
|
-
<span style={{ fontSize: 13, color: t.
|
|
738
|
+
<span style={{ fontSize: 13, color: t.muted }}>
|
|
810
739
|
{tr("forge.account_dashboard.ticket_transferred")}
|
|
811
740
|
</span>
|
|
812
741
|
)}
|
|
813
742
|
</div>
|
|
814
743
|
|
|
815
744
|
{ticket.items.map((item) => (
|
|
816
|
-
<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
|
+
>
|
|
817
755
|
<span>
|
|
818
756
|
{tr("forge.account_dashboard.ticket_line", {
|
|
819
757
|
title: item.ticketTitle ?? tr("forge.account_dashboard.ticket_fallback_title"),
|
|
@@ -821,7 +759,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
821
759
|
})}
|
|
822
760
|
</span>
|
|
823
761
|
{/* A holder is not shown a price, because the server did
|
|
824
|
-
not send one
|
|
762
|
+
not send one: `Number(null)` is 0, and a confident
|
|
825
763
|
"$0.00" on somebody else's purchase is a lie. */}
|
|
826
764
|
{!gifted && (
|
|
827
765
|
<span>{formatAmount(Number(item.price) * item.quantity, ticket.currency || currency)}</span>
|
|
@@ -830,7 +768,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
830
768
|
))}
|
|
831
769
|
|
|
832
770
|
{!gifted && (
|
|
833
|
-
<div style={{ display: "flex", justifyContent: "space-between", fontWeight:
|
|
771
|
+
<div style={{ display: "flex", justifyContent: "space-between", fontWeight: 600, marginTop: 6 }}>
|
|
834
772
|
{/* Nothing has been taken on a pending request, and never
|
|
835
773
|
will be on one that ended: the label says which. */}
|
|
836
774
|
<span>
|
|
@@ -838,7 +776,9 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
838
776
|
? tr("forge.account_dashboard.total_on_approval")
|
|
839
777
|
: tr("forge.account_dashboard.total")}
|
|
840
778
|
</span>
|
|
841
|
-
<span style={{
|
|
779
|
+
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
|
780
|
+
{formatAmount(Number(ticket.totalAmount), ticket.currency || currency)}
|
|
781
|
+
</span>
|
|
842
782
|
</div>
|
|
843
783
|
)}
|
|
844
784
|
|
|
@@ -866,12 +806,12 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
866
806
|
</div>
|
|
867
807
|
)}
|
|
868
808
|
|
|
869
|
-
{/* Diarise it
|
|
809
|
+
{/* Diarise it: including the Apple option, which needs the
|
|
870
810
|
platform's own `.ics` and so could not exist before. Null
|
|
871
811
|
for a called-off show, and the component draws nothing. */}
|
|
872
812
|
{/* Not offered on a request: the fan has not been admitted to
|
|
873
813
|
anything yet, and a calendar entry says they have. */}
|
|
874
|
-
{!isRequestRow && (
|
|
814
|
+
{!isRequestRow && !eventOver && (
|
|
875
815
|
<AddToCalendar
|
|
876
816
|
variant="compact"
|
|
877
817
|
title={ticket.eventTitle}
|
|
@@ -885,7 +825,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
885
825
|
|
|
886
826
|
{/*
|
|
887
827
|
Wallet passes (2.3). Renders NOTHING unless the API offers a
|
|
888
|
-
real pass for a real pass id
|
|
828
|
+
real pass for a real pass id: which, with no signing
|
|
889
829
|
credentials configured, is every ticket today. Deliberately
|
|
890
830
|
not wrapped in a condition of its own: the component owns that
|
|
891
831
|
decision, and a second copy of the rule here is how one of the
|
|
@@ -894,32 +834,41 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
894
834
|
Eligibility (cancelled order, rotating QR) is the server's
|
|
895
835
|
call, exactly as it is for cancellation above.
|
|
896
836
|
*/}
|
|
897
|
-
<WalletPassButtons passIds={
|
|
837
|
+
{!eventOver && <WalletPassButtons passIds={heldPassIds} variant="compact" style={{ marginTop: 12 }} />}
|
|
898
838
|
|
|
899
839
|
{/*
|
|
900
840
|
Transfer (2.2). The email a transfer sends points at
|
|
901
841
|
`/i/tickets/claim`, so this affordance and that route ship
|
|
902
|
-
together
|
|
842
|
+
together: a send with nowhere to land is undeliverable in
|
|
903
843
|
practice.
|
|
904
844
|
|
|
905
845
|
Renders nothing when the payload carries no `TN-…` pass ids
|
|
906
846
|
(an older API build), because every pass-scoped endpoint would
|
|
907
847
|
404. Whether THIS pass may be handed on is the server's call,
|
|
908
|
-
answered on submit in its own words
|
|
848
|
+
answered on submit in its own words: not hidden behind a
|
|
909
849
|
guess here.
|
|
910
850
|
|
|
911
851
|
HELD passes only: the endpoints resolve the current holder, so
|
|
912
852
|
a control on a named guest's seat or on a ticket already
|
|
913
853
|
claimed by somebody else could answer nothing but 404. The
|
|
914
854
|
pass objects go through too, because each carries the caller's
|
|
915
|
-
own `pendingTransfer
|
|
855
|
+
own `pendingTransfer`: which is what lets a sender withdraw
|
|
916
856
|
from a device that is not the one they sent from.
|
|
917
857
|
*/}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
style={{ marginTop: 12 }}
|
|
922
|
-
|
|
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
|
+
)}
|
|
923
872
|
|
|
924
873
|
{/* The terms they agreed to, in the artist's own words where given. */}
|
|
925
874
|
{ticket.cancellation.description && (
|
|
@@ -933,16 +882,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
933
882
|
<button
|
|
934
883
|
onClick={() => void onCancel(ticket)}
|
|
935
884
|
disabled={pendingId === ticket.id}
|
|
936
|
-
style={{
|
|
937
|
-
marginTop: 12,
|
|
938
|
-
padding: "8px 14px",
|
|
939
|
-
borderRadius: t.cornerRadius,
|
|
940
|
-
border: `1px solid ${t.primary}40`,
|
|
941
|
-
background: "transparent",
|
|
942
|
-
color: t.text,
|
|
943
|
-
cursor: pendingId === ticket.id ? "wait" : "pointer",
|
|
944
|
-
fontWeight: 600,
|
|
945
|
-
}}
|
|
885
|
+
style={{ ...ghostButton, marginTop: 12, cursor: pendingId === ticket.id ? "wait" : "pointer" }}
|
|
946
886
|
>
|
|
947
887
|
{pendingId === ticket.id
|
|
948
888
|
? tr("forge.account_dashboard.cancelling")
|
|
@@ -954,8 +894,9 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
954
894
|
})}
|
|
955
895
|
</div>
|
|
956
896
|
) : (
|
|
957
|
-
<
|
|
897
|
+
<EmptySection>{tr("forge.account_dashboard.tickets_empty")}</EmptySection>
|
|
958
898
|
)}
|
|
899
|
+
<ShowMore hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} onMore={() => void fetchNextPage()} />
|
|
959
900
|
</div>
|
|
960
901
|
);
|
|
961
902
|
}
|
|
@@ -967,8 +908,8 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
967
908
|
*
|
|
968
909
|
* Two states, and they are genuinely different things:
|
|
969
910
|
*
|
|
970
|
-
* WAITING
|
|
971
|
-
* NOTIFIED
|
|
911
|
+
* WAITING: a 1-based `position` in the queue.
|
|
912
|
+
* NOTIFIED: an OFFER with a deadline, and `position: null`. An offer is not a
|
|
972
913
|
* queue place, so it is never rendered as one; the deadline is the
|
|
973
914
|
* whole point and gets the emphasis.
|
|
974
915
|
*
|
|
@@ -978,26 +919,23 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
978
919
|
*/
|
|
979
920
|
function WaitlistTab({ ctx }: { ctx: TabContext }) {
|
|
980
921
|
const { user } = usePublicAuth();
|
|
981
|
-
const {
|
|
922
|
+
const { card, list } = useCardStyles();
|
|
982
923
|
const tr = useForgeT();
|
|
983
924
|
const { formatDate } = ctx;
|
|
984
925
|
const { places, isLoading } = useMyWaitlistPlaces(user?.id);
|
|
985
926
|
|
|
986
927
|
return (
|
|
987
928
|
<div style={card}>
|
|
988
|
-
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
|
|
989
|
-
{tr("forge.account_dashboard.waitlists_title")}
|
|
990
|
-
</h2>
|
|
991
929
|
{isLoading ? (
|
|
992
930
|
<Loading />
|
|
993
931
|
) : places.length > 0 ? (
|
|
994
|
-
<div style={
|
|
932
|
+
<div style={list}>
|
|
995
933
|
{places.map((place) => (
|
|
996
934
|
<WaitlistPlaceRow key={place.id} place={place} formatDate={formatDate} />
|
|
997
935
|
))}
|
|
998
936
|
</div>
|
|
999
937
|
) : (
|
|
1000
|
-
<
|
|
938
|
+
<EmptySection>{tr("forge.account_dashboard.waitlists_empty")}</EmptySection>
|
|
1001
939
|
)}
|
|
1002
940
|
</div>
|
|
1003
941
|
);
|
|
@@ -1010,7 +948,7 @@ function WaitlistPlaceRow({
|
|
|
1010
948
|
place: WaitlistPlace;
|
|
1011
949
|
formatDate: (value: string | number | Date) => string;
|
|
1012
950
|
}) {
|
|
1013
|
-
const { t } = useCardStyles();
|
|
951
|
+
const { t, row, meta, ghostButton, alert } = useCardStyles();
|
|
1014
952
|
const tr = useForgeT();
|
|
1015
953
|
const leave = useLeaveEventWaitlist(place.eventId);
|
|
1016
954
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -1030,19 +968,13 @@ function WaitlistPlaceRow({
|
|
|
1030
968
|
const deadline = place.offer.claimExpiresAt ? new Date(place.offer.claimExpiresAt) : null;
|
|
1031
969
|
|
|
1032
970
|
return (
|
|
1033
|
-
<div
|
|
1034
|
-
style={{
|
|
1035
|
-
border: `1px solid ${t.primary}${place.offer.offerOpen ? "80" : "20"}`,
|
|
1036
|
-
borderRadius: t.cornerRadius,
|
|
1037
|
-
padding: 16,
|
|
1038
|
-
}}
|
|
1039
|
-
>
|
|
971
|
+
<div style={row}>
|
|
1040
972
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
|
|
1041
973
|
<div>
|
|
1042
|
-
<div style={{ fontWeight: 600 }}>
|
|
974
|
+
<div style={{ fontWeight: 600, fontSize: 16 }}>
|
|
1043
975
|
{place.event?.title ?? tr("forge.account_dashboard.waitlist_event_fallback")}
|
|
1044
976
|
</div>
|
|
1045
|
-
<div style={
|
|
977
|
+
<div style={meta}>
|
|
1046
978
|
{tr(
|
|
1047
979
|
place.quantity === 1
|
|
1048
980
|
? "forge.account_dashboard.waitlist_ticket_line_one"
|
|
@@ -1053,21 +985,17 @@ function WaitlistPlaceRow({
|
|
|
1053
985
|
},
|
|
1054
986
|
)}
|
|
1055
987
|
</div>
|
|
1056
|
-
{place.event?.dateTime && (
|
|
1057
|
-
<div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(place.event.dateTime)}</div>
|
|
1058
|
-
)}
|
|
988
|
+
{place.event?.dateTime && <div style={meta}>{formatDate(place.event.dateTime)}</div>}
|
|
1059
989
|
</div>
|
|
1060
990
|
{place.offer.offerOpen ? (
|
|
1061
|
-
<span style={{ fontWeight: 700, color: t.primary }}>
|
|
1062
|
-
{tr("forge.account_dashboard.waitlist_offer_open")}
|
|
1063
|
-
</span>
|
|
991
|
+
<span style={{ fontWeight: 700, color: t.primary }}>{tr("forge.account_dashboard.waitlist_offer_open")}</span>
|
|
1064
992
|
) : place.position != null ? (
|
|
1065
|
-
<span style={{ fontSize: 14,
|
|
993
|
+
<span style={{ fontSize: 14, color: t.muted }}>
|
|
1066
994
|
{tr("forge.account_dashboard.waitlist_position_label")}{" "}
|
|
1067
|
-
<strong style={{ color: t.
|
|
995
|
+
<strong style={{ color: t.text }}>#{place.position}</strong>
|
|
1068
996
|
</span>
|
|
1069
997
|
) : (
|
|
1070
|
-
<span style={{ fontSize: 14,
|
|
998
|
+
<span style={{ fontSize: 14, color: t.muted }}>{tr("forge.account_dashboard.waitlist_on_list")}</span>
|
|
1071
999
|
)}
|
|
1072
1000
|
</div>
|
|
1073
1001
|
|
|
@@ -1075,9 +1003,9 @@ function WaitlistPlaceRow({
|
|
|
1075
1003
|
<div
|
|
1076
1004
|
style={{
|
|
1077
1005
|
marginTop: 12,
|
|
1078
|
-
padding: "10px
|
|
1079
|
-
|
|
1080
|
-
|
|
1006
|
+
padding: "10px 14px",
|
|
1007
|
+
borderLeft: `3px solid ${t.primary}`,
|
|
1008
|
+
background: t.surface,
|
|
1081
1009
|
fontSize: 14,
|
|
1082
1010
|
}}
|
|
1083
1011
|
>
|
|
@@ -1099,21 +1027,16 @@ function WaitlistPlaceRow({
|
|
|
1099
1027
|
</div>
|
|
1100
1028
|
)}
|
|
1101
1029
|
|
|
1102
|
-
{error &&
|
|
1030
|
+
{error && (
|
|
1031
|
+
<p role="alert" style={alert}>
|
|
1032
|
+
{error}
|
|
1033
|
+
</p>
|
|
1034
|
+
)}
|
|
1103
1035
|
|
|
1104
1036
|
<button
|
|
1105
1037
|
onClick={() => void onLeave()}
|
|
1106
1038
|
disabled={leave.isPending}
|
|
1107
|
-
style={{
|
|
1108
|
-
marginTop: 12,
|
|
1109
|
-
padding: "8px 14px",
|
|
1110
|
-
borderRadius: t.cornerRadius,
|
|
1111
|
-
border: `1px solid ${t.primary}40`,
|
|
1112
|
-
background: "transparent",
|
|
1113
|
-
color: t.text,
|
|
1114
|
-
cursor: leave.isPending ? "wait" : "pointer",
|
|
1115
|
-
fontWeight: 600,
|
|
1116
|
-
}}
|
|
1039
|
+
style={{ ...ghostButton, marginTop: 12, cursor: leave.isPending ? "wait" : "pointer" }}
|
|
1117
1040
|
>
|
|
1118
1041
|
{leave.isPending
|
|
1119
1042
|
? tr("forge.account_dashboard.waitlist_leaving")
|
|
@@ -1131,13 +1054,13 @@ function WaitlistPlaceRow({
|
|
|
1131
1054
|
* Session buyers had no purchase surface at all: `GET /public/coaching/bookings/mine`
|
|
1132
1055
|
* and the reschedule action shipped with nothing that could reach them.
|
|
1133
1056
|
*
|
|
1134
|
-
* A booking is deliberately NOT folded into the Orders tab
|
|
1057
|
+
* A booking is deliberately NOT folded into the Orders tab: that tab reads
|
|
1135
1058
|
* `/public/orders` and covers product orders only, so a session would never
|
|
1136
1059
|
* appear there however hard one looked.
|
|
1137
1060
|
*
|
|
1138
1061
|
* Nothing is recomputed here. `canReschedule` and `rescheduleReason` arrive on
|
|
1139
1062
|
* every row, and the slot grid the picker offers is the server's own
|
|
1140
|
-
* availability read
|
|
1063
|
+
* availability read: which already excludes taken slots and hours the coach is
|
|
1141
1064
|
* busy on any of their services.
|
|
1142
1065
|
*
|
|
1143
1066
|
* `cancellation` comes from the POLICY SNAPSHOT taken on the booking at
|
|
@@ -1148,28 +1071,26 @@ function WaitlistPlaceRow({
|
|
|
1148
1071
|
*/
|
|
1149
1072
|
function BookingsTab({ ctx }: { ctx: TabContext }) {
|
|
1150
1073
|
const { user } = usePublicAuth();
|
|
1151
|
-
const {
|
|
1074
|
+
const { card, list } = useCardStyles();
|
|
1152
1075
|
const tr = useForgeT();
|
|
1153
|
-
const { data, isLoading } =
|
|
1076
|
+
const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = useMyBookingsPaged(user?.id);
|
|
1154
1077
|
|
|
1155
|
-
const bookings = data?.data ?? [];
|
|
1078
|
+
const bookings = data?.pages.flatMap((page) => page.data) ?? [];
|
|
1156
1079
|
|
|
1157
1080
|
return (
|
|
1158
1081
|
<div style={card}>
|
|
1159
|
-
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
|
|
1160
|
-
{tr("forge.account_dashboard.sessions_title")}
|
|
1161
|
-
</h2>
|
|
1162
1082
|
{isLoading ? (
|
|
1163
1083
|
<Loading />
|
|
1164
1084
|
) : bookings.length > 0 ? (
|
|
1165
|
-
<div style={
|
|
1085
|
+
<div style={list}>
|
|
1166
1086
|
{bookings.map((booking) => (
|
|
1167
1087
|
<BookingRow key={booking.id} booking={booking} ctx={ctx} />
|
|
1168
1088
|
))}
|
|
1169
1089
|
</div>
|
|
1170
1090
|
) : (
|
|
1171
|
-
<
|
|
1091
|
+
<EmptySection>{tr("forge.account_dashboard.sessions_empty")}</EmptySection>
|
|
1172
1092
|
)}
|
|
1093
|
+
<ShowMore hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} onMore={() => void fetchNextPage()} />
|
|
1173
1094
|
</div>
|
|
1174
1095
|
);
|
|
1175
1096
|
}
|
|
@@ -1194,15 +1115,15 @@ function formatSessionWindow(booking: MyBooking, tr: ForgeT): string {
|
|
|
1194
1115
|
}
|
|
1195
1116
|
|
|
1196
1117
|
/**
|
|
1197
|
-
* WHERE the session is
|
|
1118
|
+
* WHERE the session is: the line this portal existed without.
|
|
1198
1119
|
*
|
|
1199
1120
|
* A client who bought a coaching call could see the time, the price and an
|
|
1200
1121
|
* add-to-calendar button, and nothing at all about how to attend; the answer
|
|
1201
1122
|
* arrived, if it arrived, in a separate email from the coach.
|
|
1202
1123
|
*
|
|
1203
1124
|
* The two kinds are not interchangeable, so they do not render the same. An
|
|
1204
|
-
* online session's link is an anchor
|
|
1205
|
-
* thing on this row anyone wants
|
|
1125
|
+
* online session's link is an anchor: at 9:59 for a 10:00 call it is the only
|
|
1126
|
+
* thing on this row anyone wants: shown in full rather than as the word "Join",
|
|
1206
1127
|
* because the client may be reading this on a phone and about to type it into
|
|
1207
1128
|
* a laptop. An in-person address is plain text: it is the coach's own words,
|
|
1208
1129
|
* often "Studio B, ring the buzzer" rather than anything a maps provider could
|
|
@@ -1210,23 +1131,17 @@ function formatSessionWindow(booking: MyBooking, tr: ForgeT): string {
|
|
|
1210
1131
|
*
|
|
1211
1132
|
* `location` is already resolved server-side (booking override over product
|
|
1212
1133
|
* default) and is null unless the session is confirmed, so there is no rule to
|
|
1213
|
-
* apply here
|
|
1134
|
+
* apply here: which is the same discipline `canReschedule` follows two blocks
|
|
1214
1135
|
* down.
|
|
1215
1136
|
*/
|
|
1216
|
-
function BookingLocationLine({
|
|
1217
|
-
location,
|
|
1218
|
-
primary,
|
|
1219
|
-
}: {
|
|
1220
|
-
location: MyBooking["location"];
|
|
1221
|
-
primary: string;
|
|
1222
|
-
}) {
|
|
1137
|
+
function BookingLocationLine({ location, primary }: { location: MyBooking["location"]; primary: string }) {
|
|
1223
1138
|
const tr = useForgeT();
|
|
1224
1139
|
if (!location) return null;
|
|
1225
1140
|
|
|
1226
1141
|
if (location.type === "online") {
|
|
1227
1142
|
return (
|
|
1228
1143
|
<div style={{ fontSize: 13, marginTop: 4 }}>
|
|
1229
|
-
<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>
|
|
1230
1145
|
<a
|
|
1231
1146
|
href={location.url}
|
|
1232
1147
|
target="_blank"
|
|
@@ -1251,7 +1166,7 @@ function BookingLocationLine({
|
|
|
1251
1166
|
if (location.type === "video") {
|
|
1252
1167
|
return (
|
|
1253
1168
|
<div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
|
|
1254
|
-
<span style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.booking_join_label")}
|
|
1169
|
+
<span style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.booking_join_label")} </span>
|
|
1255
1170
|
{tr("forge.account_dashboard.booking_video_call")}
|
|
1256
1171
|
</div>
|
|
1257
1172
|
);
|
|
@@ -1259,30 +1174,29 @@ function BookingLocationLine({
|
|
|
1259
1174
|
|
|
1260
1175
|
return (
|
|
1261
1176
|
<div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
|
|
1262
|
-
<span style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.booking_where_label")}
|
|
1177
|
+
<span style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.booking_where_label")} </span>
|
|
1263
1178
|
{location.address}
|
|
1264
1179
|
</div>
|
|
1265
1180
|
);
|
|
1266
1181
|
}
|
|
1267
1182
|
|
|
1268
1183
|
function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
|
|
1269
|
-
const { t } = useCardStyles();
|
|
1184
|
+
const { t, row, meta, ghostButton, alert } = useCardStyles();
|
|
1270
1185
|
const tr = useForgeT();
|
|
1271
1186
|
const { formatAmount, currency } = ctx;
|
|
1272
1187
|
const cancelBooking = useCancelMyBooking();
|
|
1273
1188
|
const [picking, setPicking] = useState(false);
|
|
1274
1189
|
const [error, setError] = useState<string | null>(null);
|
|
1275
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;
|
|
1276
1198
|
|
|
1277
|
-
const actionButton
|
|
1278
|
-
padding: "8px 14px",
|
|
1279
|
-
borderRadius: t.cornerRadius,
|
|
1280
|
-
border: `1px solid ${t.primary}40`,
|
|
1281
|
-
background: "transparent",
|
|
1282
|
-
color: t.text,
|
|
1283
|
-
cursor: "pointer",
|
|
1284
|
-
fontWeight: 600,
|
|
1285
|
-
};
|
|
1199
|
+
const actionButton = ghostButton;
|
|
1286
1200
|
|
|
1287
1201
|
const onCancel = async () => {
|
|
1288
1202
|
setError(null);
|
|
@@ -1300,49 +1214,42 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
|
|
|
1300
1214
|
};
|
|
1301
1215
|
|
|
1302
1216
|
return (
|
|
1303
|
-
<div
|
|
1304
|
-
style={{
|
|
1305
|
-
border: `1px solid ${t.primary}20`,
|
|
1306
|
-
borderRadius: t.cornerRadius,
|
|
1307
|
-
padding: 16,
|
|
1308
|
-
opacity: cancelled ? 0.6 : 1,
|
|
1309
|
-
}}
|
|
1310
|
-
>
|
|
1217
|
+
<div data-testid="my-booking" style={{ ...row, opacity: cancelled ? 0.6 : 1 }}>
|
|
1311
1218
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
|
|
1312
1219
|
<div>
|
|
1313
|
-
<div style={{ fontWeight: 600 }}>{booking.coachingProductTitle}</div>
|
|
1314
|
-
<div style={
|
|
1220
|
+
<div style={{ fontWeight: 600, fontSize: 16 }}>{booking.coachingProductTitle}</div>
|
|
1221
|
+
<div style={meta}>{formatSessionWindow(booking, tr)}</div>
|
|
1315
1222
|
{booking.coachingProductDurationMinutes != null && (
|
|
1316
|
-
<div style={
|
|
1223
|
+
<div style={meta}>
|
|
1317
1224
|
{tr("forge.account_dashboard.booking_duration", { minutes: booking.coachingProductDurationMinutes })}
|
|
1318
1225
|
</div>
|
|
1319
1226
|
)}
|
|
1320
1227
|
<BookingLocationLine location={booking.location} primary={t.primary} />
|
|
1321
1228
|
</div>
|
|
1322
1229
|
<div style={{ textAlign: "right" }}>
|
|
1323
|
-
{cancelled && (
|
|
1324
|
-
<div style={{ fontSize: 13, color: t.primary }}>{tr("forge.account_dashboard.cancelled")}</div>
|
|
1325
|
-
)}
|
|
1230
|
+
{cancelled && <div style={{ fontSize: 13, color: t.muted }}>{tr("forge.account_dashboard.cancelled")}</div>}
|
|
1326
1231
|
{booking.totalAmount != null && (
|
|
1327
|
-
<div style={{ fontWeight:
|
|
1232
|
+
<div style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
|
|
1328
1233
|
{formatAmount(booking.totalAmount, booking.currency || currency)}
|
|
1329
1234
|
</div>
|
|
1330
1235
|
)}
|
|
1331
1236
|
</div>
|
|
1332
1237
|
</div>
|
|
1333
1238
|
|
|
1334
|
-
{/* Diarise it
|
|
1239
|
+
{/* Diarise it: including Apple, which needs the platform's own `.ics`.
|
|
1335
1240
|
Null unless the session is confirmed, and the component draws nothing. */}
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
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
|
+
)}
|
|
1346
1253
|
|
|
1347
1254
|
{/* The terms they booked under, in the artist's own words where given. */}
|
|
1348
1255
|
{booking.cancellation.description && (
|
|
@@ -1353,17 +1260,19 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
|
|
|
1353
1260
|
)}
|
|
1354
1261
|
{/* A reschedule rule of its own. When it follows the cancellation window
|
|
1355
1262
|
the sentence above already says it, so nothing is repeated. */}
|
|
1356
|
-
{booking.reschedulePolicy &&
|
|
1357
|
-
|
|
1358
|
-
|
|
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
|
+
)}
|
|
1359
1268
|
|
|
1360
1269
|
{/*
|
|
1361
|
-
The action row. Both controls are drawn from the SERVER's flags
|
|
1362
|
-
`canReschedule` and `cancellation.canCancel
|
|
1270
|
+
The action row. Both controls are drawn from the SERVER's flags:
|
|
1271
|
+
`canReschedule` and `cancellation.canCancel`: never from eligibility
|
|
1363
1272
|
re-derived in the browser, which would give two answers to one question.
|
|
1364
1273
|
*/}
|
|
1365
1274
|
{error && (
|
|
1366
|
-
<p style={
|
|
1275
|
+
<p style={alert} role="alert">
|
|
1367
1276
|
{error}
|
|
1368
1277
|
</p>
|
|
1369
1278
|
)}
|
|
@@ -1391,7 +1300,7 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
|
|
|
1391
1300
|
|
|
1392
1301
|
{/* The way INTO a video session: a Join control while the room is open,
|
|
1393
1302
|
the call itself once pressed. Nothing for any other kind of session. */}
|
|
1394
|
-
{!cancelled && <BookingCallEntry booking={booking} />}
|
|
1303
|
+
{!cancelled && !callNoteExpired && <BookingCallEntry booking={booking} />}
|
|
1395
1304
|
</div>
|
|
1396
1305
|
);
|
|
1397
1306
|
}
|
|
@@ -1487,7 +1396,7 @@ function BookingCallEntry({ booking }: { booking: MyBooking }) {
|
|
|
1487
1396
|
* Pick another time for a session already bought.
|
|
1488
1397
|
*
|
|
1489
1398
|
* The grid is `GET /public/coaching/products/:id/availability` over the next
|
|
1490
|
-
* fortnight
|
|
1399
|
+
* fortnight: the same read the original purchase used, which already drops
|
|
1491
1400
|
* taken slots and hours the coach is busy on any of their services. It is not
|
|
1492
1401
|
* filtered further here: a slot this list offers and the server then refuses
|
|
1493
1402
|
* (two buyers racing for one hour, decided by a database EXCLUDE constraint)
|
|
@@ -1530,7 +1439,15 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
|
|
|
1530
1439
|
const open = (slots ?? []).filter((slot) => slot.id !== booking.coachingBookingSlotId);
|
|
1531
1440
|
|
|
1532
1441
|
return (
|
|
1533
|
-
<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
|
+
>
|
|
1534
1451
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12 }}>
|
|
1535
1452
|
<strong style={{ fontSize: 14 }}>{tr("forge.account_dashboard.reschedule_title")}</strong>
|
|
1536
1453
|
<button
|
|
@@ -1538,7 +1455,7 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
|
|
|
1538
1455
|
style={{
|
|
1539
1456
|
padding: "4px 10px",
|
|
1540
1457
|
borderRadius: t.cornerRadius,
|
|
1541
|
-
border: `1px solid ${t.
|
|
1458
|
+
border: `1px solid ${t.border}`,
|
|
1542
1459
|
background: "transparent",
|
|
1543
1460
|
color: t.text,
|
|
1544
1461
|
cursor: "pointer",
|
|
@@ -1550,7 +1467,7 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
|
|
|
1550
1467
|
</div>
|
|
1551
1468
|
|
|
1552
1469
|
{error && (
|
|
1553
|
-
<p style={{
|
|
1470
|
+
<p style={{ fontSize: 14, fontWeight: 600, marginTop: 10 }} role="alert">
|
|
1554
1471
|
{error}
|
|
1555
1472
|
</p>
|
|
1556
1473
|
)}
|
|
@@ -1570,8 +1487,8 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
|
|
|
1570
1487
|
style={{
|
|
1571
1488
|
padding: "8px 12px",
|
|
1572
1489
|
borderRadius: t.cornerRadius,
|
|
1573
|
-
border: `1px solid ${t.
|
|
1574
|
-
background:
|
|
1490
|
+
border: `1px solid ${t.border}`,
|
|
1491
|
+
background: t.background,
|
|
1575
1492
|
color: t.text,
|
|
1576
1493
|
cursor: reschedule.isPending ? "wait" : "pointer",
|
|
1577
1494
|
fontSize: 13,
|
|
@@ -1592,9 +1509,7 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
|
|
|
1592
1509
|
})}
|
|
1593
1510
|
</div>
|
|
1594
1511
|
) : (
|
|
1595
|
-
<p style={{ fontSize: 13, opacity: 0.7, marginTop: 12 }}>
|
|
1596
|
-
{tr("forge.account_dashboard.reschedule_empty")}
|
|
1597
|
-
</p>
|
|
1512
|
+
<p style={{ fontSize: 13, opacity: 0.7, marginTop: 12 }}>{tr("forge.account_dashboard.reschedule_empty")}</p>
|
|
1598
1513
|
)}
|
|
1599
1514
|
</div>
|
|
1600
1515
|
);
|
|
@@ -1616,15 +1531,10 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
|
|
|
1616
1531
|
* That decision lives in one place, `FilmRentalList`, and both surfaces read it.
|
|
1617
1532
|
*/
|
|
1618
1533
|
function RentalsTab({ ctx }: { ctx: TabContext }) {
|
|
1619
|
-
const {
|
|
1620
|
-
const tr = useForgeT();
|
|
1621
|
-
|
|
1534
|
+
const { card } = useCardStyles();
|
|
1622
1535
|
return (
|
|
1623
1536
|
<div style={card}>
|
|
1624
|
-
<
|
|
1625
|
-
{tr("forge.account_dashboard.rentals_title")}
|
|
1626
|
-
</h2>
|
|
1627
|
-
<FilmRentalList navigate={ctx.onNavigateFilm} />
|
|
1537
|
+
<FilmRentalList navigate={ctx.onNavigateFilm} layout="rows" />
|
|
1628
1538
|
</div>
|
|
1629
1539
|
);
|
|
1630
1540
|
}
|
|
@@ -1640,46 +1550,50 @@ function RentalsTab({ ctx }: { ctx: TabContext }) {
|
|
|
1640
1550
|
* the question this whole tab exists to stop someone asking.
|
|
1641
1551
|
*/
|
|
1642
1552
|
function CoursesTab({ ctx, courses }: { ctx: TabContext; courses: MyCourse[] }) {
|
|
1643
|
-
const {
|
|
1553
|
+
const { card, list, row, meta, button, ghostButton } = useCardStyles();
|
|
1644
1554
|
const tr = useForgeT();
|
|
1645
1555
|
|
|
1646
1556
|
return (
|
|
1647
1557
|
<div style={card}>
|
|
1648
|
-
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
|
|
1649
|
-
{tr("forge.account_dashboard.courses_title")}
|
|
1650
|
-
</h2>
|
|
1651
|
-
|
|
1652
1558
|
{courses.length === 0 ? (
|
|
1653
|
-
<
|
|
1559
|
+
<EmptySection>{tr("forge.account_dashboard.courses_empty")}</EmptySection>
|
|
1654
1560
|
) : (
|
|
1655
|
-
<div style={
|
|
1561
|
+
<div style={list}>
|
|
1656
1562
|
{courses.map((course) => (
|
|
1657
|
-
<
|
|
1563
|
+
<div
|
|
1658
1564
|
key={course.accessId}
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
style={{
|
|
1662
|
-
textAlign: "left",
|
|
1663
|
-
background: t.surface,
|
|
1664
|
-
border: `1px solid ${t.border}`,
|
|
1665
|
-
borderRadius: t.cornerRadius,
|
|
1666
|
-
padding: 16,
|
|
1667
|
-
cursor: ctx.onNavigateCourse ? "pointer" : "default",
|
|
1668
|
-
color: t.text,
|
|
1669
|
-
}}
|
|
1565
|
+
data-testid="my-course"
|
|
1566
|
+
style={{ ...row, display: "flex", alignItems: "center", gap: 16, flexWrap: "wrap" }}
|
|
1670
1567
|
>
|
|
1671
|
-
<div style={{
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
{
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
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>
|
|
1681
1580
|
</div>
|
|
1682
|
-
|
|
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>
|
|
1683
1597
|
))}
|
|
1684
1598
|
</div>
|
|
1685
1599
|
)}
|
|
@@ -1691,38 +1605,61 @@ function CoursesTab({ ctx, courses }: { ctx: TabContext; courses: MyCourse[] })
|
|
|
1691
1605
|
|
|
1692
1606
|
function OrdersTab({ ctx }: { ctx: TabContext }) {
|
|
1693
1607
|
const { user } = usePublicAuth();
|
|
1694
|
-
const { t, card } = useCardStyles();
|
|
1608
|
+
const { t, card, list, row, meta } = useCardStyles();
|
|
1695
1609
|
const tr = useForgeT();
|
|
1696
1610
|
const { formatAmount, formatDate, currency } = ctx;
|
|
1697
|
-
const { data
|
|
1611
|
+
const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = useUserOrdersPaged(user?.id);
|
|
1612
|
+
const orders = data?.pages.flat();
|
|
1698
1613
|
|
|
1699
1614
|
const formatStatus = (s: OrderStatus) => s.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
|
1700
1615
|
|
|
1701
1616
|
return (
|
|
1702
1617
|
<div style={card}>
|
|
1703
|
-
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
|
|
1704
|
-
{tr("forge.account_dashboard.orders_title")}
|
|
1705
|
-
</h2>
|
|
1706
1618
|
{isLoading ? (
|
|
1707
1619
|
<Loading />
|
|
1708
1620
|
) : orders && orders.length > 0 ? (
|
|
1709
|
-
<div style={
|
|
1621
|
+
<div style={list}>
|
|
1710
1622
|
{orders
|
|
1711
1623
|
.filter((o) => ![OrderStatus.InitiatedPayment, OrderStatus.PaymentFailed].includes(o.status))
|
|
1712
1624
|
.map((order) => (
|
|
1713
|
-
<div key={order.id} style={
|
|
1714
|
-
<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
|
+
>
|
|
1715
1635
|
<div>
|
|
1716
|
-
|
|
1717
|
-
|
|
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>
|
|
1718
1644
|
</div>
|
|
1719
|
-
<div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(order.createdAt)}</div>
|
|
1720
1645
|
</div>
|
|
1721
|
-
<span style={{
|
|
1646
|
+
<span style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
|
|
1647
|
+
{formatAmount(order.totalAmount, order.currency || currency)}
|
|
1648
|
+
</span>
|
|
1722
1649
|
</div>
|
|
1723
1650
|
{order.deliveryGroups.map((group) =>
|
|
1724
1651
|
group.items.map((item, i) => (
|
|
1725
|
-
<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
|
+
>
|
|
1726
1663
|
<span>
|
|
1727
1664
|
{tr("forge.account_dashboard.order_line", { title: item.title, quantity: item.quantity })}
|
|
1728
1665
|
{/* Which version. Two versions of one release carry the
|
|
@@ -1732,20 +1669,19 @@ function OrdersTab({ ctx }: { ctx: TabContext }) {
|
|
|
1732
1669
|
the sale, not re-resolved. */}
|
|
1733
1670
|
<CartLineOptions item={item} accent={t.primary} size="small" style={{ marginTop: 2 }} />
|
|
1734
1671
|
</span>
|
|
1735
|
-
<span
|
|
1672
|
+
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
|
1673
|
+
{formatAmount(item.price * item.quantity, order.currency || currency)}
|
|
1674
|
+
</span>
|
|
1736
1675
|
</div>
|
|
1737
1676
|
)),
|
|
1738
1677
|
)}
|
|
1739
|
-
<div style={{ display: "flex", justifyContent: "space-between", fontWeight: 700, marginTop: 12, paddingTop: 12, borderTop: `1px solid ${t.primary}20` }}>
|
|
1740
|
-
<span>{tr("forge.account_dashboard.total")}</span>
|
|
1741
|
-
<span style={{ color: t.primary }}>{formatAmount(order.totalAmount, order.currency || currency)}</span>
|
|
1742
|
-
</div>
|
|
1743
1678
|
</div>
|
|
1744
1679
|
))}
|
|
1745
1680
|
</div>
|
|
1746
1681
|
) : (
|
|
1747
|
-
<
|
|
1682
|
+
<EmptySection>{tr("forge.account_dashboard.orders_empty")}</EmptySection>
|
|
1748
1683
|
)}
|
|
1684
|
+
<ShowMore hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} onMore={() => void fetchNextPage()} />
|
|
1749
1685
|
</div>
|
|
1750
1686
|
);
|
|
1751
1687
|
}
|
|
@@ -1754,31 +1690,28 @@ function OrdersTab({ ctx }: { ctx: TabContext }) {
|
|
|
1754
1690
|
|
|
1755
1691
|
function SavedTab() {
|
|
1756
1692
|
const { user } = usePublicAuth();
|
|
1757
|
-
const {
|
|
1693
|
+
const { card, list, row, meta } = useCardStyles();
|
|
1758
1694
|
const tr = useForgeT();
|
|
1759
1695
|
const { data, isLoading } = useSavedPosts(user?.id);
|
|
1760
1696
|
const posts = data?.data;
|
|
1761
1697
|
|
|
1762
1698
|
return (
|
|
1763
1699
|
<div style={card}>
|
|
1764
|
-
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
|
|
1765
|
-
{tr("forge.account_dashboard.saved_title")}
|
|
1766
|
-
</h2>
|
|
1767
1700
|
{isLoading ? (
|
|
1768
1701
|
<Loading />
|
|
1769
1702
|
) : posts && posts.length > 0 ? (
|
|
1770
|
-
<div style={
|
|
1703
|
+
<div style={list}>
|
|
1771
1704
|
{posts.map((post) => (
|
|
1772
|
-
<div key={post.id} style={
|
|
1773
|
-
<p style={{ fontSize:
|
|
1774
|
-
<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 }}>
|
|
1775
1708
|
{tr("forge.account_dashboard.saved_post_stats", { likes: post.likes, comments: post.comments })}
|
|
1776
1709
|
</p>
|
|
1777
1710
|
</div>
|
|
1778
1711
|
))}
|
|
1779
1712
|
</div>
|
|
1780
1713
|
) : (
|
|
1781
|
-
<
|
|
1714
|
+
<EmptySection>{tr("forge.account_dashboard.saved_empty")}</EmptySection>
|
|
1782
1715
|
)}
|
|
1783
1716
|
</div>
|
|
1784
1717
|
);
|
|
@@ -1788,7 +1721,7 @@ function SavedTab() {
|
|
|
1788
1721
|
|
|
1789
1722
|
function AccountTab({ ctx }: { ctx: TabContext }) {
|
|
1790
1723
|
const { user, refetchUser, logout } = usePublicAuth();
|
|
1791
|
-
const { t,
|
|
1724
|
+
const { t, button, ghostButton, input, meta } = useCardStyles();
|
|
1792
1725
|
const tr = useForgeT();
|
|
1793
1726
|
const { formatDate } = ctx;
|
|
1794
1727
|
|
|
@@ -1851,15 +1784,23 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
|
|
|
1851
1784
|
}
|
|
1852
1785
|
};
|
|
1853
1786
|
|
|
1854
|
-
|
|
1855
|
-
|
|
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 };
|
|
1856
1797
|
|
|
1857
1798
|
return (
|
|
1858
|
-
<div style={{ display: "flex", flexDirection: "column",
|
|
1799
|
+
<div style={{ display: "flex", flexDirection: "column", borderTop: `1px solid ${t.border}`, color: t.text }}>
|
|
1859
1800
|
{/* Profile */}
|
|
1860
1801
|
<form onSubmit={onUpdateProfile} style={card}>
|
|
1861
1802
|
<h2 style={sectionTitle}>{tr("forge.account_dashboard.profile_section_title")}</h2>
|
|
1862
|
-
<div
|
|
1803
|
+
<div className="fg:flex fg:flex-col fg:gap-3 fg:sm:flex-row" style={{ marginBottom: 12 }}>
|
|
1863
1804
|
<div style={{ flex: 1 }}>
|
|
1864
1805
|
<label style={label}>{tr("forge.account_dashboard.first_name")}</label>
|
|
1865
1806
|
<input value={firstName} onChange={(e) => setFirstName(e.target.value)} style={input} />
|
|
@@ -1869,7 +1810,7 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
|
|
|
1869
1810
|
<input value={lastName} onChange={(e) => setLastName(e.target.value)} style={input} />
|
|
1870
1811
|
</div>
|
|
1871
1812
|
</div>
|
|
1872
|
-
<p style={{
|
|
1813
|
+
<p style={{ ...meta, margin: "0 0 16px" }}>
|
|
1873
1814
|
{tr("forge.account_dashboard.email_readonly", { email: user?.email ?? "" })}
|
|
1874
1815
|
</p>
|
|
1875
1816
|
{profileMsg && <p style={{ fontSize: 13, marginBottom: 12 }}>{profileMsg}</p>}
|
|
@@ -1881,20 +1822,39 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
|
|
|
1881
1822
|
{/* Password */}
|
|
1882
1823
|
<form onSubmit={onChangePassword} style={card}>
|
|
1883
1824
|
<h2 style={sectionTitle}>{tr("forge.account_dashboard.password_section_title")}</h2>
|
|
1884
|
-
{passwordMsg &&
|
|
1825
|
+
{passwordMsg && (
|
|
1826
|
+
<p role="alert" style={{ fontSize: 14, fontWeight: 600, margin: "0 0 12px" }}>
|
|
1827
|
+
{passwordMsg}
|
|
1828
|
+
</p>
|
|
1829
|
+
)}
|
|
1885
1830
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
1886
1831
|
<div>
|
|
1887
1832
|
<label style={label}>{tr("forge.account_dashboard.current_password")}</label>
|
|
1888
|
-
<input
|
|
1833
|
+
<input
|
|
1834
|
+
type="password"
|
|
1835
|
+
value={currentPassword}
|
|
1836
|
+
onChange={(e) => setCurrentPassword(e.target.value)}
|
|
1837
|
+
style={input}
|
|
1838
|
+
/>
|
|
1889
1839
|
</div>
|
|
1890
|
-
<div
|
|
1840
|
+
<div className="fg:flex fg:flex-col fg:gap-3 fg:sm:flex-row">
|
|
1891
1841
|
<div style={{ flex: 1 }}>
|
|
1892
1842
|
<label style={label}>{tr("forge.account_dashboard.new_password")}</label>
|
|
1893
|
-
<input
|
|
1843
|
+
<input
|
|
1844
|
+
type="password"
|
|
1845
|
+
value={newPassword}
|
|
1846
|
+
onChange={(e) => setNewPassword(e.target.value)}
|
|
1847
|
+
style={input}
|
|
1848
|
+
/>
|
|
1894
1849
|
</div>
|
|
1895
1850
|
<div style={{ flex: 1 }}>
|
|
1896
1851
|
<label style={label}>{tr("forge.account_dashboard.confirm_password")}</label>
|
|
1897
|
-
<input
|
|
1852
|
+
<input
|
|
1853
|
+
type="password"
|
|
1854
|
+
value={confirmPassword}
|
|
1855
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
1856
|
+
style={input}
|
|
1857
|
+
/>
|
|
1898
1858
|
</div>
|
|
1899
1859
|
</div>
|
|
1900
1860
|
</div>
|
|
@@ -1906,17 +1866,15 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
|
|
|
1906
1866
|
{/* Data rights */}
|
|
1907
1867
|
<div style={card}>
|
|
1908
1868
|
<h2 style={sectionTitle}>{tr("forge.account_dashboard.data_section_title")}</h2>
|
|
1909
|
-
<p style={{
|
|
1910
|
-
{tr("forge.account_dashboard.data_export_description")}
|
|
1911
|
-
</p>
|
|
1869
|
+
<p style={{ ...meta, margin: "0 0 12px" }}>{tr("forge.account_dashboard.data_export_description")}</p>
|
|
1912
1870
|
<button onClick={onExport} style={ghostButton}>
|
|
1913
1871
|
{tr("forge.account_dashboard.export_data")}
|
|
1914
1872
|
</button>
|
|
1915
1873
|
</div>
|
|
1916
1874
|
|
|
1917
1875
|
{/* Delete account */}
|
|
1918
|
-
<div style={{ ...card,
|
|
1919
|
-
<h2 style={
|
|
1876
|
+
<div style={{ ...card, borderBottom: "none" }}>
|
|
1877
|
+
<h2 style={sectionTitle}>{tr("forge.account_dashboard.delete_section_title")}</h2>
|
|
1920
1878
|
{scheduledAt ? (
|
|
1921
1879
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
1922
1880
|
<p style={{ fontSize: 14 }}>
|
|
@@ -1930,7 +1888,7 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
|
|
|
1930
1888
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
1931
1889
|
<p style={{ fontSize: 14 }}>{tr("forge.account_dashboard.deletion_confirm_body")}</p>
|
|
1932
1890
|
<div style={{ display: "flex", gap: 12 }}>
|
|
1933
|
-
<button onClick={() => requestDeletion.mutate()} style={
|
|
1891
|
+
<button onClick={() => requestDeletion.mutate()} style={button}>
|
|
1934
1892
|
{tr("forge.account_dashboard.deletion_confirm")}
|
|
1935
1893
|
</button>
|
|
1936
1894
|
<button onClick={() => setConfirmDelete(false)} style={ghostButton}>
|
|
@@ -1939,10 +1897,7 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
|
|
|
1939
1897
|
</div>
|
|
1940
1898
|
</div>
|
|
1941
1899
|
) : (
|
|
1942
|
-
<button
|
|
1943
|
-
onClick={() => setConfirmDelete(true)}
|
|
1944
|
-
style={{ padding: "8px 14px", borderRadius: t.cornerRadius, background: "#ef444411", color: "#ef4444", border: "1px solid #ef444433", cursor: "pointer" }}
|
|
1945
|
-
>
|
|
1900
|
+
<button onClick={() => setConfirmDelete(true)} style={ghostButton}>
|
|
1946
1901
|
{tr("forge.account_dashboard.request_deletion")}
|
|
1947
1902
|
</button>
|
|
1948
1903
|
)}
|
|
@@ -1953,85 +1908,282 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
|
|
|
1953
1908
|
|
|
1954
1909
|
// ---- Notifications tab ------------------------------------------------------
|
|
1955
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
|
+
|
|
1956
2027
|
function NotificationsTab() {
|
|
1957
|
-
const { t, card } = useCardStyles();
|
|
2028
|
+
const { t, card, list, row, alert } = useCardStyles();
|
|
1958
2029
|
const tr = useForgeT();
|
|
1959
|
-
const { data } =
|
|
1960
|
-
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);
|
|
1961
2036
|
|
|
1962
|
-
|
|
2037
|
+
if (isLoading || !data) return <Loading />;
|
|
1963
2038
|
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
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;
|
|
1969
2055
|
});
|
|
1970
2056
|
}
|
|
1971
|
-
}
|
|
2057
|
+
};
|
|
1972
2058
|
|
|
1973
|
-
const
|
|
1974
|
-
|
|
1975
|
-
|
|
2059
|
+
const chooseCadence = async (value: DigestCadence) => {
|
|
2060
|
+
setError(null);
|
|
2061
|
+
setCadence(value);
|
|
1976
2062
|
try {
|
|
1977
|
-
await
|
|
2063
|
+
await update.mutateAsync({ digestCadence: value });
|
|
1978
2064
|
} catch {
|
|
1979
|
-
|
|
2065
|
+
setError(tr("forge.account_dashboard.notify_save_error"));
|
|
2066
|
+
} finally {
|
|
2067
|
+
setCadence(null);
|
|
1980
2068
|
}
|
|
1981
2069
|
};
|
|
1982
2070
|
|
|
1983
|
-
const
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
<div style={{ fontWeight: 600 }}>{title}</div>
|
|
1987
|
-
<div style={{ fontSize: 13, opacity: 0.7 }}>{desc}</div>
|
|
1988
|
-
</div>
|
|
2071
|
+
const chip = (key: string, label: string, typeLabel: string) => {
|
|
2072
|
+
const on = isOn(key);
|
|
2073
|
+
return (
|
|
1989
2074
|
<button
|
|
1990
|
-
|
|
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)}
|
|
1991
2081
|
style={{
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
2082
|
+
display: "inline-flex",
|
|
2083
|
+
alignItems: "center",
|
|
2084
|
+
gap: 6,
|
|
2085
|
+
minHeight: 34,
|
|
2086
|
+
padding: "6px 12px",
|
|
1995
2087
|
borderRadius: 999,
|
|
1996
|
-
|
|
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,
|
|
1997
2096
|
cursor: "pointer",
|
|
1998
|
-
background: preferences[k] ? t.primary : `${t.text}40`,
|
|
1999
2097
|
}}
|
|
2000
|
-
aria-pressed={preferences[k]}
|
|
2001
2098
|
>
|
|
2002
|
-
<
|
|
2003
|
-
|
|
2004
|
-
position: "absolute",
|
|
2005
|
-
top: 4,
|
|
2006
|
-
left: preferences[k] ? 24 : 4,
|
|
2007
|
-
width: 16,
|
|
2008
|
-
height: 16,
|
|
2009
|
-
borderRadius: "50%",
|
|
2010
|
-
background: "#fff",
|
|
2011
|
-
transition: "left 0.15s",
|
|
2012
|
-
}}
|
|
2013
|
-
/>
|
|
2099
|
+
{on ? <Check size={14} aria-hidden style={{ color: t.primary }} /> : null}
|
|
2100
|
+
{label}
|
|
2014
2101
|
</button>
|
|
2015
|
-
|
|
2016
|
-
|
|
2102
|
+
);
|
|
2103
|
+
};
|
|
2017
2104
|
|
|
2018
2105
|
return (
|
|
2019
|
-
<div style={card}>
|
|
2020
|
-
<
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
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>
|
|
2035
2187
|
</div>
|
|
2036
2188
|
);
|
|
2037
2189
|
}
|