@tribe-nest/forge 3.60.0 → 3.63.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.
Files changed (220) hide show
  1. package/package.json +5 -2
  2. package/src/contexts/CartContext.tsx +34 -1
  3. package/src/contexts/PublicAuthContext.tsx +15 -3
  4. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  5. package/src/data/queries/useAccount.ts +33 -2
  6. package/src/data/queries/useAnalytics.ts +6 -6
  7. package/src/data/queries/useCallChat.ts +2 -2
  8. package/src/data/queries/useChat.ts +10 -1
  9. package/src/data/queries/useCheckouts.ts +2 -1
  10. package/src/data/queries/useCoachingAvailability.ts +10 -1
  11. package/src/data/queries/useCommunities.ts +2 -0
  12. package/src/data/queries/useCommunity.ts +362 -18
  13. package/src/data/queries/useCommunityAgent.ts +247 -0
  14. package/src/data/queries/useCommunityCalendar.ts +150 -0
  15. package/src/data/queries/useCommunityClassroom.ts +75 -0
  16. package/src/data/queries/useCommunityMembers.ts +108 -0
  17. package/src/data/queries/useCommunityModeration.ts +327 -0
  18. package/src/data/queries/useCommunityWiki.ts +137 -0
  19. package/src/data/queries/useCourses.ts +2 -0
  20. package/src/data/queries/useEmailSignIn.ts +79 -0
  21. package/src/data/queries/useEvents.ts +6 -1
  22. package/src/data/queries/useForms.ts +5 -1
  23. package/src/data/queries/useLeadMagnet.ts +6 -2
  24. package/src/data/queries/useMyBookings.ts +24 -1
  25. package/src/data/queries/useMyTickets.ts +24 -1
  26. package/src/data/queries/useNotifications.ts +6 -2
  27. package/src/data/queries/useOrders.ts +2 -1
  28. package/src/data/queries/usePWA.ts +111 -35
  29. package/src/data/queries/useSiteApp.ts +130 -0
  30. package/src/data/queries/useSubscriptions.ts +6 -1
  31. package/src/i18n/de.json +746 -1
  32. package/src/i18n/en.json +746 -1
  33. package/src/index.ts +20 -0
  34. package/src/runtime/pages.spec.ts +5 -0
  35. package/src/runtime/pages.ts +32 -6
  36. package/src/seo/_tests/seo.spec.ts +326 -0
  37. package/src/seo/applySeoToHead.ts +98 -0
  38. package/src/seo/index.ts +9 -0
  39. package/src/seo/resolve.ts +211 -0
  40. package/src/seo/seoHead.ts +61 -0
  41. package/src/seo/siteSeoHead.ts +97 -0
  42. package/src/seo/types.ts +99 -0
  43. package/src/server/_tests/siteBootstrap.spec.ts +65 -0
  44. package/src/server/index.ts +33 -4
  45. package/src/types/models.ts +11 -0
  46. package/src/ui/analytics/ForgeAnalytics.tsx +134 -34
  47. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  48. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  49. package/src/ui/analytics/_tests/ForgeAnalytics.webVitals.spec.tsx +129 -0
  50. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  51. package/src/ui/analytics/useMetaEvents.ts +78 -0
  52. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  53. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  54. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  55. package/src/ui/headless/_tests/communityHarness.tsx +10 -3
  56. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  57. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  58. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  59. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  60. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  61. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  62. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  63. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
  64. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
  65. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  66. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
  67. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  68. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  69. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  70. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  71. package/src/ui/headless/auth/useSignupForm.ts +20 -1
  72. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  73. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  74. package/src/ui/headless/communities/index.ts +41 -0
  75. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  76. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  77. package/src/ui/headless/communities/useCommunityHome.ts +3 -0
  78. package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
  79. package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
  80. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  81. package/src/ui/headless/community/communityPoll.ts +297 -0
  82. package/src/ui/headless/community/countries.ts +123 -0
  83. package/src/ui/headless/community/index.ts +81 -1
  84. package/src/ui/headless/community/linkPreview.ts +71 -0
  85. package/src/ui/headless/community/richLexical.ts +27 -2
  86. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  87. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  88. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  89. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  90. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  91. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  92. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  93. package/src/ui/headless/downloads/index.ts +8 -0
  94. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  95. package/src/ui/headless/index.ts +13 -0
  96. package/src/ui/headless/memberHome/index.ts +10 -0
  97. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  98. package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
  99. package/src/ui/headless/portal/index.ts +57 -0
  100. package/src/ui/headless/portal/usePortal.ts +274 -0
  101. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  102. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  103. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  104. package/src/ui/headless/siteApp/appBar.ts +102 -0
  105. package/src/ui/headless/siteApp/appHome.ts +344 -0
  106. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  107. package/src/ui/headless/siteApp/index.ts +58 -0
  108. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  109. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  110. package/src/ui/headless/work/index.ts +2 -0
  111. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  112. package/src/ui/index.ts +11 -0
  113. package/src/ui/styled/AccountDashboard.tsx +712 -352
  114. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  115. package/src/ui/styled/ChatRoom.tsx +126 -16
  116. package/src/ui/styled/Checkout.tsx +8 -0
  117. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  118. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  119. package/src/ui/styled/CoachingDetail.tsx +2 -0
  120. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  121. package/src/ui/styled/CourseDetail.tsx +2 -0
  122. package/src/ui/styled/DownloadPage.tsx +263 -0
  123. package/src/ui/styled/EventConfirmation.tsx +10 -0
  124. package/src/ui/styled/EventDetail.tsx +10 -0
  125. package/src/ui/styled/FilmLibrary.tsx +22 -9
  126. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  127. package/src/ui/styled/ProductDetail.tsx +12 -0
  128. package/src/ui/styled/PwaRegistration.tsx +5 -1
  129. package/src/ui/styled/SignupForm.tsx +18 -3
  130. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
  131. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  132. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  133. package/src/ui/styled/app/AppBar.tsx +298 -0
  134. package/src/ui/styled/app/AppHome.tsx +1139 -0
  135. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  136. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  137. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  138. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  139. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  140. package/src/ui/styled/app/index.ts +7 -0
  141. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  142. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  143. package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
  144. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  145. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  146. package/src/ui/styled/communities/CommunityHome.tsx +134 -11
  147. package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
  148. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
  149. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  150. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  151. package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
  152. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  153. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  154. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  155. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  156. package/src/ui/styled/communities/index.ts +9 -0
  157. package/src/ui/styled/communities/shared.tsx +39 -11
  158. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  159. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  160. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  161. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  162. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  163. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  164. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  165. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  166. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  167. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  168. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  169. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  170. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  171. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  172. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  173. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  174. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  175. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  176. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  177. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  178. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  179. package/src/ui/styled/community/index.ts +37 -2
  180. package/src/ui/styled/forge-utilities.css +234 -0
  181. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  182. package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
  183. package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
  184. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  185. package/src/ui/styled/members/index.ts +8 -0
  186. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  187. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  188. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  189. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  190. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  191. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  192. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  193. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  194. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  195. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  196. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  197. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  198. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  199. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  200. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  201. package/src/ui/styled/portal/index.ts +13 -0
  202. package/src/ui/styled/portal/portalNav.ts +260 -0
  203. package/src/ui/styled/portal/shared.tsx +206 -0
  204. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  205. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  206. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  207. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  208. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  209. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  210. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  211. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  212. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  213. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  214. package/src/ui/styled/work/index.ts +1 -1
  215. package/src/utils/_tests/landing.spec.ts +39 -0
  216. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  217. package/src/utils/_tests/structuredDataSeo.spec.ts +247 -0
  218. package/src/utils/landing.ts +36 -10
  219. package/src/utils/metaEvents.ts +302 -0
  220. package/src/utils/structuredData.ts +339 -1
@@ -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
+ }
@@ -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
+ }