@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,59 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { useMyCommunityMemberships, type MyCommunityMembership } from "../../../data/queries/useCommunities";
|
|
3
|
+
import { getMembershipAccess, type MembershipAccessSummary } from "../../../utils/membershipAccess";
|
|
4
|
+
import { communityHomePath } from "./paths";
|
|
5
|
+
import { isMembershipInTrial } from "../membership/membershipTrial";
|
|
6
|
+
|
|
7
|
+
export interface MyMembershipEntry {
|
|
8
|
+
id: string;
|
|
9
|
+
community: { id: string; name: string; slug: string };
|
|
10
|
+
tierId: string | null;
|
|
11
|
+
tierName: string;
|
|
12
|
+
status: string;
|
|
13
|
+
access: MembershipAccessSummary;
|
|
14
|
+
/** When the paid period ends: a renewal, or the last day when cancelling. */
|
|
15
|
+
periodEnd: string | null;
|
|
16
|
+
cancelAtPeriodEnd: boolean;
|
|
17
|
+
pendingDowngradeTierId: string | null;
|
|
18
|
+
trialEndsAt: string | null;
|
|
19
|
+
/** Inside a free trial right now: nothing charged yet, the first charge on `trialEndsAt`. */
|
|
20
|
+
inTrial: boolean;
|
|
21
|
+
amount: number | null;
|
|
22
|
+
currency: string | null;
|
|
23
|
+
billingCycle: string | null;
|
|
24
|
+
/** The community home, where the fan manages this membership. */
|
|
25
|
+
homePath: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Normalise one `/public/memberships/mine` row. Pure, for the spec and for a custom list. */
|
|
29
|
+
export function toMyMembershipEntry(row: MyCommunityMembership): MyMembershipEntry {
|
|
30
|
+
const cancelAtPeriodEnd = !!row.cancelAtPeriodEnd;
|
|
31
|
+
return {
|
|
32
|
+
id: row.id,
|
|
33
|
+
community: row.community,
|
|
34
|
+
tierId: row.tierId ?? row.membershipTierId ?? row.membershipTier?.id ?? null,
|
|
35
|
+
tierName: row.tierName ?? row.membershipTier?.name ?? "",
|
|
36
|
+
status: row.status,
|
|
37
|
+
access: getMembershipAccess({ status: row.status, cancelAtPeriodEnd }),
|
|
38
|
+
periodEnd: row.currentPeriodEnd ?? row.endDate ?? null,
|
|
39
|
+
cancelAtPeriodEnd,
|
|
40
|
+
pendingDowngradeTierId: row.pendingDowngradeTierId ?? null,
|
|
41
|
+
trialEndsAt: row.trialEndsAt ?? null,
|
|
42
|
+
inTrial: isMembershipInTrial({ trialEndsAt: row.trialEndsAt ?? null }),
|
|
43
|
+
amount: row.subscriptionAmount ?? null,
|
|
44
|
+
currency: row.subscriptionCurrency ?? null,
|
|
45
|
+
billingCycle: row.billingCycle ?? null,
|
|
46
|
+
homePath: communityHomePath(row.community.slug),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Every membership the signed-in fan holds with this creator, one per
|
|
52
|
+
* community, for the account page's "My memberships". Nothing sends a fan
|
|
53
|
+
* here: managing a membership happens inside its community.
|
|
54
|
+
*/
|
|
55
|
+
export function useMyMemberships() {
|
|
56
|
+
const query = useMyCommunityMemberships();
|
|
57
|
+
const memberships = useMemo(() => (query.data ?? []).map(toMyMembershipEntry), [query.data]);
|
|
58
|
+
return { memberships, isLoading: query.isLoading, isError: query.isError, refetch: query.refetch };
|
|
59
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
COMMUNITY_POLL_RESULTS_VISIBILITY,
|
|
4
|
+
useVoteCommunityPoll,
|
|
5
|
+
type CommunityPollInput,
|
|
6
|
+
type CommunityPollResultsVisibility,
|
|
7
|
+
type CommunityPollView,
|
|
8
|
+
type CommunityPost,
|
|
9
|
+
} from "../../../data/queries/useCommunity";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Polls on community posts (community parity §6.1). A poll rides on a post:
|
|
13
|
+
* the post title is its question, and the poll carries 2 to 10 options. The
|
|
14
|
+
* limits below mirror the server's create schema, so a draft that passes here
|
|
15
|
+
* is not refused there for its shape.
|
|
16
|
+
*/
|
|
17
|
+
export const COMMUNITY_POLL_LIMITS = {
|
|
18
|
+
minOptions: 2,
|
|
19
|
+
maxOptions: 10,
|
|
20
|
+
optionMaxLength: 120,
|
|
21
|
+
questionMaxLength: 200,
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
// ── Draft (the composer) ─────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export interface CommunityPollDraft {
|
|
27
|
+
/** The question. Sent as the post title. */
|
|
28
|
+
question: string;
|
|
29
|
+
options: string[];
|
|
30
|
+
multipleChoice: boolean;
|
|
31
|
+
/** A `datetime-local` value (local time, no zone), or "" for no close. */
|
|
32
|
+
closesAt: string;
|
|
33
|
+
resultsVisibility: CommunityPollResultsVisibility;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const emptyPollDraft = (): CommunityPollDraft => ({
|
|
37
|
+
question: "",
|
|
38
|
+
options: ["", ""],
|
|
39
|
+
multipleChoice: false,
|
|
40
|
+
closesAt: "",
|
|
41
|
+
resultsVisibility: "after_vote",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export type CommunityPollQuestionError = "question_required" | "question_too_long";
|
|
45
|
+
export type CommunityPollOptionError = "option_empty" | "option_too_long" | "option_duplicate";
|
|
46
|
+
export type CommunityPollOptionsError = "options_too_few" | "options_too_many";
|
|
47
|
+
export type CommunityPollClosesError = "closes_invalid" | "closes_in_past" | "closes_required";
|
|
48
|
+
|
|
49
|
+
export interface CommunityPollDraftErrors {
|
|
50
|
+
question: CommunityPollQuestionError | null;
|
|
51
|
+
/** One entry per option input, in order. */
|
|
52
|
+
options: (CommunityPollOptionError | null)[];
|
|
53
|
+
/** About the list as a whole (how many options). */
|
|
54
|
+
optionsList: CommunityPollOptionsError | null;
|
|
55
|
+
closesAt: CommunityPollClosesError | null;
|
|
56
|
+
resultsVisibility: "visibility_invalid" | null;
|
|
57
|
+
valid: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A `datetime-local` value as a Date, or null when it does not parse. */
|
|
61
|
+
export function parsePollCloseTime(value: string): Date | null {
|
|
62
|
+
if (!value.trim()) return null;
|
|
63
|
+
const date = new Date(value);
|
|
64
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check a draft against the server's limits: a question, 2 to 10 options of
|
|
69
|
+
* 1 to 120 characters each, no two alike (compared trimmed and ignoring case,
|
|
70
|
+
* as the server de-duplicates), and a close time, when given, in the future.
|
|
71
|
+
* A poll whose results wait for it to close must have a close time.
|
|
72
|
+
*/
|
|
73
|
+
export function validatePollDraft(draft: CommunityPollDraft, now: number = Date.now()): CommunityPollDraftErrors {
|
|
74
|
+
const question = draft.question.trim();
|
|
75
|
+
const questionError: CommunityPollQuestionError | null = !question
|
|
76
|
+
? "question_required"
|
|
77
|
+
: question.length > COMMUNITY_POLL_LIMITS.questionMaxLength
|
|
78
|
+
? "question_too_long"
|
|
79
|
+
: null;
|
|
80
|
+
|
|
81
|
+
const seen = new Set<string>();
|
|
82
|
+
const options = draft.options.map((raw): CommunityPollOptionError | null => {
|
|
83
|
+
const label = raw.trim();
|
|
84
|
+
if (!label) return "option_empty";
|
|
85
|
+
if (label.length > COMMUNITY_POLL_LIMITS.optionMaxLength) return "option_too_long";
|
|
86
|
+
const key = label.toLowerCase();
|
|
87
|
+
if (seen.has(key)) return "option_duplicate";
|
|
88
|
+
seen.add(key);
|
|
89
|
+
return null;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const optionsList: CommunityPollOptionsError | null =
|
|
93
|
+
draft.options.length < COMMUNITY_POLL_LIMITS.minOptions
|
|
94
|
+
? "options_too_few"
|
|
95
|
+
: draft.options.length > COMMUNITY_POLL_LIMITS.maxOptions
|
|
96
|
+
? "options_too_many"
|
|
97
|
+
: null;
|
|
98
|
+
|
|
99
|
+
let closesAt: CommunityPollClosesError | null = null;
|
|
100
|
+
if (draft.closesAt.trim()) {
|
|
101
|
+
const date = parsePollCloseTime(draft.closesAt);
|
|
102
|
+
if (!date) closesAt = "closes_invalid";
|
|
103
|
+
else if (date.getTime() <= now) closesAt = "closes_in_past";
|
|
104
|
+
} else if (draft.resultsVisibility === "after_close") {
|
|
105
|
+
// Results shown "when the poll closes" on a poll that never closes would
|
|
106
|
+
// never be shown to a member at all.
|
|
107
|
+
closesAt = "closes_required";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const resultsVisibility = COMMUNITY_POLL_RESULTS_VISIBILITY.includes(draft.resultsVisibility)
|
|
111
|
+
? null
|
|
112
|
+
: ("visibility_invalid" as const);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
question: questionError,
|
|
116
|
+
options,
|
|
117
|
+
optionsList,
|
|
118
|
+
closesAt,
|
|
119
|
+
resultsVisibility,
|
|
120
|
+
valid: !questionError && options.every((e) => !e) && !optionsList && !closesAt && !resultsVisibility,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The create-post fields a draft becomes: the question as `title`, and the `poll` body. */
|
|
125
|
+
export function buildPollPayload(draft: CommunityPollDraft): { title: string; poll: CommunityPollInput } {
|
|
126
|
+
const close = parsePollCloseTime(draft.closesAt);
|
|
127
|
+
return {
|
|
128
|
+
title: draft.question.trim(),
|
|
129
|
+
poll: {
|
|
130
|
+
options: draft.options.map((o) => o.trim()),
|
|
131
|
+
multipleChoice: draft.multipleChoice,
|
|
132
|
+
closesAt: close ? close.toISOString() : null,
|
|
133
|
+
resultsVisibility: draft.resultsVisibility,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The composer's poll state: the draft, adding and removing options within
|
|
140
|
+
* 2 to 10, and the errors (shown once the member has tried to post, so an
|
|
141
|
+
* untouched form does not open in red).
|
|
142
|
+
*/
|
|
143
|
+
export function useCommunityPollDraft() {
|
|
144
|
+
const [draft, setDraft] = useState<CommunityPollDraft>(emptyPollDraft);
|
|
145
|
+
const [showErrors, setShowErrors] = useState(false);
|
|
146
|
+
const errors = useMemo(() => validatePollDraft(draft), [draft]);
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
draft,
|
|
150
|
+
errors,
|
|
151
|
+
/** True once a submit was attempted: render `errors` from then on. */
|
|
152
|
+
showErrors,
|
|
153
|
+
canAddOption: draft.options.length < COMMUNITY_POLL_LIMITS.maxOptions,
|
|
154
|
+
canRemoveOption: draft.options.length > COMMUNITY_POLL_LIMITS.minOptions,
|
|
155
|
+
setQuestion: (question: string) => setDraft((d) => ({ ...d, question })),
|
|
156
|
+
setOption: (index: number, value: string) =>
|
|
157
|
+
setDraft((d) => ({ ...d, options: d.options.map((o, i) => (i === index ? value : o)) })),
|
|
158
|
+
addOption: () =>
|
|
159
|
+
setDraft((d) =>
|
|
160
|
+
d.options.length >= COMMUNITY_POLL_LIMITS.maxOptions ? d : { ...d, options: [...d.options, ""] },
|
|
161
|
+
),
|
|
162
|
+
removeOption: (index: number) =>
|
|
163
|
+
setDraft((d) =>
|
|
164
|
+
d.options.length <= COMMUNITY_POLL_LIMITS.minOptions
|
|
165
|
+
? d
|
|
166
|
+
: { ...d, options: d.options.filter((_, i) => i !== index) },
|
|
167
|
+
),
|
|
168
|
+
setMultipleChoice: (multipleChoice: boolean) => setDraft((d) => ({ ...d, multipleChoice })),
|
|
169
|
+
setClosesAt: (closesAt: string) => setDraft((d) => ({ ...d, closesAt })),
|
|
170
|
+
setResultsVisibility: (resultsVisibility: CommunityPollResultsVisibility) =>
|
|
171
|
+
setDraft((d) => ({ ...d, resultsVisibility })),
|
|
172
|
+
/** Mark a submit attempt; returns whether the draft may be sent. */
|
|
173
|
+
attempt: () => {
|
|
174
|
+
setShowErrors(true);
|
|
175
|
+
return validatePollDraft(draft).valid;
|
|
176
|
+
},
|
|
177
|
+
reset: () => {
|
|
178
|
+
setDraft(emptyPollDraft());
|
|
179
|
+
setShowErrors(false);
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Reading and voting ───────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/** A share of the voters, as a whole percentage. A multiple-choice poll's shares can sum past 100. */
|
|
187
|
+
export function pollOptionPercent(votes: number | null, totalVoters: number | null): number | null {
|
|
188
|
+
if (votes === null || totalVoters === null) return null;
|
|
189
|
+
if (totalVoters <= 0) return 0;
|
|
190
|
+
return Math.round((votes / totalVoters) * 100);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Why the counts are hidden, when they are. */
|
|
194
|
+
export type CommunityPollHiddenReason = "after_vote" | "after_close" | null;
|
|
195
|
+
|
|
196
|
+
export function pollHiddenReason(poll: CommunityPollView): CommunityPollHiddenReason {
|
|
197
|
+
if (poll.resultsVisible) return null;
|
|
198
|
+
return poll.resultsVisibility === "after_close" ? "after_close" : "after_vote";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** What a failed vote means for the member. */
|
|
202
|
+
export type CommunityPollVoteError = "closed" | "invalid" | "forbidden" | "generic";
|
|
203
|
+
|
|
204
|
+
export function pollVoteErrorCode(error: unknown): CommunityPollVoteError {
|
|
205
|
+
const status = (error as { response?: { status?: number } } | null)?.response?.status;
|
|
206
|
+
if (status === 409) return "closed";
|
|
207
|
+
if (status === 400) return "invalid";
|
|
208
|
+
if (status === 403 || status === 401) return "forbidden";
|
|
209
|
+
return "generic";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Two answers are the same answer when they hold the same options, in any order. */
|
|
213
|
+
export function sameAnswer(a: readonly string[], b: readonly string[]): boolean {
|
|
214
|
+
if (a.length !== b.length) return false;
|
|
215
|
+
const set = new Set(a);
|
|
216
|
+
return b.every((id) => set.has(id));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Headless poll on one post: the member's selection (starting from their
|
|
221
|
+
* current vote), picking options (one on a single-choice poll, several on a
|
|
222
|
+
* multiple-choice one), sending the answer, changing it and withdrawing it.
|
|
223
|
+
* `disabled` (a post still waiting for approval) keeps it read-only.
|
|
224
|
+
*/
|
|
225
|
+
export function useCommunityPoll(post: Pick<CommunityPost, "id" | "spaceId" | "poll">, opts: { disabled?: boolean } = {}) {
|
|
226
|
+
const poll = post.poll ?? null;
|
|
227
|
+
const vote = useVoteCommunityPoll();
|
|
228
|
+
const myVotes = poll?.myVotes ?? [];
|
|
229
|
+
const hasVoted = myVotes.length > 0;
|
|
230
|
+
const [selection, setSelection] = useState<string[]>(myVotes);
|
|
231
|
+
// Changing a vote reopens the options; a fresh answer from the server closes them.
|
|
232
|
+
const [changing, setChanging] = useState(false);
|
|
233
|
+
const [error, setError] = useState<CommunityPollVoteError | null>(null);
|
|
234
|
+
const myVotesKey = myVotes.join(",");
|
|
235
|
+
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
setSelection(poll?.myVotes ?? []);
|
|
238
|
+
// Re-sync only when the server's answer changes, not on every render.
|
|
239
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
240
|
+
}, [myVotesKey, poll?.id]);
|
|
241
|
+
|
|
242
|
+
const closed = !!poll?.closed;
|
|
243
|
+
const canVote = !!poll && !closed && !opts.disabled;
|
|
244
|
+
/** Options are pickable: before a first vote, or while changing one. */
|
|
245
|
+
const choosing = canVote && (!hasVoted || changing);
|
|
246
|
+
|
|
247
|
+
const send = async (optionIds: string[]) => {
|
|
248
|
+
if (!poll) return;
|
|
249
|
+
setError(null);
|
|
250
|
+
try {
|
|
251
|
+
await vote.mutateAsync({ postId: post.id, spaceId: post.spaceId, optionIds });
|
|
252
|
+
setChanging(false);
|
|
253
|
+
} catch (e) {
|
|
254
|
+
setError(pollVoteErrorCode(e));
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
poll,
|
|
260
|
+
closed,
|
|
261
|
+
hasVoted,
|
|
262
|
+
canVote,
|
|
263
|
+
choosing,
|
|
264
|
+
changing,
|
|
265
|
+
selection,
|
|
266
|
+
isSelected: (optionId: string) => selection.includes(optionId),
|
|
267
|
+
toggle: (optionId: string) => {
|
|
268
|
+
if (!choosing || !poll) return;
|
|
269
|
+
setSelection((prev) =>
|
|
270
|
+
poll.multipleChoice
|
|
271
|
+
? prev.includes(optionId)
|
|
272
|
+
? prev.filter((id) => id !== optionId)
|
|
273
|
+
: [...prev, optionId]
|
|
274
|
+
: [optionId],
|
|
275
|
+
);
|
|
276
|
+
},
|
|
277
|
+
/** Something is picked, and it differs from the answer already on record. */
|
|
278
|
+
canSubmit: choosing && selection.length > 0 && !sameAnswer(selection, myVotes) && !vote.isPending,
|
|
279
|
+
submit: () => send(selection),
|
|
280
|
+
startChange: () => {
|
|
281
|
+
if (!canVote) return;
|
|
282
|
+
setSelection(myVotes);
|
|
283
|
+
setChanging(true);
|
|
284
|
+
setError(null);
|
|
285
|
+
},
|
|
286
|
+
cancelChange: () => {
|
|
287
|
+
setSelection(myVotes);
|
|
288
|
+
setChanging(false);
|
|
289
|
+
setError(null);
|
|
290
|
+
},
|
|
291
|
+
/** Take the vote back: an empty answer. */
|
|
292
|
+
withdraw: () => send([]),
|
|
293
|
+
pending: vote.isPending,
|
|
294
|
+
error,
|
|
295
|
+
hiddenReason: poll ? pollHiddenReason(poll) : null,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Countries for the member directory and map: every ISO 3166-1 alpha-2 code a
|
|
3
|
+
* member may pick, a name for it in the viewer's language (through
|
|
4
|
+
* `Intl.DisplayNames`, so no name list ships), and an approximate centre per
|
|
5
|
+
* country for placing a dot on the member globe.
|
|
6
|
+
*
|
|
7
|
+
* The centres began as the admin analytics globe's table
|
|
8
|
+
* (apps/admin/.../analytics/-components/country-centroids.ts), which Forge
|
|
9
|
+
* cannot import, extended to most countries. They place a country-level dot and
|
|
10
|
+
* nothing finer: members choose a country, never a location. A country missing
|
|
11
|
+
* a centre still lists in the directory, it just has no dot.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Every ISO 3166-1 alpha-2 code, as a member can choose one. */
|
|
15
|
+
// prettier-ignore
|
|
16
|
+
export const COUNTRY_CODES: readonly string[] = [
|
|
17
|
+
"AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ",
|
|
18
|
+
"BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS",
|
|
19
|
+
"BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN",
|
|
20
|
+
"CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE",
|
|
21
|
+
"EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF",
|
|
22
|
+
"GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM",
|
|
23
|
+
"HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM",
|
|
24
|
+
"JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC",
|
|
25
|
+
"LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK",
|
|
26
|
+
"ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA",
|
|
27
|
+
"NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG",
|
|
28
|
+
"PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW",
|
|
29
|
+
"SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS",
|
|
30
|
+
"ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO",
|
|
31
|
+
"TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI",
|
|
32
|
+
"VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** Approximate centre of each country, alpha-2 to [latitude, longitude]. */
|
|
36
|
+
// prettier-ignore
|
|
37
|
+
export const COUNTRY_CENTROIDS: Readonly<Record<string, readonly [number, number]>> = {
|
|
38
|
+
AD: [42.5, 1.6], AE: [23.4, 53.8], AF: [33.9, 67.7], AG: [17.1, -61.8], AL: [41.2, 20.2],
|
|
39
|
+
AM: [40.1, 45.0], AO: [-11.2, 17.9], AR: [-38.4, -63.6], AT: [47.5, 14.6], AU: [-25.3, 133.8],
|
|
40
|
+
AZ: [40.1, 47.6], BA: [43.9, 17.7], BB: [13.2, -59.5], BD: [23.7, 90.4], BE: [50.5, 4.5],
|
|
41
|
+
BF: [12.2, -1.6], BG: [42.7, 25.5], BH: [26.0, 50.6], BI: [-3.4, 29.9], BJ: [9.3, 2.3],
|
|
42
|
+
BN: [4.5, 114.7], BO: [-16.3, -63.6], BR: [-14.2, -51.9], BS: [25.0, -77.4], BT: [27.5, 90.4],
|
|
43
|
+
BW: [-22.3, 24.7], BY: [53.7, 27.9], BZ: [17.2, -88.5], CA: [56.1, -106.3], CD: [-4.0, 21.8],
|
|
44
|
+
CF: [6.6, 20.9], CG: [-0.2, 15.8], CH: [46.8, 8.2], CI: [7.5, -5.5], CL: [-35.7, -71.5],
|
|
45
|
+
CM: [7.4, 12.4], CN: [35.9, 104.2], CO: [4.6, -74.3], CR: [9.7, -83.8], CU: [21.5, -77.8],
|
|
46
|
+
CV: [16.0, -24.0], CY: [35.1, 33.4], CZ: [49.8, 15.5], DE: [51.2, 10.5], DJ: [11.8, 42.6],
|
|
47
|
+
DK: [56.3, 9.5], DM: [15.4, -61.4], DO: [18.7, -70.2], DZ: [28.0, 1.7], EC: [-1.8, -78.2],
|
|
48
|
+
EE: [58.6, 25.0], EG: [26.8, 30.8], ER: [15.2, 39.8], ES: [40.5, -3.7], ET: [9.1, 40.5],
|
|
49
|
+
FI: [61.9, 25.7], FJ: [-17.7, 178.1], FR: [46.2, 2.2], GA: [-0.8, 11.6], GB: [55.4, -3.4],
|
|
50
|
+
GD: [12.1, -61.7], GE: [42.3, 43.4], GH: [7.9, -1.0], GL: [71.7, -42.6], GM: [13.4, -15.3],
|
|
51
|
+
GN: [9.9, -9.7], GQ: [1.7, 10.3], GR: [39.1, 21.8], GT: [15.8, -90.2], GW: [11.8, -15.2],
|
|
52
|
+
GY: [4.9, -58.9], HK: [22.3, 114.2], HN: [15.2, -86.2], HR: [45.1, 15.2], HT: [18.97, -72.3],
|
|
53
|
+
HU: [47.2, 19.5], ID: [-0.8, 113.9], IE: [53.4, -8.2], IL: [31.0, 34.9], IN: [20.6, 79.0],
|
|
54
|
+
IQ: [33.2, 43.7], IR: [32.4, 53.7], IS: [65.0, -19.0], IT: [41.9, 12.6], JM: [18.1, -77.3],
|
|
55
|
+
JO: [30.6, 36.2], JP: [36.2, 138.3], KE: [-0.02, 37.9], KG: [41.2, 74.8], KH: [12.6, 104.99],
|
|
56
|
+
KM: [-11.9, 43.9], KN: [17.4, -62.8], KP: [40.3, 127.5], KR: [35.9, 127.8], KW: [29.3, 47.5],
|
|
57
|
+
KZ: [48.0, 66.9], LA: [19.9, 102.5], LB: [33.9, 35.9], LC: [13.9, -60.98], LI: [47.2, 9.6],
|
|
58
|
+
LK: [7.9, 80.8], LR: [6.4, -9.4], LS: [-29.6, 28.2], LT: [55.2, 23.9], LU: [49.8, 6.1],
|
|
59
|
+
LV: [56.9, 24.6], LY: [26.3, 17.2], MA: [31.8, -7.1], MC: [43.7, 7.4], MD: [47.4, 28.4],
|
|
60
|
+
ME: [42.7, 19.4], MG: [-18.8, 46.9], MK: [41.6, 21.7], ML: [17.6, -4.0], MM: [21.9, 95.96],
|
|
61
|
+
MN: [46.9, 103.8], MO: [22.2, 113.5], MR: [21.0, -10.9], MT: [35.9, 14.4], MU: [-20.3, 57.6],
|
|
62
|
+
MV: [3.2, 73.2], MW: [-13.3, 34.3], MX: [23.6, -102.6], MY: [4.2, 101.9], MZ: [-18.7, 35.5],
|
|
63
|
+
NA: [-22.9, 18.5], NE: [17.6, 8.1], NG: [9.1, 8.7], NI: [12.9, -85.2], NL: [52.1, 5.3],
|
|
64
|
+
NO: [60.5, 8.5], NP: [28.4, 84.1], NZ: [-40.9, 174.9], OM: [21.5, 55.9], PA: [8.5, -80.8],
|
|
65
|
+
PE: [-9.2, -75.0], PG: [-6.3, 143.96], PH: [12.9, 121.8], PK: [30.4, 69.3], PL: [51.9, 19.1],
|
|
66
|
+
PR: [18.2, -66.6], PS: [31.95, 35.2], PT: [39.4, -8.2], PY: [-23.4, -58.4], QA: [25.4, 51.2],
|
|
67
|
+
RO: [45.9, 25.0], RS: [44.0, 21.0], RU: [61.5, 105.3], RW: [-1.9, 29.9], SA: [23.9, 45.1],
|
|
68
|
+
SB: [-9.6, 160.2], SC: [-4.7, 55.5], SD: [12.9, 30.2], SE: [60.1, 18.6], SG: [1.35, 103.8],
|
|
69
|
+
SI: [46.2, 15.0], SK: [48.7, 19.7], SL: [8.5, -11.8], SM: [43.9, 12.5], SN: [14.5, -14.5],
|
|
70
|
+
SO: [5.2, 46.2], SR: [3.9, -56.0], SS: [6.9, 31.3], SV: [13.8, -88.9], SY: [34.8, 39.0],
|
|
71
|
+
SZ: [-26.5, 31.5], TD: [15.5, 18.7], TG: [8.6, 0.8], TH: [15.9, 100.99], TJ: [38.9, 71.3],
|
|
72
|
+
TL: [-8.9, 125.7], TM: [38.97, 59.6], TN: [33.9, 9.5], TO: [-21.2, -175.2], TR: [38.96, 35.2],
|
|
73
|
+
TT: [10.7, -61.2], TW: [23.7, 121.0], TZ: [-6.4, 34.9], UA: [48.4, 31.2], UG: [1.4, 32.3],
|
|
74
|
+
US: [37.1, -95.7], UY: [-32.5, -55.8], UZ: [41.4, 64.6], VC: [13.3, -61.2], VE: [6.4, -66.6],
|
|
75
|
+
VN: [14.1, 108.3], VU: [-15.4, 166.96], WS: [-13.8, -172.1], YE: [15.6, 48.5], ZA: [-30.6, 22.9],
|
|
76
|
+
ZM: [-13.1, 27.8], ZW: [-19.0, 29.2],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** True for a two-letter code this list knows. */
|
|
80
|
+
export function isCountryCode(code: unknown): code is string {
|
|
81
|
+
return typeof code === "string" && COUNTRY_CODES.includes(code.toUpperCase());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const displayNamesCache = new Map<string, Intl.DisplayNames | null>();
|
|
85
|
+
|
|
86
|
+
function displayNames(locale: string): Intl.DisplayNames | null {
|
|
87
|
+
if (!displayNamesCache.has(locale)) {
|
|
88
|
+
let names: Intl.DisplayNames | null = null;
|
|
89
|
+
try {
|
|
90
|
+
names =
|
|
91
|
+
typeof Intl !== "undefined" && "DisplayNames" in Intl
|
|
92
|
+
? new Intl.DisplayNames([locale], { type: "region" })
|
|
93
|
+
: null;
|
|
94
|
+
} catch {
|
|
95
|
+
names = null;
|
|
96
|
+
}
|
|
97
|
+
displayNamesCache.set(locale, names);
|
|
98
|
+
}
|
|
99
|
+
return displayNamesCache.get(locale) ?? null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A country's name in `locale` ("DE" is "Germany" in English, "Deutschland"
|
|
104
|
+
* in German). Falls back to the code where the runtime has no names, and to
|
|
105
|
+
* null for no code at all.
|
|
106
|
+
*/
|
|
107
|
+
export function countryName(code: string | null | undefined, locale = "en"): string | null {
|
|
108
|
+
if (!code) return null;
|
|
109
|
+
const upper = code.toUpperCase();
|
|
110
|
+
try {
|
|
111
|
+
return displayNames(locale)?.of(upper) ?? upper;
|
|
112
|
+
} catch {
|
|
113
|
+
return upper;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Every country as a select option, sorted by its name in `locale`. */
|
|
118
|
+
export function countryOptions(locale = "en"): { code: string; name: string }[] {
|
|
119
|
+
const collator = new Intl.Collator(locale);
|
|
120
|
+
return COUNTRY_CODES.map((code) => ({ code, name: countryName(code, locale) ?? code })).sort((a, b) =>
|
|
121
|
+
collator.compare(a.name, b.name),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Community headless primitives. NOTE: this is a lane-local aggregator
|
|
1
|
+
// Community headless primitives. NOTE: this is a lane-local aggregator. The
|
|
2
2
|
// integrator re-exports it from `ui/headless/index.ts` (`export * from "./community"`).
|
|
3
3
|
export {
|
|
4
4
|
buildLexicalState,
|
|
@@ -18,3 +18,83 @@ export { useRichMentionComposer, type RichCommand } from "./useRichMentionCompos
|
|
|
18
18
|
export { serializeRichToLexical, collectMentions, isRichEmpty } from "./richLexical";
|
|
19
19
|
export { useCommunitySpaceFeed } from "./useCommunitySpaceFeed";
|
|
20
20
|
export { useCommunityThread } from "./useCommunityThread";
|
|
21
|
+
export {
|
|
22
|
+
useCommunityAgentThinking,
|
|
23
|
+
useChatAgentThinking,
|
|
24
|
+
useCommunityAgentAnswerFeedback,
|
|
25
|
+
useCommunityCatchUp,
|
|
26
|
+
linkifyText,
|
|
27
|
+
useCommunityAgentAnswerRatings,
|
|
28
|
+
chatThinkingKey,
|
|
29
|
+
AGENT_THINKING_TIMEOUT_MS,
|
|
30
|
+
CATCH_ME_UP_MIN_REPLIES,
|
|
31
|
+
type LinkifiedSegment,
|
|
32
|
+
} from "./useCommunityAgent";
|
|
33
|
+
export {
|
|
34
|
+
COUNTRY_CODES,
|
|
35
|
+
COUNTRY_CENTROIDS,
|
|
36
|
+
countryName,
|
|
37
|
+
countryOptions,
|
|
38
|
+
isCountryCode,
|
|
39
|
+
} from "./countries";
|
|
40
|
+
export {
|
|
41
|
+
useCommunityMemberDirectory,
|
|
42
|
+
memberGlobePoints,
|
|
43
|
+
memberMapCountries,
|
|
44
|
+
memberGlobeMarkers,
|
|
45
|
+
memberLocationLabel,
|
|
46
|
+
globeAnglesFor,
|
|
47
|
+
DIRECTORY_SORTS,
|
|
48
|
+
DIRECTORY_LEVELS,
|
|
49
|
+
DIRECTORY_SEARCH_DEBOUNCE_MS,
|
|
50
|
+
type MemberGlobePoint,
|
|
51
|
+
type MemberGlobeMarker,
|
|
52
|
+
type CommunityMembersView,
|
|
53
|
+
type UseCommunityMemberDirectoryOptions,
|
|
54
|
+
} from "./useCommunityMembers";
|
|
55
|
+
export {
|
|
56
|
+
useCommunityModeration,
|
|
57
|
+
useCommunityModeratorActions,
|
|
58
|
+
describeHoldReason,
|
|
59
|
+
describeModerationNotification,
|
|
60
|
+
restorableLogEntries,
|
|
61
|
+
communityMuteUntil,
|
|
62
|
+
moderationErrorMessage,
|
|
63
|
+
COMMUNITY_MUTE_DAYS,
|
|
64
|
+
type CommunityHoldReason,
|
|
65
|
+
type CommunityMuteDays,
|
|
66
|
+
type CommunityModerationOutcome,
|
|
67
|
+
type CommunityLogRestoreTarget,
|
|
68
|
+
type CommunityReportResolution,
|
|
69
|
+
type UseCommunityModerationOptions,
|
|
70
|
+
} from "./useCommunityModeration";
|
|
71
|
+
export {
|
|
72
|
+
useCommunityPoll,
|
|
73
|
+
useCommunityPollDraft,
|
|
74
|
+
validatePollDraft,
|
|
75
|
+
buildPollPayload,
|
|
76
|
+
emptyPollDraft,
|
|
77
|
+
parsePollCloseTime,
|
|
78
|
+
pollOptionPercent,
|
|
79
|
+
pollHiddenReason,
|
|
80
|
+
pollVoteErrorCode,
|
|
81
|
+
sameAnswer,
|
|
82
|
+
COMMUNITY_POLL_LIMITS,
|
|
83
|
+
type CommunityPollDraft,
|
|
84
|
+
type CommunityPollDraftErrors,
|
|
85
|
+
type CommunityPollQuestionError,
|
|
86
|
+
type CommunityPollOptionError,
|
|
87
|
+
type CommunityPollOptionsError,
|
|
88
|
+
type CommunityPollClosesError,
|
|
89
|
+
type CommunityPollHiddenReason,
|
|
90
|
+
type CommunityPollVoteError,
|
|
91
|
+
} from "./communityPoll";
|
|
92
|
+
export {
|
|
93
|
+
COMMUNITY_EMBED_ORIGINS,
|
|
94
|
+
safeHttpUrl,
|
|
95
|
+
safeEmbedUrl,
|
|
96
|
+
linkPreviewHost,
|
|
97
|
+
linkPreviewDisplay,
|
|
98
|
+
autoplayEmbedUrl,
|
|
99
|
+
type LinkPreviewDisplay,
|
|
100
|
+
} from "./linkPreview";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { CommunityLinkPreview } from "../../../data/queries/useCommunity";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Link cards on community posts (community parity §6.2). The server puts the
|
|
5
|
+
* first link of a post on it as a card, shortly after it is published. These
|
|
6
|
+
* helpers decide what of that card is safe to render: an http(s) link only,
|
|
7
|
+
* and a video player only from the three privacy-respecting embed origins the
|
|
8
|
+
* server itself builds. Anything else falls back to a plain link card, and a
|
|
9
|
+
* preview with no safe link renders nothing at all.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The only origins a post's video may play from. */
|
|
13
|
+
export const COMMUNITY_EMBED_ORIGINS: readonly string[] = [
|
|
14
|
+
"https://www.youtube-nocookie.com",
|
|
15
|
+
"https://player.vimeo.com",
|
|
16
|
+
"https://www.loom.com",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** The address, when it is an absolute http(s) URL. */
|
|
20
|
+
export function safeHttpUrl(value: unknown): string | null {
|
|
21
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
22
|
+
try {
|
|
23
|
+
const url = new URL(value.trim());
|
|
24
|
+
return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : null;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The video's player address, when the preview is a video from an allowed origin. */
|
|
31
|
+
export function safeEmbedUrl(preview: Pick<CommunityLinkPreview, "kind" | "embedUrl"> | null | undefined): string | null {
|
|
32
|
+
if (!preview || preview.kind !== "video" || typeof preview.embedUrl !== "string") return null;
|
|
33
|
+
try {
|
|
34
|
+
const url = new URL(preview.embedUrl);
|
|
35
|
+
return url.protocol === "https:" && COMMUNITY_EMBED_ORIGINS.includes(url.origin) ? url.toString() : null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** "example.com": the host without `www.`, for a card with no site name. */
|
|
42
|
+
export function linkPreviewHost(value: string | null | undefined): string | null {
|
|
43
|
+
const href = safeHttpUrl(value);
|
|
44
|
+
if (!href) return null;
|
|
45
|
+
return new URL(href).hostname.replace(/^www\./, "") || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** How a post's preview renders: a video facade, a link card, or nothing. */
|
|
49
|
+
export type LinkPreviewDisplay =
|
|
50
|
+
| { kind: "video"; href: string; embedUrl: string; imageUrl: string | null }
|
|
51
|
+
| { kind: "card"; href: string; imageUrl: string | null }
|
|
52
|
+
| null;
|
|
53
|
+
|
|
54
|
+
export function linkPreviewDisplay(preview: CommunityLinkPreview | null | undefined): LinkPreviewDisplay {
|
|
55
|
+
if (!preview) return null;
|
|
56
|
+
const href = safeHttpUrl(preview.url);
|
|
57
|
+
if (!href) return null;
|
|
58
|
+
const imageUrl = safeHttpUrl(preview.imageUrl);
|
|
59
|
+
const embedUrl = safeEmbedUrl(preview);
|
|
60
|
+
return embedUrl ? { kind: "video", href, embedUrl, imageUrl } : { kind: "card", href, imageUrl };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The player address with autoplay on, used once the member has pressed play,
|
|
65
|
+
* so one click starts the video rather than showing a second play button.
|
|
66
|
+
*/
|
|
67
|
+
export function autoplayEmbedUrl(embedUrl: string): string {
|
|
68
|
+
const url = new URL(embedUrl);
|
|
69
|
+
url.searchParams.set("autoplay", "1");
|
|
70
|
+
return url.toString();
|
|
71
|
+
}
|