@tribe-nest/forge 1.13.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 (255) hide show
  1. package/package.json +42 -0
  2. package/src/client/createForgeClient.ts +54 -0
  3. package/src/content/ForgeContentProvider.tsx +118 -0
  4. package/src/content/ForgeEditBridge.tsx +134 -0
  5. package/src/content/editFlag.ts +23 -0
  6. package/src/content/index.ts +28 -0
  7. package/src/content/resolve.ts +67 -0
  8. package/src/content/types.ts +97 -0
  9. package/src/content/useField.ts +64 -0
  10. package/src/contexts/AppAuthContext.tsx +185 -0
  11. package/src/contexts/AudioPlayerContext.tsx +389 -0
  12. package/src/contexts/CartContext.tsx +131 -0
  13. package/src/contexts/PublicAuthContext.tsx +333 -0
  14. package/src/contexts/useAppAdminGuard.ts +56 -0
  15. package/src/css.d.ts +5 -0
  16. package/src/data/collectionParams.ts +57 -0
  17. package/src/data/queries/useAccount.ts +71 -0
  18. package/src/data/queries/useAccountSettings.ts +82 -0
  19. package/src/data/queries/useAnalytics.ts +86 -0
  20. package/src/data/queries/useAuthActions.ts +28 -0
  21. package/src/data/queries/useBlog.ts +103 -0
  22. package/src/data/queries/useBlogComments.ts +96 -0
  23. package/src/data/queries/useBroadcasts.ts +31 -0
  24. package/src/data/queries/useCertificates.ts +58 -0
  25. package/src/data/queries/useChat.ts +267 -0
  26. package/src/data/queries/useCoachingAvailability.ts +69 -0
  27. package/src/data/queries/useCoachingProducts.ts +67 -0
  28. package/src/data/queries/useCohortPage.ts +17 -0
  29. package/src/data/queries/useCollections.ts +211 -0
  30. package/src/data/queries/useCommunity.ts +794 -0
  31. package/src/data/queries/useCoupons.ts +20 -0
  32. package/src/data/queries/useCourseAccess.ts +50 -0
  33. package/src/data/queries/useCourses.ts +91 -0
  34. package/src/data/queries/useCurrencies.ts +33 -0
  35. package/src/data/queries/useDocuments.ts +208 -0
  36. package/src/data/queries/useDonations.ts +29 -0
  37. package/src/data/queries/useEngagement.ts +115 -0
  38. package/src/data/queries/useEvents.ts +69 -0
  39. package/src/data/queries/useFinalize.ts +109 -0
  40. package/src/data/queries/useForms.ts +31 -0
  41. package/src/data/queries/useInvoice.ts +65 -0
  42. package/src/data/queries/useLeadMagnet.ts +96 -0
  43. package/src/data/queries/useMembership.ts +20 -0
  44. package/src/data/queries/useNotifications.ts +267 -0
  45. package/src/data/queries/useOffers.ts +56 -0
  46. package/src/data/queries/useOrders.ts +71 -0
  47. package/src/data/queries/usePWA.ts +101 -0
  48. package/src/data/queries/usePageActions.ts +36 -0
  49. package/src/data/queries/usePaymentFlow.ts +80 -0
  50. package/src/data/queries/usePaymentLink.ts +47 -0
  51. package/src/data/queries/usePodcast.ts +85 -0
  52. package/src/data/queries/usePostCollections.ts +48 -0
  53. package/src/data/queries/usePosts.ts +94 -0
  54. package/src/data/queries/useProducts.ts +108 -0
  55. package/src/data/queries/useReplay.ts +19 -0
  56. package/src/data/queries/useReviews.ts +122 -0
  57. package/src/data/queries/useShipping.ts +109 -0
  58. package/src/data/queries/useSubscriptions.ts +66 -0
  59. package/src/data/queries/useWebsite.ts +128 -0
  60. package/src/data/queries/useWebsiteAgent.ts +100 -0
  61. package/src/index.ts +98 -0
  62. package/src/provider/ForgeAppProvider.tsx +149 -0
  63. package/src/provider/ForgeProvider.tsx +91 -0
  64. package/src/provider/SiteConfigProvider.tsx +27 -0
  65. package/src/server/_tests/buildPwaHead.spec.ts +182 -0
  66. package/src/server/_tests/buildWebManifest.spec.ts +313 -0
  67. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +251 -0
  68. package/src/server/index.ts +260 -0
  69. package/src/server/jobs.ts +104 -0
  70. package/src/server/pwa.ts +332 -0
  71. package/src/types/index.ts +74 -0
  72. package/src/types/models.ts +1313 -0
  73. package/src/ui/analytics/ForgeAnalytics.tsx +208 -0
  74. package/src/ui/analytics/PageMetaPixel.tsx +92 -0
  75. package/src/ui/content/Editable.tsx +90 -0
  76. package/src/ui/format/PriceDisplay.tsx +142 -0
  77. package/src/ui/format/useFormatCurrency.ts +92 -0
  78. package/src/ui/headless/agent/useAiAgent.ts +106 -0
  79. package/src/ui/headless/auth/useLoginFlow.ts +72 -0
  80. package/src/ui/headless/auth/useSignupForm.ts +85 -0
  81. package/src/ui/headless/broadcast/useBroadcastWatch.ts +59 -0
  82. package/src/ui/headless/chat/attachmentKind.ts +27 -0
  83. package/src/ui/headless/chat/useChatAttachmentUpload.ts +132 -0
  84. package/src/ui/headless/chat/useChatRoom.ts +223 -0
  85. package/src/ui/headless/chat/useMessageThread.ts +60 -0
  86. package/src/ui/headless/checkout/useCheckout.ts +438 -0
  87. package/src/ui/headless/coaching/useCoachingBooking.ts +223 -0
  88. package/src/ui/headless/community/index.ts +20 -0
  89. package/src/ui/headless/community/lexical.ts +131 -0
  90. package/src/ui/headless/community/replyTree.ts +51 -0
  91. package/src/ui/headless/community/richLexical.ts +314 -0
  92. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +137 -0
  93. package/src/ui/headless/community/useCommunitySpaceFeed.ts +122 -0
  94. package/src/ui/headless/community/useCommunityThread.ts +95 -0
  95. package/src/ui/headless/community/useMentionComposer.ts +105 -0
  96. package/src/ui/headless/community/useRichMentionComposer.ts +193 -0
  97. package/src/ui/headless/consent/useCookieConsent.ts +81 -0
  98. package/src/ui/headless/course/useCourseCheckout.ts +99 -0
  99. package/src/ui/headless/dialog.tsx +14 -0
  100. package/src/ui/headless/document/index.ts +7 -0
  101. package/src/ui/headless/document/useContractSignature.ts +68 -0
  102. package/src/ui/headless/document/useDocumentSigningFlow.ts +92 -0
  103. package/src/ui/headless/document/useOtpCodeInput.ts +86 -0
  104. package/src/ui/headless/document/useSignaturePad.ts +180 -0
  105. package/src/ui/headless/donation/Donation.tsx +115 -0
  106. package/src/ui/headless/donation/DonationContext.tsx +223 -0
  107. package/src/ui/headless/donation/fees.ts +15 -0
  108. package/src/ui/headless/donation/index.ts +10 -0
  109. package/src/ui/headless/engagement/usePostEngagement.ts +119 -0
  110. package/src/ui/headless/event/useEventCheckout.ts +141 -0
  111. package/src/ui/headless/forms/useContactForm.ts +58 -0
  112. package/src/ui/headless/forms/useEmailListForm.ts +75 -0
  113. package/src/ui/headless/forms/useSectionedForm.ts +256 -0
  114. package/src/ui/headless/index.ts +60 -0
  115. package/src/ui/headless/invoice/useInvoicePayment.ts +48 -0
  116. package/src/ui/headless/memberHome/index.ts +7 -0
  117. package/src/ui/headless/memberHome/useMemberHomeNav.ts +82 -0
  118. package/src/ui/headless/membership/MembershipGate.tsx +39 -0
  119. package/src/ui/headless/membership/useMembershipCheckout.ts +121 -0
  120. package/src/ui/headless/offer/Offer.tsx +88 -0
  121. package/src/ui/headless/offer/OfferContext.tsx +162 -0
  122. package/src/ui/headless/offer/index.ts +8 -0
  123. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +46 -0
  124. package/src/ui/headless/podcast/usePodcastPlayer.ts +96 -0
  125. package/src/ui/headless/posts/index.ts +13 -0
  126. package/src/ui/headless/posts/upsell.ts +41 -0
  127. package/src/ui/headless/posts/useMemberPostsFeed.ts +110 -0
  128. package/src/ui/headless/posts/usePostCollectionDetail.ts +56 -0
  129. package/src/ui/headless/posts/usePostCollectionsList.ts +90 -0
  130. package/src/ui/headless/posts/usePostItem.ts +81 -0
  131. package/src/ui/headless/reviews/index.ts +6 -0
  132. package/src/ui/headless/reviews/useHelpfulVote.ts +45 -0
  133. package/src/ui/headless/reviews/useReviewForm.ts +108 -0
  134. package/src/ui/headless/reviews/useReviewsList.ts +79 -0
  135. package/src/ui/headless/work/index.ts +25 -0
  136. package/src/ui/headless/work/useWorkPortal.ts +349 -0
  137. package/src/ui/index.ts +144 -0
  138. package/src/ui/payment/ForgePaymentProvider.tsx +45 -0
  139. package/src/ui/payment/ForgeStripePayment.tsx +171 -0
  140. package/src/ui/shell/TribeNestApp.tsx +121 -0
  141. package/src/ui/shell/shellGating.spec.ts +28 -0
  142. package/src/ui/shell/shellGating.ts +11 -0
  143. package/src/ui/styled/AccountDashboard.tsx +575 -0
  144. package/src/ui/styled/AiAgentWidget.tsx +152 -0
  145. package/src/ui/styled/AudioPlayer.tsx +123 -0
  146. package/src/ui/styled/BlogCategory.tsx +79 -0
  147. package/src/ui/styled/BlogComments.tsx +236 -0
  148. package/src/ui/styled/BlogList.tsx +101 -0
  149. package/src/ui/styled/BlogPost.tsx +160 -0
  150. package/src/ui/styled/BlogPostFooter.tsx +33 -0
  151. package/src/ui/styled/BotProtection.tsx +101 -0
  152. package/src/ui/styled/Button.tsx +88 -0
  153. package/src/ui/styled/Cart.tsx +276 -0
  154. package/src/ui/styled/ChatRoom.tsx +252 -0
  155. package/src/ui/styled/Checkout.tsx +988 -0
  156. package/src/ui/styled/CheckoutConfirmation.tsx +452 -0
  157. package/src/ui/styled/CoachingBooking.tsx +605 -0
  158. package/src/ui/styled/CoachingConfirmation.tsx +151 -0
  159. package/src/ui/styled/CoachingDetail.tsx +99 -0
  160. package/src/ui/styled/CohortPage.tsx +82 -0
  161. package/src/ui/styled/ConfirmSubscription.tsx +134 -0
  162. package/src/ui/styled/Confirmation.tsx +171 -0
  163. package/src/ui/styled/ContactForm.tsx +81 -0
  164. package/src/ui/styled/CookieConsent.tsx +239 -0
  165. package/src/ui/styled/CourseAccess.tsx +169 -0
  166. package/src/ui/styled/CourseCheckout.tsx +425 -0
  167. package/src/ui/styled/CourseConfirmation.tsx +191 -0
  168. package/src/ui/styled/CourseDetail.tsx +140 -0
  169. package/src/ui/styled/CoursesGrid.tsx +85 -0
  170. package/src/ui/styled/CurrencySwitcher.tsx +51 -0
  171. package/src/ui/styled/DonationButton.tsx +247 -0
  172. package/src/ui/styled/DonationPage.tsx +29 -0
  173. package/src/ui/styled/EmailListForm.tsx +88 -0
  174. package/src/ui/styled/EventConfirmation.tsx +228 -0
  175. package/src/ui/styled/EventCountdown.tsx +98 -0
  176. package/src/ui/styled/EventDetail.tsx +141 -0
  177. package/src/ui/styled/EventTickets.tsx +548 -0
  178. package/src/ui/styled/EventsList.tsx +83 -0
  179. package/src/ui/styled/ForgotPasswordForm.tsx +105 -0
  180. package/src/ui/styled/FormRenderer.tsx +249 -0
  181. package/src/ui/styled/InstallBanner.tsx +114 -0
  182. package/src/ui/styled/InvoiceConfirmation.tsx +50 -0
  183. package/src/ui/styled/InvoicePayment.tsx +141 -0
  184. package/src/ui/styled/LeadMagnet.tsx +93 -0
  185. package/src/ui/styled/Loading.tsx +56 -0
  186. package/src/ui/styled/LoginForm.tsx +134 -0
  187. package/src/ui/styled/Markdown.tsx +103 -0
  188. package/src/ui/styled/MembershipCheckout.tsx +197 -0
  189. package/src/ui/styled/MembershipTiers.tsx +115 -0
  190. package/src/ui/styled/OfferButton.tsx +181 -0
  191. package/src/ui/styled/PageActions.tsx +124 -0
  192. package/src/ui/styled/PaymentLinkConfirmation.tsx +50 -0
  193. package/src/ui/styled/PaymentLinkPayment.tsx +85 -0
  194. package/src/ui/styled/Paywall.tsx +54 -0
  195. package/src/ui/styled/PlayButton.tsx +48 -0
  196. package/src/ui/styled/PodcastEpisode.tsx +137 -0
  197. package/src/ui/styled/PodcastList.tsx +58 -0
  198. package/src/ui/styled/PodcastShow.tsx +164 -0
  199. package/src/ui/styled/PostsFeed.tsx +63 -0
  200. package/src/ui/styled/ProductDetail.tsx +616 -0
  201. package/src/ui/styled/ProductGrid.tsx +82 -0
  202. package/src/ui/styled/PushOptIn.tsx +65 -0
  203. package/src/ui/styled/PwaRegistration.tsx +58 -0
  204. package/src/ui/styled/ReactionBar.tsx +93 -0
  205. package/src/ui/styled/ReplayList.tsx +134 -0
  206. package/src/ui/styled/ResetPasswordForm.tsx +127 -0
  207. package/src/ui/styled/ReviewForm.tsx +292 -0
  208. package/src/ui/styled/ReviewsSection.tsx +260 -0
  209. package/src/ui/styled/SectionedFormRenderer.tsx +146 -0
  210. package/src/ui/styled/SignupForm.tsx +109 -0
  211. package/src/ui/styled/UserMenu.tsx +166 -0
  212. package/src/ui/styled/chat/ChatAttachments.tsx +172 -0
  213. package/src/ui/styled/chat/ChatComposer.tsx +153 -0
  214. package/src/ui/styled/chat/ChatThreadPanel.tsx +94 -0
  215. package/src/ui/styled/chat/MessageBubble.tsx +66 -0
  216. package/src/ui/styled/chat/NewDmDialog.tsx +78 -0
  217. package/src/ui/styled/community/CommunityComposer.tsx +358 -0
  218. package/src/ui/styled/community/CommunityFeed.tsx +483 -0
  219. package/src/ui/styled/community/CommunityLeaderboard.tsx +109 -0
  220. package/src/ui/styled/community/CommunityMediaGallery.tsx +116 -0
  221. package/src/ui/styled/community/CommunityMemberProfile.tsx +285 -0
  222. package/src/ui/styled/community/CommunityNotifications.tsx +222 -0
  223. package/src/ui/styled/community/CommunityPostCard.tsx +165 -0
  224. package/src/ui/styled/community/CommunityPostDetail.tsx +337 -0
  225. package/src/ui/styled/community/CommunityReactionBar.tsx +107 -0
  226. package/src/ui/styled/community/CommunityRichText.tsx +157 -0
  227. package/src/ui/styled/community/CommunitySpaces.tsx +174 -0
  228. package/src/ui/styled/community/MemberAvatar.tsx +74 -0
  229. package/src/ui/styled/community/index.ts +23 -0
  230. package/src/ui/styled/community/util.ts +15 -0
  231. package/src/ui/styled/members/MemberHomeLayout.tsx +98 -0
  232. package/src/ui/styled/members/MemberPostCard.tsx +392 -0
  233. package/src/ui/styled/members/MemberPostsFeed.tsx +219 -0
  234. package/src/ui/styled/members/PostCollectionDetail.tsx +227 -0
  235. package/src/ui/styled/members/PostCollectionsGrid.tsx +287 -0
  236. package/src/ui/styled/members/index.ts +7 -0
  237. package/src/ui/styled/rich-text.css +150 -0
  238. package/src/ui/styled/work/WorkInviteAccept.tsx +222 -0
  239. package/src/ui/styled/work/WorkPortalProjects.tsx +163 -0
  240. package/src/ui/styled/work/WorkProjectInvoices.tsx +113 -0
  241. package/src/ui/styled/work/WorkProjectReport.tsx +66 -0
  242. package/src/ui/styled/work/WorkReportBody.tsx +327 -0
  243. package/src/ui/styled/work/WorkTaskAttachments.tsx +67 -0
  244. package/src/ui/styled/work/WorkTaskComments.tsx +151 -0
  245. package/src/ui/styled/work/WorkTaskDetail.tsx +92 -0
  246. package/src/ui/styled/work/WorkTokenReport.tsx +49 -0
  247. package/src/ui/styled/work/index.ts +10 -0
  248. package/src/ui/theme/ForgeThemeProvider.tsx +121 -0
  249. package/src/ui/theme/contrast.ts +22 -0
  250. package/src/utils/analyticsBus.ts +31 -0
  251. package/src/utils/attribution.ts +68 -0
  252. package/src/utils/cookieConsent.ts +83 -0
  253. package/src/utils/landing.ts +153 -0
  254. package/src/utils/metaPixel.ts +122 -0
  255. package/src/utils/structuredData.ts +126 -0
@@ -0,0 +1,90 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { IPublicPostCollection, MembershipTier } from "../../../types/models";
3
+ import { useGetPostCollections } from "../../../data/queries/usePostCollections";
4
+ import { useGetMembershipTiers } from "../../../data/queries/useMembership";
5
+ import { usePublicAuth } from "../../../contexts/PublicAuthContext";
6
+ import { buildPostUpsell, type PostUpsell, type UpsellPathOptions } from "./upsell";
7
+
8
+ export interface UsePostCollectionsListOptions extends UpsellPathOptions {
9
+ /** Debounce for the search input → query round trip (default 500ms, matching the craft members area). */
10
+ searchDebounceMs?: number;
11
+ }
12
+
13
+ /**
14
+ * Headless post-collections list — the Forge port of the craft members area's
15
+ * `CollectionsContent` list semantics: paginated `GET /public/post-collections`
16
+ * (the server computes per-collection `hasAccess`; a locked collection can be
17
+ * shown but not opened — never re-gate client-side), debounced search, and the
18
+ * locked-collection upsell target (which tier unlocks + membership checkout
19
+ * deep link). Bring your own UI; drill-in uses `usePostCollectionDetail`.
20
+ */
21
+ export function usePostCollectionsList(opts: UsePostCollectionsListOptions = {}) {
22
+ const { isAuthenticated } = usePublicAuth();
23
+
24
+ // ── Search + paging ────────────────────────────────────────────────────────
25
+ const [search, setSearchState] = useState("");
26
+ const [debouncedSearch, setDebouncedSearch] = useState("");
27
+ const [page, setPage] = useState(1);
28
+ const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
29
+
30
+ const setSearch = (value: string) => {
31
+ setSearchState(value);
32
+ if (debounceTimer.current) clearTimeout(debounceTimer.current);
33
+ debounceTimer.current = setTimeout(() => {
34
+ setDebouncedSearch(value);
35
+ setPage(1);
36
+ }, opts.searchDebounceMs ?? 500);
37
+ };
38
+ useEffect(
39
+ () => () => {
40
+ if (debounceTimer.current) clearTimeout(debounceTimer.current);
41
+ },
42
+ [],
43
+ );
44
+
45
+ const clearFilters = () => {
46
+ if (debounceTimer.current) clearTimeout(debounceTimer.current);
47
+ setSearchState("");
48
+ setDebouncedSearch("");
49
+ setPage(1);
50
+ };
51
+ const hasActiveFilters = !!search;
52
+
53
+ // ── Data ───────────────────────────────────────────────────────────────────
54
+ const { data: tiers } = useGetMembershipTiers();
55
+ const listQ = useGetPostCollections({ query: debouncedSearch || undefined, page });
56
+
57
+ const collections: IPublicPostCollection[] = listQ.data?.data ?? [];
58
+ const total = listQ.data?.total ?? 0;
59
+ const pageSize = listQ.data?.pageSize ?? 10;
60
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
61
+
62
+ /** Locked-collection upsell: which tier unlocks it + the checkout/signup deep link. */
63
+ const getUpsell = (collection: IPublicPostCollection): PostUpsell =>
64
+ buildPostUpsell(collection, tiers, isAuthenticated, opts);
65
+
66
+ return {
67
+ collections,
68
+ total,
69
+ isLoading: listQ.isLoading,
70
+ isError: listQ.isError,
71
+ // Paging (numbered, server-side — matches the craft pagination).
72
+ page,
73
+ pageSize,
74
+ totalPages,
75
+ hasPrevPage: page > 1,
76
+ hasNextPage: page < totalPages,
77
+ setPage,
78
+ nextPage: () => setPage((p) => Math.min(p + 1, totalPages)),
79
+ prevPage: () => setPage((p) => Math.max(p - 1, 1)),
80
+ // Search.
81
+ search,
82
+ setSearch,
83
+ hasActiveFilters,
84
+ clearFilters,
85
+ /** Tiers for locked-card tier names. */
86
+ tiers: (tiers ?? []) as MembershipTier[],
87
+ isAuthenticated,
88
+ getUpsell,
89
+ };
90
+ }
@@ -0,0 +1,81 @@
1
+ import { useMemo } from "react";
2
+ import type { IMedia, IPublicPost } from "../../../types/models";
3
+ import type { AudioTrack } from "../../../contexts/AudioPlayerContext";
4
+ import { usePublicAuth } from "../../../contexts/PublicAuthContext";
5
+ import { useGetMembershipTiers } from "../../../data/queries/useMembership";
6
+ import { usePostEngagement } from "../engagement/usePostEngagement";
7
+ import { buildPostUpsell, type PostUpsell, type UpsellPathOptions } from "./upsell";
8
+
9
+ export interface UsePostItemOptions extends UpsellPathOptions {
10
+ /** Other audio posts in the feed — becomes the continue-playing queue. */
11
+ otherAudioPosts?: IPublicPost[];
12
+ /** Shown as the audio track artist (the craft members area uses the profile name). */
13
+ artistName?: string;
14
+ /** Called when a logged-out visitor tries to like/save/comment. */
15
+ onRequireAuth?: () => void;
16
+ }
17
+
18
+ /** How the card body should render. */
19
+ export type PostBodyKind = "locked" | "poll" | "media" | "text";
20
+
21
+ const trackTitle = (caption: string) => (caption.length > 20 ? caption.substring(0, 20) + "…" : caption);
22
+
23
+ const toTrack = (post: IPublicPost, media: IMedia, artistName?: string): AudioTrack => ({
24
+ id: media.id,
25
+ url: media.url,
26
+ title: trackTitle(post.caption ?? ""),
27
+ artist: artistName ?? "",
28
+ });
29
+
30
+ /**
31
+ * Headless single-post behavior — the Forge port of the craft `PostItem` minus
32
+ * rendering: engagement (like/save/comment via `usePostEngagement`), media
33
+ * selection (image/video/audio/document), the audio track + queue for the
34
+ * Forge audio player, and the locked-post upsell state. The server already
35
+ * gated the post: `hasAccess === false` means no media was shipped.
36
+ */
37
+ export function usePostItem(post: IPublicPost, opts: UsePostItemOptions = {}) {
38
+ const { isAuthenticated } = usePublicAuth();
39
+ const { data: tiers } = useGetMembershipTiers();
40
+
41
+ const engagement = usePostEngagement(post.id, "post", {
42
+ onRequireAuth: opts.onRequireAuth,
43
+ initialLikeCount: post.likes ?? 0,
44
+ initialCommentCount: post.comments ?? 0,
45
+ });
46
+
47
+ const isLocked = !post.hasAccess;
48
+ const media = !isLocked ? (post.media?.[0] ?? null) : null;
49
+
50
+ const kind: PostBodyKind = post.type === "poll" ? "poll" : isLocked ? "locked" : media ? "media" : "text";
51
+
52
+ /** The playable track when this is an accessible audio post. */
53
+ const audioTrack = useMemo(
54
+ () => (post.type === "audio" && media?.url ? toTrack(post, media, opts.artistName) : null),
55
+ [post, media, opts.artistName],
56
+ );
57
+
58
+ /** Queue built from the surrounding accessible audio posts (feed order). */
59
+ const audioQueue = useMemo(
60
+ () =>
61
+ (opts.otherAudioPosts ?? [])
62
+ .filter((p) => p.hasAccess && p.media?.[0]?.url)
63
+ .map((p) => toTrack(p, p.media![0]!, opts.artistName)),
64
+ [opts.otherAudioPosts, opts.artistName],
65
+ );
66
+
67
+ const upsell: PostUpsell = buildPostUpsell(post, tiers, isAuthenticated, opts);
68
+
69
+ return {
70
+ post,
71
+ kind,
72
+ isLocked,
73
+ /** First media item (null when locked — the server ships none). */
74
+ media,
75
+ audioTrack,
76
+ audioQueue,
77
+ upsell,
78
+ engagement,
79
+ isAuthenticated,
80
+ };
81
+ }
@@ -0,0 +1,6 @@
1
+ // Reviews headless primitives (Tier 1). NOTE: this is a lane-local aggregator —
2
+ // the integrator re-exports it from `ui/headless/index.ts`
3
+ // (`export * from "./reviews"`).
4
+ export { useReviewsList, REVIEW_SORT_OPTIONS, type UseReviewsListOptions } from "./useReviewsList";
5
+ export { useHelpfulVote } from "./useHelpfulVote";
6
+ export { useReviewForm, type ReviewFormStatus } from "./useReviewForm";
@@ -0,0 +1,45 @@
1
+ import { useContext, useEffect, useState } from "react";
2
+ import type { IPublicReview } from "../../../types/models";
3
+ import { useAddHelpfulVote, useRemoveHelpfulVote } from "../../../data/queries/useReviews";
4
+ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
5
+
6
+ /**
7
+ * Headless helpful-vote toggle for one review. Voting requires a logged-in
8
+ * member (`canVote`); the voted state is session-local (the backend exposes no
9
+ * per-viewer vote flag on the list) — first toggle adds the vote, second
10
+ * removes it, and failures (e.g. expired session, duplicate vote) are
11
+ * swallowed non-fatally. The count re-syncs whenever the list refetches.
12
+ */
13
+ export function useHelpfulVote(review: Pick<IPublicReview, "id" | "helpfulCount">) {
14
+ const auth = useContext(PublicAuthContext);
15
+ const canVote = !!auth?.isAuthenticated;
16
+
17
+ const addVote = useAddHelpfulVote();
18
+ const removeVote = useRemoveHelpfulVote();
19
+ const [voted, setVoted] = useState(false);
20
+ const [helpfulCount, setHelpfulCount] = useState(review.helpfulCount);
21
+
22
+ // Keep the local count in sync when the list refetches.
23
+ useEffect(() => setHelpfulCount(review.helpfulCount), [review.helpfulCount]);
24
+
25
+ const isPending = addVote.isPending || removeVote.isPending;
26
+
27
+ const toggle = async () => {
28
+ if (!canVote || isPending) return;
29
+ try {
30
+ if (voted) {
31
+ const res = await removeVote.mutateAsync({ reviewId: review.id });
32
+ setHelpfulCount(res.helpfulCount);
33
+ setVoted(false);
34
+ } else {
35
+ const res = await addVote.mutateAsync({ reviewId: review.id });
36
+ setHelpfulCount(res.helpfulCount);
37
+ setVoted(true);
38
+ }
39
+ } catch {
40
+ // Vote failures are non-fatal (e.g. session expired) — leave state as-is.
41
+ }
42
+ };
43
+
44
+ return { voted, helpfulCount, toggle, canVote, isPending };
45
+ }
@@ -0,0 +1,108 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { ISubmittedReview } from "../../../types/models";
3
+ import { useReviewRequest, useSubmitReview } from "../../../data/queries/useReviews";
4
+
5
+ /** Which screen the token-gated review form should render. */
6
+ export type ReviewFormStatus =
7
+ | "loading" // request context still loading
8
+ | "invalid" // bad/unknown token
9
+ | "expired" // token valid but past its window
10
+ | "locked" // already reviewed and the 30-day edit window has passed
11
+ | "submitted" // this session just submitted — show the thank-you state
12
+ | "form"; // show the (possibly prefilled) form
13
+
14
+ /**
15
+ * Headless review-submission form behind an emailed token link
16
+ * (`/reviews/:token` — the token proves the verified purchase, no login).
17
+ * Owns the whole state machine: invalid/expired links, already-reviewed
18
+ * locked, edit-within-30-days prefill, submit, and the spam-guard
19
+ * "held for moderation" outcome. The behavior core of `<ReviewForm/>`.
20
+ */
21
+ export function useReviewForm(token?: string) {
22
+ const { data: request, isLoading, isError } = useReviewRequest(token);
23
+ const submitMutation = useSubmitReview();
24
+
25
+ const [rating, setRating] = useState(0);
26
+ const [title, setTitle] = useState("");
27
+ const [body, setBody] = useState("");
28
+ const [reviewerName, setReviewerName] = useState("");
29
+ const [submitted, setSubmitted] = useState<ISubmittedReview | null>(null);
30
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
31
+
32
+ // Prefill from the existing review (edit-within-window) or defaults.
33
+ useEffect(() => {
34
+ if (!request) return;
35
+ const existing = request.existingReview;
36
+ if (existing) {
37
+ setRating(existing.rating);
38
+ setTitle(existing.title ?? "");
39
+ setBody(existing.body ?? "");
40
+ setReviewerName(existing.reviewerName ?? request.defaultReviewerName ?? "");
41
+ } else {
42
+ setReviewerName((prev) => prev || request.defaultReviewerName || "");
43
+ }
44
+ }, [request]);
45
+
46
+ const existingReview = request?.existingReview ?? null;
47
+
48
+ const status: ReviewFormStatus = isLoading
49
+ ? "loading"
50
+ : isError || !request
51
+ ? "invalid"
52
+ : !request.valid || request.status === "expired"
53
+ ? "expired"
54
+ : submitted
55
+ ? "submitted"
56
+ : existingReview && !existingReview.canEdit
57
+ ? "locked"
58
+ : "form";
59
+
60
+ const canSubmit = rating >= 1 && !submitMutation.isPending;
61
+
62
+ const submit = async () => {
63
+ if (!canSubmit || !token) return;
64
+ setErrorMessage(null);
65
+ try {
66
+ const review = await submitMutation.mutateAsync({
67
+ token,
68
+ rating,
69
+ title: title.trim() || null,
70
+ body: body.trim() || null,
71
+ reviewerName: reviewerName.trim() || null,
72
+ });
73
+ setSubmitted(review);
74
+ } catch (error: unknown) {
75
+ const message =
76
+ (error as { response?: { data?: { message?: string } } })?.response?.data?.message ||
77
+ "Something went wrong submitting your review. Please try again.";
78
+ setErrorMessage(message);
79
+ }
80
+ };
81
+
82
+ return {
83
+ status,
84
+ /** The full request context (entity title, profile, existing review). */
85
+ request: request ?? null,
86
+ entityTitle: request?.entityTitle ?? null,
87
+ /** Prior review when the link was already used (prefills the form; read-only when locked). */
88
+ existingReview,
89
+ // Fields
90
+ rating,
91
+ setRating,
92
+ title,
93
+ setTitle,
94
+ body,
95
+ setBody,
96
+ reviewerName,
97
+ setReviewerName,
98
+ // Submission
99
+ canSubmit,
100
+ isSubmitting: submitMutation.isPending,
101
+ submit,
102
+ errorMessage,
103
+ /** The review returned by the submit, when status === "submitted". */
104
+ submitted,
105
+ /** True when the spam guard held the submitted review for moderation. */
106
+ isHeld: !!submitted && (submitted.heldReason === "held_for_review" || submitted.status === "pending"),
107
+ };
108
+ }
@@ -0,0 +1,79 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import type { IPublicReview, IReviewAggregate, ReviewEntityType, ReviewSortOption } from "../../../types/models";
3
+ import { usePublicReviews } from "../../../data/queries/useReviews";
4
+
5
+ /** The sort options the public reviews endpoint supports, with display labels. */
6
+ export const REVIEW_SORT_OPTIONS: { value: ReviewSortOption; label: string }[] = [
7
+ { value: "recent", label: "Most recent" },
8
+ { value: "rating", label: "Highest rating" },
9
+ { value: "helpful", label: "Most helpful" },
10
+ ];
11
+
12
+ export interface UseReviewsListOptions {
13
+ /** Which kind of entity these reviews belong to. */
14
+ entityType: ReviewEntityType;
15
+ /** The entity's id (product/course/coaching product UUID). */
16
+ entityId?: string;
17
+ /** Reviews per page. Default 10. */
18
+ pageSize?: number;
19
+ /** Aggregate attached to the detail response — used for instant paint (SSR). */
20
+ initialAggregate?: IReviewAggregate | null;
21
+ }
22
+
23
+ /**
24
+ * Headless reviews list for an entity's detail page: sortable, "load more"
25
+ * accumulated pages, and the rating aggregate (average + count + star
26
+ * distribution). Changing sort resets to page 1. `isEmpty` is true until the
27
+ * entity has at least one published review — render nothing in that case to
28
+ * keep the page clean. The behavior core of `<ReviewsSection/>`.
29
+ */
30
+ export function useReviewsList({ entityType, entityId, pageSize = 10, initialAggregate }: UseReviewsListOptions) {
31
+ const [sort, setSortState] = useState<ReviewSortOption>("recent");
32
+ const [page, setPage] = useState(1);
33
+ const [reviews, setReviews] = useState<IPublicReview[]>([]);
34
+
35
+ const { data, isFetching } = usePublicReviews(
36
+ { entityType, entityId: entityId ?? "", page, sort, limit: pageSize },
37
+ !!entityId,
38
+ );
39
+
40
+ // Accumulate pages ("load more"); page 1 replaces (fresh sort or refetch).
41
+ useEffect(() => {
42
+ if (!data) return;
43
+ setReviews((prev) =>
44
+ data.page === 1 ? data.data : [...prev, ...data.data.filter((r) => !prev.some((p) => p.id === r.id))],
45
+ );
46
+ }, [data]);
47
+
48
+ const setSort = (next: ReviewSortOption) => {
49
+ if (next === sort) return;
50
+ setSortState(next);
51
+ setPage(1);
52
+ };
53
+
54
+ const aggregate = data?.aggregate ?? initialAggregate ?? null;
55
+ const total = data?.total ?? aggregate?.reviewCount ?? 0;
56
+ const isEmpty = !aggregate || aggregate.reviewCount === 0;
57
+
58
+ /** Largest star-bucket count — the denominator for distribution bar widths. */
59
+ const distributionMax = useMemo(
60
+ () => (aggregate ? Math.max(1, ...Object.values(aggregate.distribution)) : 1),
61
+ [aggregate],
62
+ );
63
+
64
+ return {
65
+ reviews,
66
+ aggregate,
67
+ total,
68
+ isEmpty,
69
+ distributionMax,
70
+ // Sorting
71
+ sort,
72
+ setSort,
73
+ sortOptions: REVIEW_SORT_OPTIONS,
74
+ // Paging
75
+ hasMore: reviews.length < total,
76
+ loadMore: () => setPage((p) => p + 1),
77
+ isFetching,
78
+ };
79
+ }
@@ -0,0 +1,25 @@
1
+ // Work (project management) client-portal headless primitives.
2
+ export {
3
+ useWorkPortalProjects,
4
+ useWorkProjectReport,
5
+ useWorkTokenReport,
6
+ useWorkTaskComments,
7
+ useWorkTaskAttachments,
8
+ useWorkInvite,
9
+ useAcceptWorkInvite,
10
+ useWorkProjectInvoices,
11
+ formatWorkMoney,
12
+ formatWorkDate,
13
+ type WorkProjectSummary,
14
+ type WorkMilestone,
15
+ type WorkTask,
16
+ type WorkTimeReportRow,
17
+ type WorkTimeReport,
18
+ type WorkProjectReportData,
19
+ type WorkTaskComment,
20
+ type WorkTaskAttachment,
21
+ type WorkInviteContext,
22
+ type WorkInviteAcceptResult,
23
+ type WorkProjectInvoice,
24
+ type UseWorkTaskCommentsResult,
25
+ } from "./useWorkPortal";