@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
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { MissingCommunityProviderError, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
|
|
3
|
+
import {
|
|
4
|
+
useCancelCommunityMembership,
|
|
5
|
+
useDowngradeCommunityMembership,
|
|
6
|
+
useMyCommunityMemberships,
|
|
7
|
+
useUndoCommunityDowngrade,
|
|
8
|
+
type PublicTier,
|
|
9
|
+
} from "../../../data/queries/useCommunities";
|
|
10
|
+
import { useOpenBillingPortal } from "../../../data/queries/useSubscriptions";
|
|
11
|
+
import { cycleFloor, cycleIsFree, defaultCycle, type BillingCycle } from "../../format/membershipPwyw";
|
|
12
|
+
import { useCommunity } from "./useCommunity";
|
|
13
|
+
|
|
14
|
+
const errMessage = (e: unknown) =>
|
|
15
|
+
(e as { response?: { data?: { message?: string } } })?.response?.data?.message || "Something went wrong.";
|
|
16
|
+
|
|
17
|
+
/** A monthly-equivalent price, so tiers sold on different cycles still order correctly. */
|
|
18
|
+
const monthlyPrice = (tier: PublicTier) => {
|
|
19
|
+
const cycle = defaultCycle(tier);
|
|
20
|
+
const floor = cycleFloor(tier, cycle);
|
|
21
|
+
return cycle === "year" ? floor / 12 : floor;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The tiers a member can move DOWN to: this community's tiers priced below the
|
|
26
|
+
* current one, excluding anything the upgrade list offers. Pure.
|
|
27
|
+
*/
|
|
28
|
+
export function communityDowngradeTiers(tiers: PublicTier[], currentTierId: string | null | undefined, upgradeTierIds: string[]): PublicTier[] {
|
|
29
|
+
const current = tiers.find((tier) => tier.id === currentTierId);
|
|
30
|
+
if (!current) return [];
|
|
31
|
+
const currentPrice = monthlyPrice(current);
|
|
32
|
+
return tiers.filter(
|
|
33
|
+
(tier) =>
|
|
34
|
+
tier.id !== current.id &&
|
|
35
|
+
!upgradeTierIds.includes(tier.id) &&
|
|
36
|
+
tier.joinMode !== "application" &&
|
|
37
|
+
monthlyPrice(tier) < currentPrice,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What the member pays, as far as the client can tell. */
|
|
42
|
+
export interface CommunityMembershipBilling {
|
|
43
|
+
/** True when the membership is paid for (a subscription with a price). */
|
|
44
|
+
isPaid: boolean;
|
|
45
|
+
/** Per period, in `currency`. Null on a free membership, or while unknown. */
|
|
46
|
+
amount: number | null;
|
|
47
|
+
currency: string | null;
|
|
48
|
+
cycle: BillingCycle | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Is this membership paid for, and what does it cost? Pure.
|
|
53
|
+
*
|
|
54
|
+
* The answer comes from the member's own row in `/public/memberships/mine`
|
|
55
|
+
* (`row`), which carries the subscription's real amount and cycle: a
|
|
56
|
+
* pay-what-you-want member pays what they chose, and a member on a tier sold
|
|
57
|
+
* both ways pays the cycle they picked. Until that row arrives, the tier's own
|
|
58
|
+
* price stands in, so a free tier never flashes "Update payment method".
|
|
59
|
+
*/
|
|
60
|
+
export function communityMembershipBilling(
|
|
61
|
+
row: { amount: number | null; currency: string | null; billingCycle: string | null } | null | undefined,
|
|
62
|
+
tier: PublicTier | null | undefined,
|
|
63
|
+
): CommunityMembershipBilling {
|
|
64
|
+
if (row) {
|
|
65
|
+
const amount = typeof row.amount === "number" && row.amount > 0 ? row.amount : null;
|
|
66
|
+
const cycle = row.billingCycle === "year" ? "year" : row.billingCycle === "month" ? "month" : null;
|
|
67
|
+
return { isPaid: amount !== null, amount, currency: amount !== null ? row.currency : null, cycle: amount !== null ? cycle : null };
|
|
68
|
+
}
|
|
69
|
+
if (!tier) return { isPaid: false, amount: null, currency: null, cycle: null };
|
|
70
|
+
const cycle = defaultCycle(tier);
|
|
71
|
+
return { isPaid: !cycleIsFree(tier, cycle), amount: null, currency: null, cycle: null };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The Membership menu inside a community: current tier, renewal date, a pending
|
|
76
|
+
* downgrade, and the actions. Cancel keeps the paid period; a downgrade takes
|
|
77
|
+
* effect at period end and can be withdrawn until then (decision 11).
|
|
78
|
+
*
|
|
79
|
+
* Cancel is two-step (`requestCancel()` then `confirmCancel()`), because a
|
|
80
|
+
* destructive action has to be confirmed and the confirmation's wording depends
|
|
81
|
+
* on state the hook owns.
|
|
82
|
+
*/
|
|
83
|
+
export function useCommunityMembership() {
|
|
84
|
+
const context = useOptionalCommunityContext();
|
|
85
|
+
if (!context) throw new MissingCommunityProviderError("useCommunityMembership()");
|
|
86
|
+
const community = useCommunity();
|
|
87
|
+
const ref = community.slug ?? context.slug ?? context.ref;
|
|
88
|
+
const cancel = useCancelCommunityMembership(ref);
|
|
89
|
+
const downgrade = useDowngradeCommunityMembership(ref);
|
|
90
|
+
const undo = useUndoCommunityDowngrade(ref);
|
|
91
|
+
const billingPortal = useOpenBillingPortal();
|
|
92
|
+
const mine = useMyCommunityMemberships({ enabled: !!community.membership });
|
|
93
|
+
|
|
94
|
+
const [cancelRequested, setCancelRequested] = useState(false);
|
|
95
|
+
const [error, setError] = useState<string | null>(null);
|
|
96
|
+
|
|
97
|
+
const membership = community.membership;
|
|
98
|
+
const upgradeIds = community.upgradeTiers.map((tier) => tier.id);
|
|
99
|
+
const downgradeTiers = useMemo(
|
|
100
|
+
() => communityDowngradeTiers(community.tiers, membership?.tierId, upgradeIds),
|
|
101
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
102
|
+
[community.tiers, membership?.tierId, upgradeIds.join(",")],
|
|
103
|
+
);
|
|
104
|
+
const pendingDowngradeTier = membership?.pendingDowngradeTierId
|
|
105
|
+
? (community.tiers.find((tier) => tier.id === membership.pendingDowngradeTierId) ?? {
|
|
106
|
+
id: membership.pendingDowngradeTierId,
|
|
107
|
+
name: "",
|
|
108
|
+
})
|
|
109
|
+
: null;
|
|
110
|
+
|
|
111
|
+
const mineRow = membership ? mine.data?.find((row) => row.id === membership.id) : undefined;
|
|
112
|
+
const billing = communityMembershipBilling(
|
|
113
|
+
mineRow ? { amount: mineRow.subscriptionAmount ?? null, currency: mineRow.subscriptionCurrency ?? null, billingCycle: mineRow.billingCycle ?? null } : null,
|
|
114
|
+
community.currentTier,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const run = async (action: () => Promise<unknown>) => {
|
|
118
|
+
setError(null);
|
|
119
|
+
try {
|
|
120
|
+
await action();
|
|
121
|
+
await Promise.all([community.refetch(), membership ? mine.refetch() : Promise.resolve()]);
|
|
122
|
+
return true;
|
|
123
|
+
} catch (e) {
|
|
124
|
+
setError(errMessage(e));
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
isLoading: community.isLoading,
|
|
131
|
+
/** False for a viewer with nothing to manage here. */
|
|
132
|
+
hasMembership: !!membership,
|
|
133
|
+
membership,
|
|
134
|
+
currentTier: community.currentTier,
|
|
135
|
+
/** When the paid period ends: the renewal date, or the last day when cancelling. */
|
|
136
|
+
periodEnd: membership?.currentPeriodEnd ?? null,
|
|
137
|
+
isCancelling: !!membership?.cancelAtPeriodEnd,
|
|
138
|
+
pendingDowngradeTier,
|
|
139
|
+
downgradeTiers,
|
|
140
|
+
isPastDue: community.viewer?.state === "past_due",
|
|
141
|
+
/** Paid or free, and the price per period when the server has said. A free membership has no card to update. */
|
|
142
|
+
billing,
|
|
143
|
+
isPaid: billing.isPaid,
|
|
144
|
+
/** A higher tier exists, so the sheet offers Upgrade. */
|
|
145
|
+
hasUpgrade: community.hasUpgrade,
|
|
146
|
+
error,
|
|
147
|
+
isWorking: cancel.isPending || downgrade.isPending || undo.isPending || billingPortal.isPending,
|
|
148
|
+
|
|
149
|
+
cancelRequested,
|
|
150
|
+
/** Ask to cancel: show the confirmation, which must say the paid period is kept. */
|
|
151
|
+
requestCancel: () => {
|
|
152
|
+
setError(null);
|
|
153
|
+
setCancelRequested(true);
|
|
154
|
+
},
|
|
155
|
+
abortCancel: () => setCancelRequested(false),
|
|
156
|
+
/** Cancel for real. Access runs to the end of the paid period. */
|
|
157
|
+
confirmCancel: async () => {
|
|
158
|
+
const ok = await run(() => cancel.mutateAsync());
|
|
159
|
+
if (ok) setCancelRequested(false);
|
|
160
|
+
return ok;
|
|
161
|
+
},
|
|
162
|
+
/** The same, without the confirmation step, for a host that confirms on its own. */
|
|
163
|
+
cancel: () => run(() => cancel.mutateAsync()),
|
|
164
|
+
/** Move down to `tierId` at the end of the paid period. */
|
|
165
|
+
downgrade: (tierId: string) => run(() => downgrade.mutateAsync({ membershipTierId: tierId })),
|
|
166
|
+
/** Withdraw a pending downgrade. */
|
|
167
|
+
undoDowngrade: () => run(() => undo.mutateAsync()),
|
|
168
|
+
/**
|
|
169
|
+
* Open the provider's billing page to change the card. The server derives
|
|
170
|
+
* the customer from the session; the fan comes back to this page.
|
|
171
|
+
*/
|
|
172
|
+
updateCard: async () => {
|
|
173
|
+
setError(null);
|
|
174
|
+
try {
|
|
175
|
+
const { url } = await billingPortal.mutateAsync();
|
|
176
|
+
if (typeof window !== "undefined" && url) window.location.assign(url);
|
|
177
|
+
} catch (e) {
|
|
178
|
+
setError(errMessage(e));
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from "react";
|
|
2
|
+
import { MissingCommunityProviderError, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
|
|
3
|
+
import {
|
|
4
|
+
useCommunityUpgradePreview,
|
|
5
|
+
useInvalidateCommunities,
|
|
6
|
+
useUpgradeCommunityTier,
|
|
7
|
+
type CommunityUpgradeResult,
|
|
8
|
+
type CommunityUpgradeTier,
|
|
9
|
+
type PublicTier,
|
|
10
|
+
} from "../../../data/queries/useCommunities";
|
|
11
|
+
import {
|
|
12
|
+
hasPaystackSession,
|
|
13
|
+
openPaystackCheckout as openPaystackModal,
|
|
14
|
+
type PaystackCheckoutOutcome,
|
|
15
|
+
type PaystackCheckoutSession,
|
|
16
|
+
} from "../../../utils/paystackCheckout";
|
|
17
|
+
import { useCommunity } from "./useCommunity";
|
|
18
|
+
import { communityHomePath } from "./paths";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where the upgrade sheet is.
|
|
22
|
+
*
|
|
23
|
+
* choose the tiers above the current one
|
|
24
|
+
* confirm one tier chosen: its price now (prorated on Stripe) or the
|
|
25
|
+
* warning that the card is collected again (Paystack)
|
|
26
|
+
* submitting the upgrade request is in flight
|
|
27
|
+
* payment the provider needs the card: a Stripe secret to confirm, or a
|
|
28
|
+
* Paystack checkout to open
|
|
29
|
+
* done upgraded
|
|
30
|
+
*/
|
|
31
|
+
export type CommunityUpgradeStep = "choose" | "confirm" | "submitting" | "payment" | "done";
|
|
32
|
+
|
|
33
|
+
export interface UseCommunityUpgradeOptions {
|
|
34
|
+
/** Where the provider returns after a card payment. Default: the community home with `confirmSubscription=1`. */
|
|
35
|
+
returnPath?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Called once the upgrade is complete. Omitted, the fan is sent to the
|
|
38
|
+
* community home (`navigate`, or a full-page visit).
|
|
39
|
+
*/
|
|
40
|
+
onUpgraded?: (result: { requiresConfirmation: boolean; path: string }) => void;
|
|
41
|
+
/** The host's router, used for the default landing. */
|
|
42
|
+
navigate?: (href: string) => void;
|
|
43
|
+
/** Open with this tier chosen (a locked item's "Unlock with {tier}"). */
|
|
44
|
+
initialTierId?: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const errMessage = (e: unknown) =>
|
|
48
|
+
(e as { response?: { data?: { message?: string } } })?.response?.data?.message || "Something went wrong.";
|
|
49
|
+
|
|
50
|
+
export interface CommunityUpgradeOption extends CommunityUpgradeTier {
|
|
51
|
+
/** The full tier (benefits, description) when the community read carried it. */
|
|
52
|
+
tier: PublicTier | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The upgrade sheet: only the tiers ABOVE the viewer's current one in this
|
|
57
|
+
* community, the price of moving now, and `upgrade(tierId)`.
|
|
58
|
+
*
|
|
59
|
+
* Provider differences live here, not in markup:
|
|
60
|
+
*
|
|
61
|
+
* - **Stripe**: one tap on the saved card. The server swaps the price with
|
|
62
|
+
* proration and the answer is `changed`, with nothing to pay here. If the
|
|
63
|
+
* bank asks for authentication, the answer is `requires_payment` with a
|
|
64
|
+
* secret to confirm.
|
|
65
|
+
* - **Paystack**: the provider cannot swap a price, so the subscription is
|
|
66
|
+
* cancelled and recreated and the card is collected again (decision 10).
|
|
67
|
+
* `requiresCard` is true from the preview, so the sheet says so BEFORE the fan
|
|
68
|
+
* confirms, and the answer carries a checkout to open.
|
|
69
|
+
*/
|
|
70
|
+
export function useCommunityUpgrade(opts: UseCommunityUpgradeOptions = {}) {
|
|
71
|
+
const context = useOptionalCommunityContext();
|
|
72
|
+
if (!context) throw new MissingCommunityProviderError("useCommunityUpgrade()");
|
|
73
|
+
const community = useCommunity();
|
|
74
|
+
const ref = community.slug ?? context.slug ?? context.ref;
|
|
75
|
+
const slug = community.slug ?? context.slug;
|
|
76
|
+
const mutation = useUpgradeCommunityTier(ref);
|
|
77
|
+
const invalidate = useInvalidateCommunities();
|
|
78
|
+
|
|
79
|
+
const [selectedTierId, setSelectedTierId] = useState<string | null>(opts.initialTierId ?? null);
|
|
80
|
+
const [result, setResult] = useState<CommunityUpgradeResult | null>(null);
|
|
81
|
+
const [paystackSession, setPaystackSession] = useState<PaystackCheckoutSession | null>(null);
|
|
82
|
+
const [done, setDone] = useState(false);
|
|
83
|
+
const [error, setError] = useState<string | null>(null);
|
|
84
|
+
|
|
85
|
+
const preview = useCommunityUpgradePreview(ref, selectedTierId ?? undefined);
|
|
86
|
+
|
|
87
|
+
const options: CommunityUpgradeOption[] = useMemo(
|
|
88
|
+
() =>
|
|
89
|
+
community.upgradeTiers.map((option) => ({
|
|
90
|
+
...option,
|
|
91
|
+
tier: community.tiers.find((tier) => tier.id === option.id) ?? null,
|
|
92
|
+
})),
|
|
93
|
+
[community.upgradeTiers, community.tiers],
|
|
94
|
+
);
|
|
95
|
+
const selected = options.find((option) => option.id === selectedTierId) ?? null;
|
|
96
|
+
|
|
97
|
+
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
98
|
+
const returnPath = opts.returnPath ?? (slug ? communityHomePath(slug, { confirmSubscription: 1 }) : "");
|
|
99
|
+
const landingPath = slug ? communityHomePath(slug) : returnPath;
|
|
100
|
+
|
|
101
|
+
const land = useCallback(
|
|
102
|
+
(requiresConfirmation: boolean) => {
|
|
103
|
+
setDone(true);
|
|
104
|
+
void invalidate();
|
|
105
|
+
const path = requiresConfirmation ? returnPath : landingPath;
|
|
106
|
+
if (opts.onUpgraded) return opts.onUpgraded({ requiresConfirmation, path });
|
|
107
|
+
if (opts.navigate) return opts.navigate(path);
|
|
108
|
+
if (typeof window !== "undefined" && path) window.location.assign(`${origin}${path}`);
|
|
109
|
+
},
|
|
110
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
111
|
+
[returnPath, landingPath, origin, opts.onUpgraded, opts.navigate],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const upgrade = async (tierId?: string) => {
|
|
115
|
+
const target = tierId ?? selectedTierId;
|
|
116
|
+
setError(null);
|
|
117
|
+
if (!target) {
|
|
118
|
+
setError("Choose a tier to upgrade to.");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
setSelectedTierId(target);
|
|
122
|
+
try {
|
|
123
|
+
const data = await mutation.mutateAsync({ membershipTierId: target, returnUrl: `${origin}${returnPath}` });
|
|
124
|
+
setResult(data);
|
|
125
|
+
if (data.status === "changed") {
|
|
126
|
+
land(false);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (data.accessCode || data.checkoutUrl) {
|
|
130
|
+
const session: PaystackCheckoutSession = {
|
|
131
|
+
accessCode: data.accessCode,
|
|
132
|
+
checkoutUrl: data.checkoutUrl,
|
|
133
|
+
returnUrl: `${origin}${returnPath}`,
|
|
134
|
+
};
|
|
135
|
+
setPaystackSession(session);
|
|
136
|
+
void openPaystackModal(session, {
|
|
137
|
+
onDismiss: () => {},
|
|
138
|
+
onError: (message) => setError(message ?? "The payment could not be completed. Please try again."),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
} catch (e) {
|
|
142
|
+
setError(errMessage(e));
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const step: CommunityUpgradeStep = done
|
|
147
|
+
? "done"
|
|
148
|
+
: mutation.isPending
|
|
149
|
+
? "submitting"
|
|
150
|
+
: result?.status === "requires_payment" && (result.clientSecret || hasPaystackSession(paystackSession))
|
|
151
|
+
? "payment"
|
|
152
|
+
: selectedTierId
|
|
153
|
+
? "confirm"
|
|
154
|
+
: "choose";
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
step,
|
|
158
|
+
isLoading: community.isLoading,
|
|
159
|
+
/** Only the tiers above the current one. Empty means there is nothing to upgrade to. */
|
|
160
|
+
options,
|
|
161
|
+
currentTier: community.currentTier,
|
|
162
|
+
currentTierName: community.membership?.tierName ?? null,
|
|
163
|
+
selectedTierId,
|
|
164
|
+
selected,
|
|
165
|
+
/** Pick a tier (moves to `confirm`). */
|
|
166
|
+
select: (tierId: string) => {
|
|
167
|
+
setError(null);
|
|
168
|
+
setResult(null);
|
|
169
|
+
setPaystackSession(null);
|
|
170
|
+
setSelectedTierId(tierId);
|
|
171
|
+
},
|
|
172
|
+
/** Back to the list. */
|
|
173
|
+
back: () => {
|
|
174
|
+
setError(null);
|
|
175
|
+
setResult(null);
|
|
176
|
+
setPaystackSession(null);
|
|
177
|
+
setSelectedTierId(null);
|
|
178
|
+
},
|
|
179
|
+
/** What moving now costs, once a tier is chosen. */
|
|
180
|
+
preview: preview.data ?? null,
|
|
181
|
+
previewLoading: !!selectedTierId && preview.isLoading,
|
|
182
|
+
/** True on Paystack: the card is collected again and the subscription replaced. Say so before confirming. */
|
|
183
|
+
requiresCard: preview.data?.requiresCard ?? false,
|
|
184
|
+
prorated: preview.data?.prorated ?? false,
|
|
185
|
+
upgrade,
|
|
186
|
+
isSubmitting: mutation.isPending,
|
|
187
|
+
error,
|
|
188
|
+
/** STRIPE: the secret to confirm on the payment step. */
|
|
189
|
+
clientSecret: result?.clientSecret,
|
|
190
|
+
/** PAYSTACK: the upgrade's checkout is open or can be re-opened. */
|
|
191
|
+
isPaystack: !!paystackSession,
|
|
192
|
+
canOpenPaystack: hasPaystackSession(paystackSession),
|
|
193
|
+
openPaystackCheckout: (): Promise<PaystackCheckoutOutcome> =>
|
|
194
|
+
paystackSession
|
|
195
|
+
? openPaystackModal(paystackSession, {
|
|
196
|
+
onDismiss: () => {},
|
|
197
|
+
onError: (message) => setError(message ?? "The payment could not be completed. Please try again."),
|
|
198
|
+
})
|
|
199
|
+
: Promise.resolve("unavailable" as PaystackCheckoutOutcome),
|
|
200
|
+
/** The renderer's `returnUrl` for the payment step. */
|
|
201
|
+
returnUrl: `${origin}${returnPath}`,
|
|
202
|
+
/** Call once the card renderer reports success. Lands on the community home to reconcile. */
|
|
203
|
+
onPaymentSucceeded: () => land(true),
|
|
204
|
+
/** Clear everything, e.g. when the sheet closes. */
|
|
205
|
+
reset: () => {
|
|
206
|
+
setError(null);
|
|
207
|
+
setResult(null);
|
|
208
|
+
setPaystackSession(null);
|
|
209
|
+
setSelectedTierId(null);
|
|
210
|
+
setDone(false);
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { useCommunityUpgradeOpener, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
|
|
3
|
+
import { isNotFound } from "../../../data/queries/useCommunities";
|
|
4
|
+
import {
|
|
5
|
+
useCommunityWikiMediaUrl,
|
|
6
|
+
useCommunityWikiPageContent,
|
|
7
|
+
useCommunityWikiPages,
|
|
8
|
+
type CommunityWikiPageSummary,
|
|
9
|
+
} from "../../../data/queries/useCommunityWiki";
|
|
10
|
+
import type { LexicalNodeJson } from "../community/lexical";
|
|
11
|
+
import { communityAboutPath } from "./paths";
|
|
12
|
+
|
|
13
|
+
// ── Pure helpers (unit-tested; no React) ─────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** How a wiki image's `src` is shown, if at all. */
|
|
16
|
+
export type WikiImageSource = { kind: "url"; url: string } | { kind: "private"; mediaId: string } | null;
|
|
17
|
+
|
|
18
|
+
const PRIVATE_MEDIA_PREFIX = "kbmedia:";
|
|
19
|
+
const MEDIA_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read a Knowledge Base image's `src`. A plain http(s) address (or a site
|
|
23
|
+
* path) is shown as it is. `kbmedia:<id>` is a PRIVATE image, shown through a
|
|
24
|
+
* short-lived signed read that only a viewer of the page can get. Anything
|
|
25
|
+
* else is never rendered: not `javascript:`, not `data:`, not a malformed id.
|
|
26
|
+
*/
|
|
27
|
+
export function wikiImageSource(src: unknown): WikiImageSource {
|
|
28
|
+
if (typeof src !== "string") return null;
|
|
29
|
+
const value = src.trim();
|
|
30
|
+
if (/^https?:\/\//i.test(value) || (value.startsWith("/") && !value.startsWith("//")))
|
|
31
|
+
return { kind: "url", url: value };
|
|
32
|
+
if (value.startsWith(PRIVATE_MEDIA_PREFIX)) {
|
|
33
|
+
const mediaId = value.slice(PRIVATE_MEDIA_PREFIX.length);
|
|
34
|
+
return MEDIA_ID.test(mediaId) ? { kind: "private", mediaId } : null;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type WikiMediaImageStatus = "loading" | "ready" | "unavailable";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Where a private image stands, from the signed-read request and the addresses
|
|
43
|
+
* the browser failed to load. The first failure (usually an expired address)
|
|
44
|
+
* asks for a fresh one; once that re-request has been made
|
|
45
|
+
* (`retriedAfterFailure`), a failed address reads as unavailable, so a broken
|
|
46
|
+
* file cannot loop.
|
|
47
|
+
*/
|
|
48
|
+
export function wikiMediaImageStatus(input: {
|
|
49
|
+
isLoading: boolean;
|
|
50
|
+
isError: boolean;
|
|
51
|
+
url: string | null;
|
|
52
|
+
failedUrls: ReadonlySet<string>;
|
|
53
|
+
retriedAfterFailure: boolean;
|
|
54
|
+
}): WikiMediaImageStatus {
|
|
55
|
+
if (input.isError) return "unavailable";
|
|
56
|
+
if (!input.url) return input.isLoading ? "loading" : "unavailable";
|
|
57
|
+
if (input.failedUrls.has(input.url)) return input.retriedAfterFailure ? "unavailable" : "loading";
|
|
58
|
+
return "ready";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One entry of a page's table of contents. `id` is the heading's anchor. */
|
|
62
|
+
export interface CommunityWikiTocEntry {
|
|
63
|
+
id: string;
|
|
64
|
+
text: string;
|
|
65
|
+
/** 1 for h1 through 6 for h6. */
|
|
66
|
+
level: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A table of contents is shown once a page has this many headings. */
|
|
70
|
+
export const WIKI_TOC_MIN_HEADINGS = 2;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The page list split the way a renderer draws it: every page in order, the
|
|
74
|
+
* ones this viewer can open, and the ones listed but locked to a tier they do
|
|
75
|
+
* not have. `publicOnly` keeps just the public pages (the About page, which a
|
|
76
|
+
* visitor and a member see alike).
|
|
77
|
+
*/
|
|
78
|
+
export function shapeWikiList(pages: CommunityWikiPageSummary[] | undefined, opts: { publicOnly?: boolean } = {}) {
|
|
79
|
+
const all = (Array.isArray(pages) ? pages : []).filter((page) => !opts.publicOnly || page.isPublic);
|
|
80
|
+
return {
|
|
81
|
+
all,
|
|
82
|
+
readable: all.filter((page) => !page.locked),
|
|
83
|
+
locked: all.filter((page) => page.locked),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* What opens a locked page, for the copy beside it: the level the member has
|
|
89
|
+
* not reached yet (null when the level is not what is missing) and the names
|
|
90
|
+
* of the tiers that open it (empty when none are named).
|
|
91
|
+
*/
|
|
92
|
+
export function wikiUnlockParts(page: Pick<CommunityWikiPageSummary, "unlockLevel" | "unlockTiers">): {
|
|
93
|
+
level: number | null;
|
|
94
|
+
tiers: string;
|
|
95
|
+
} {
|
|
96
|
+
const level = typeof page.unlockLevel === "number" && page.unlockLevel > 0 ? page.unlockLevel : null;
|
|
97
|
+
const tiers = (page.unlockTiers ?? []).map((tier) => tier.name).filter(Boolean).join(", ");
|
|
98
|
+
return { level, tiers };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The page the Wiki tab opens on when the address names none: the first page
|
|
103
|
+
* this viewer can read, in the wiki's order. Null when every page is locked or
|
|
104
|
+
* there are none.
|
|
105
|
+
*/
|
|
106
|
+
export function defaultWikiPageSlug(pages: CommunityWikiPageSummary[] | undefined): string | null {
|
|
107
|
+
return (Array.isArray(pages) ? pages : []).find((page) => !page.locked)?.slug ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Lexical JSON as a root node. The Knowledge Base stores the editor state as an
|
|
112
|
+
* object, but a string of JSON is accepted too, so a page never renders empty
|
|
113
|
+
* because of how its content was serialised.
|
|
114
|
+
*/
|
|
115
|
+
export function parseWikiContent(content: unknown): { root: LexicalNodeJson } | null {
|
|
116
|
+
let value = content;
|
|
117
|
+
if (typeof value === "string") {
|
|
118
|
+
try {
|
|
119
|
+
value = JSON.parse(value);
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!value || typeof value !== "object") return null;
|
|
125
|
+
const maybe = value as { root?: LexicalNodeJson; children?: LexicalNodeJson[] };
|
|
126
|
+
if (maybe.root && typeof maybe.root === "object") return { root: maybe.root };
|
|
127
|
+
if (Array.isArray(maybe.children)) return { root: maybe as LexicalNodeJson };
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The plain text of a node and everything under it. */
|
|
132
|
+
export function lexicalNodeText(node: LexicalNodeJson): string {
|
|
133
|
+
if (typeof node.text === "string") return node.text;
|
|
134
|
+
return (node.children ?? []).map(lexicalNodeText).join("");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A URL-safe anchor for a heading ("Getting started!" to "getting-started"). */
|
|
138
|
+
export function wikiHeadingSlug(text: string): string {
|
|
139
|
+
const slug = text
|
|
140
|
+
.normalize("NFKD")
|
|
141
|
+
// Drop the accents NFKD split off ("Über" to "Uber").
|
|
142
|
+
.replace(/\p{M}/gu, "")
|
|
143
|
+
.toLowerCase()
|
|
144
|
+
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
|
145
|
+
.replace(/^-+|-+$/g, "");
|
|
146
|
+
return slug || "section";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The table of contents: every top-level heading in document order, each with
|
|
151
|
+
* a unique anchor (a repeated title gets `-2`, `-3`). The renderer gives the
|
|
152
|
+
* headings these same ids in the same order, which is why both read the root's
|
|
153
|
+
* direct children only. Empty headings are skipped.
|
|
154
|
+
*/
|
|
155
|
+
export function extractWikiToc(content: unknown): CommunityWikiTocEntry[] {
|
|
156
|
+
const parsed = parseWikiContent(content);
|
|
157
|
+
if (!parsed) return [];
|
|
158
|
+
const used = new Map<string, number>();
|
|
159
|
+
const toc: CommunityWikiTocEntry[] = [];
|
|
160
|
+
for (const node of parsed.root.children ?? []) {
|
|
161
|
+
if (node.type !== "heading") continue;
|
|
162
|
+
const text = lexicalNodeText(node).trim();
|
|
163
|
+
if (!text) continue;
|
|
164
|
+
const base = wikiHeadingSlug(text);
|
|
165
|
+
const seen = used.get(base) ?? 0;
|
|
166
|
+
used.set(base, seen + 1);
|
|
167
|
+
const tag = typeof node.tag === "string" ? node.tag : "h2";
|
|
168
|
+
const level = Math.min(6, Math.max(1, Number(tag.replace(/^h/, "")) || 2));
|
|
169
|
+
toc.push({ id: seen ? `${base}-${seen + 1}` : base, text, level });
|
|
170
|
+
}
|
|
171
|
+
return toc;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Hooks ────────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A PRIVATE wiki image (`kbmedia:<id>`) on the page `pageSlug`: its signed
|
|
178
|
+
* address, re-requested before it expires, and again once if the browser
|
|
179
|
+
* fails to load it (`onImageError`). Only the wiki page view uses this: posts
|
|
180
|
+
* never carry private Knowledge Base media.
|
|
181
|
+
*/
|
|
182
|
+
export function useWikiMediaImage(pageSlug: string | undefined, mediaId: string | undefined) {
|
|
183
|
+
const query = useCommunityWikiMediaUrl(pageSlug, mediaId);
|
|
184
|
+
const [failedUrls, setFailedUrls] = useState<ReadonlySet<string>>(() => new Set());
|
|
185
|
+
const [retriedAfterFailure, setRetriedAfterFailure] = useState(false);
|
|
186
|
+
const url = query.data?.url ?? null;
|
|
187
|
+
const status = wikiMediaImageStatus({
|
|
188
|
+
isLoading: query.isLoading || query.isFetching,
|
|
189
|
+
isError: query.isError,
|
|
190
|
+
url,
|
|
191
|
+
failedUrls,
|
|
192
|
+
retriedAfterFailure,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
status,
|
|
197
|
+
url: status === "ready" ? url : null,
|
|
198
|
+
/** Call from the <img>'s onError: an expired address is replaced once. */
|
|
199
|
+
onImageError: () => {
|
|
200
|
+
if (!url || failedUrls.has(url)) return;
|
|
201
|
+
setFailedUrls((prev) => new Set(prev).add(url));
|
|
202
|
+
if (retriedAfterFailure) return;
|
|
203
|
+
void query.refetch().finally(() => setRetriedAfterFailure(true));
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface UseCommunityWikiOptions {
|
|
209
|
+
/** Keep only the public pages (the About page). */
|
|
210
|
+
publicOnly?: boolean;
|
|
211
|
+
enabled?: boolean;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Headless wiki page list for the surrounding community: every page this
|
|
216
|
+
* viewer may see, the readable and locked ones, the page to open by default,
|
|
217
|
+
* and how to unlock a locked one. `unlock` opens the community's upgrade sheet
|
|
218
|
+
* with the page's first unlocking tier when a community home provides one;
|
|
219
|
+
* otherwise `unlockHref` is the About page with that tier preselected.
|
|
220
|
+
*/
|
|
221
|
+
export function useCommunityWiki(opts: UseCommunityWikiOptions = {}) {
|
|
222
|
+
const community = useOptionalCommunityContext();
|
|
223
|
+
const opener = useCommunityUpgradeOpener();
|
|
224
|
+
const query = useCommunityWikiPages({ enabled: opts.enabled });
|
|
225
|
+
const shaped = useMemo(
|
|
226
|
+
() => shapeWikiList(query.data, { publicOnly: opts.publicOnly }),
|
|
227
|
+
[query.data, opts.publicOnly],
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
pages: shaped.all,
|
|
232
|
+
readablePages: shaped.readable,
|
|
233
|
+
lockedPages: shaped.locked,
|
|
234
|
+
/** The page to open when the address names none. */
|
|
235
|
+
defaultSlug: defaultWikiPageSlug(shaped.all),
|
|
236
|
+
isLoading: query.isLoading,
|
|
237
|
+
isError: query.isError && !isNotFound(query.error),
|
|
238
|
+
/** Open the upgrade sheet for a locked page, when a community home provides one. */
|
|
239
|
+
unlock: opener ? (page: CommunityWikiPageSummary) => opener.openUpgrade(page.unlockTiers[0]?.id ?? null) : null,
|
|
240
|
+
/** Where a locked page's upgrade leads when no upgrade sheet is on hand. */
|
|
241
|
+
unlockHref: (page: CommunityWikiPageSummary) =>
|
|
242
|
+
community?.slug ? communityAboutPath(community.slug, { tier: page.unlockTiers[0]?.id }) : null,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Headless wiki page: its content (parsed), the table of contents and whether
|
|
248
|
+
* to show it, and `notFound` when this viewer may not read the page (locked,
|
|
249
|
+
* hidden or gone: the server does not say which, by design).
|
|
250
|
+
*/
|
|
251
|
+
export function useCommunityWikiPage(pageSlug: string | undefined, opts: { enabled?: boolean } = {}) {
|
|
252
|
+
const query = useCommunityWikiPageContent(pageSlug, opts);
|
|
253
|
+
const content = useMemo(() => parseWikiContent(query.data?.content), [query.data]);
|
|
254
|
+
const toc = useMemo(() => extractWikiToc(content), [content]);
|
|
255
|
+
return {
|
|
256
|
+
page: query.data ?? null,
|
|
257
|
+
content,
|
|
258
|
+
toc,
|
|
259
|
+
showToc: toc.length >= WIKI_TOC_MIN_HEADINGS,
|
|
260
|
+
/** Anchor ids for the page's headings, in order, for the renderer. */
|
|
261
|
+
headingIds: toc.map((entry) => entry.id),
|
|
262
|
+
isLoading: query.isLoading,
|
|
263
|
+
notFound: isNotFound(query.error),
|
|
264
|
+
isError: query.isError && !isNotFound(query.error),
|
|
265
|
+
};
|
|
266
|
+
}
|