@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.
- package/package.json +42 -0
- package/src/client/createForgeClient.ts +54 -0
- package/src/content/ForgeContentProvider.tsx +118 -0
- package/src/content/ForgeEditBridge.tsx +134 -0
- package/src/content/editFlag.ts +23 -0
- package/src/content/index.ts +28 -0
- package/src/content/resolve.ts +67 -0
- package/src/content/types.ts +97 -0
- package/src/content/useField.ts +64 -0
- package/src/contexts/AppAuthContext.tsx +185 -0
- package/src/contexts/AudioPlayerContext.tsx +389 -0
- package/src/contexts/CartContext.tsx +131 -0
- package/src/contexts/PublicAuthContext.tsx +333 -0
- package/src/contexts/useAppAdminGuard.ts +56 -0
- package/src/css.d.ts +5 -0
- package/src/data/collectionParams.ts +57 -0
- package/src/data/queries/useAccount.ts +71 -0
- package/src/data/queries/useAccountSettings.ts +82 -0
- package/src/data/queries/useAnalytics.ts +86 -0
- package/src/data/queries/useAuthActions.ts +28 -0
- package/src/data/queries/useBlog.ts +103 -0
- package/src/data/queries/useBlogComments.ts +96 -0
- package/src/data/queries/useBroadcasts.ts +31 -0
- package/src/data/queries/useCertificates.ts +58 -0
- package/src/data/queries/useChat.ts +267 -0
- package/src/data/queries/useCoachingAvailability.ts +69 -0
- package/src/data/queries/useCoachingProducts.ts +67 -0
- package/src/data/queries/useCohortPage.ts +17 -0
- package/src/data/queries/useCollections.ts +211 -0
- package/src/data/queries/useCommunity.ts +794 -0
- package/src/data/queries/useCoupons.ts +20 -0
- package/src/data/queries/useCourseAccess.ts +50 -0
- package/src/data/queries/useCourses.ts +91 -0
- package/src/data/queries/useCurrencies.ts +33 -0
- package/src/data/queries/useDocuments.ts +208 -0
- package/src/data/queries/useDonations.ts +29 -0
- package/src/data/queries/useEngagement.ts +115 -0
- package/src/data/queries/useEvents.ts +69 -0
- package/src/data/queries/useFinalize.ts +109 -0
- package/src/data/queries/useForms.ts +31 -0
- package/src/data/queries/useInvoice.ts +65 -0
- package/src/data/queries/useLeadMagnet.ts +96 -0
- package/src/data/queries/useMembership.ts +20 -0
- package/src/data/queries/useNotifications.ts +267 -0
- package/src/data/queries/useOffers.ts +56 -0
- package/src/data/queries/useOrders.ts +71 -0
- package/src/data/queries/usePWA.ts +101 -0
- package/src/data/queries/usePageActions.ts +36 -0
- package/src/data/queries/usePaymentFlow.ts +80 -0
- package/src/data/queries/usePaymentLink.ts +47 -0
- package/src/data/queries/usePodcast.ts +85 -0
- package/src/data/queries/usePostCollections.ts +48 -0
- package/src/data/queries/usePosts.ts +94 -0
- package/src/data/queries/useProducts.ts +108 -0
- package/src/data/queries/useReplay.ts +19 -0
- package/src/data/queries/useReviews.ts +122 -0
- package/src/data/queries/useShipping.ts +109 -0
- package/src/data/queries/useSubscriptions.ts +66 -0
- package/src/data/queries/useWebsite.ts +128 -0
- package/src/data/queries/useWebsiteAgent.ts +100 -0
- package/src/index.ts +98 -0
- package/src/provider/ForgeAppProvider.tsx +149 -0
- package/src/provider/ForgeProvider.tsx +91 -0
- package/src/provider/SiteConfigProvider.tsx +27 -0
- package/src/server/_tests/buildPwaHead.spec.ts +182 -0
- package/src/server/_tests/buildWebManifest.spec.ts +313 -0
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +251 -0
- package/src/server/index.ts +260 -0
- package/src/server/jobs.ts +104 -0
- package/src/server/pwa.ts +332 -0
- package/src/types/index.ts +74 -0
- package/src/types/models.ts +1313 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +208 -0
- package/src/ui/analytics/PageMetaPixel.tsx +92 -0
- package/src/ui/content/Editable.tsx +90 -0
- package/src/ui/format/PriceDisplay.tsx +142 -0
- package/src/ui/format/useFormatCurrency.ts +92 -0
- package/src/ui/headless/agent/useAiAgent.ts +106 -0
- package/src/ui/headless/auth/useLoginFlow.ts +72 -0
- package/src/ui/headless/auth/useSignupForm.ts +85 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +59 -0
- package/src/ui/headless/chat/attachmentKind.ts +27 -0
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +132 -0
- package/src/ui/headless/chat/useChatRoom.ts +223 -0
- package/src/ui/headless/chat/useMessageThread.ts +60 -0
- package/src/ui/headless/checkout/useCheckout.ts +438 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +223 -0
- package/src/ui/headless/community/index.ts +20 -0
- package/src/ui/headless/community/lexical.ts +131 -0
- package/src/ui/headless/community/replyTree.ts +51 -0
- package/src/ui/headless/community/richLexical.ts +314 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +137 -0
- package/src/ui/headless/community/useCommunitySpaceFeed.ts +122 -0
- package/src/ui/headless/community/useCommunityThread.ts +95 -0
- package/src/ui/headless/community/useMentionComposer.ts +105 -0
- package/src/ui/headless/community/useRichMentionComposer.ts +193 -0
- package/src/ui/headless/consent/useCookieConsent.ts +81 -0
- package/src/ui/headless/course/useCourseCheckout.ts +99 -0
- package/src/ui/headless/dialog.tsx +14 -0
- package/src/ui/headless/document/index.ts +7 -0
- package/src/ui/headless/document/useContractSignature.ts +68 -0
- package/src/ui/headless/document/useDocumentSigningFlow.ts +92 -0
- package/src/ui/headless/document/useOtpCodeInput.ts +86 -0
- package/src/ui/headless/document/useSignaturePad.ts +180 -0
- package/src/ui/headless/donation/Donation.tsx +115 -0
- package/src/ui/headless/donation/DonationContext.tsx +223 -0
- package/src/ui/headless/donation/fees.ts +15 -0
- package/src/ui/headless/donation/index.ts +10 -0
- package/src/ui/headless/engagement/usePostEngagement.ts +119 -0
- package/src/ui/headless/event/useEventCheckout.ts +141 -0
- package/src/ui/headless/forms/useContactForm.ts +58 -0
- package/src/ui/headless/forms/useEmailListForm.ts +75 -0
- package/src/ui/headless/forms/useSectionedForm.ts +256 -0
- package/src/ui/headless/index.ts +60 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +48 -0
- package/src/ui/headless/memberHome/index.ts +7 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +82 -0
- package/src/ui/headless/membership/MembershipGate.tsx +39 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +121 -0
- package/src/ui/headless/offer/Offer.tsx +88 -0
- package/src/ui/headless/offer/OfferContext.tsx +162 -0
- package/src/ui/headless/offer/index.ts +8 -0
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +46 -0
- package/src/ui/headless/podcast/usePodcastPlayer.ts +96 -0
- package/src/ui/headless/posts/index.ts +13 -0
- package/src/ui/headless/posts/upsell.ts +41 -0
- package/src/ui/headless/posts/useMemberPostsFeed.ts +110 -0
- package/src/ui/headless/posts/usePostCollectionDetail.ts +56 -0
- package/src/ui/headless/posts/usePostCollectionsList.ts +90 -0
- package/src/ui/headless/posts/usePostItem.ts +81 -0
- package/src/ui/headless/reviews/index.ts +6 -0
- package/src/ui/headless/reviews/useHelpfulVote.ts +45 -0
- package/src/ui/headless/reviews/useReviewForm.ts +108 -0
- package/src/ui/headless/reviews/useReviewsList.ts +79 -0
- package/src/ui/headless/work/index.ts +25 -0
- package/src/ui/headless/work/useWorkPortal.ts +349 -0
- package/src/ui/index.ts +144 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +45 -0
- package/src/ui/payment/ForgeStripePayment.tsx +171 -0
- package/src/ui/shell/TribeNestApp.tsx +121 -0
- package/src/ui/shell/shellGating.spec.ts +28 -0
- package/src/ui/shell/shellGating.ts +11 -0
- package/src/ui/styled/AccountDashboard.tsx +575 -0
- package/src/ui/styled/AiAgentWidget.tsx +152 -0
- package/src/ui/styled/AudioPlayer.tsx +123 -0
- package/src/ui/styled/BlogCategory.tsx +79 -0
- package/src/ui/styled/BlogComments.tsx +236 -0
- package/src/ui/styled/BlogList.tsx +101 -0
- package/src/ui/styled/BlogPost.tsx +160 -0
- package/src/ui/styled/BlogPostFooter.tsx +33 -0
- package/src/ui/styled/BotProtection.tsx +101 -0
- package/src/ui/styled/Button.tsx +88 -0
- package/src/ui/styled/Cart.tsx +276 -0
- package/src/ui/styled/ChatRoom.tsx +252 -0
- package/src/ui/styled/Checkout.tsx +988 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +452 -0
- package/src/ui/styled/CoachingBooking.tsx +605 -0
- package/src/ui/styled/CoachingConfirmation.tsx +151 -0
- package/src/ui/styled/CoachingDetail.tsx +99 -0
- package/src/ui/styled/CohortPage.tsx +82 -0
- package/src/ui/styled/ConfirmSubscription.tsx +134 -0
- package/src/ui/styled/Confirmation.tsx +171 -0
- package/src/ui/styled/ContactForm.tsx +81 -0
- package/src/ui/styled/CookieConsent.tsx +239 -0
- package/src/ui/styled/CourseAccess.tsx +169 -0
- package/src/ui/styled/CourseCheckout.tsx +425 -0
- package/src/ui/styled/CourseConfirmation.tsx +191 -0
- package/src/ui/styled/CourseDetail.tsx +140 -0
- package/src/ui/styled/CoursesGrid.tsx +85 -0
- package/src/ui/styled/CurrencySwitcher.tsx +51 -0
- package/src/ui/styled/DonationButton.tsx +247 -0
- package/src/ui/styled/DonationPage.tsx +29 -0
- package/src/ui/styled/EmailListForm.tsx +88 -0
- package/src/ui/styled/EventConfirmation.tsx +228 -0
- package/src/ui/styled/EventCountdown.tsx +98 -0
- package/src/ui/styled/EventDetail.tsx +141 -0
- package/src/ui/styled/EventTickets.tsx +548 -0
- package/src/ui/styled/EventsList.tsx +83 -0
- package/src/ui/styled/ForgotPasswordForm.tsx +105 -0
- package/src/ui/styled/FormRenderer.tsx +249 -0
- package/src/ui/styled/InstallBanner.tsx +114 -0
- package/src/ui/styled/InvoiceConfirmation.tsx +50 -0
- package/src/ui/styled/InvoicePayment.tsx +141 -0
- package/src/ui/styled/LeadMagnet.tsx +93 -0
- package/src/ui/styled/Loading.tsx +56 -0
- package/src/ui/styled/LoginForm.tsx +134 -0
- package/src/ui/styled/Markdown.tsx +103 -0
- package/src/ui/styled/MembershipCheckout.tsx +197 -0
- package/src/ui/styled/MembershipTiers.tsx +115 -0
- package/src/ui/styled/OfferButton.tsx +181 -0
- package/src/ui/styled/PageActions.tsx +124 -0
- package/src/ui/styled/PaymentLinkConfirmation.tsx +50 -0
- package/src/ui/styled/PaymentLinkPayment.tsx +85 -0
- package/src/ui/styled/Paywall.tsx +54 -0
- package/src/ui/styled/PlayButton.tsx +48 -0
- package/src/ui/styled/PodcastEpisode.tsx +137 -0
- package/src/ui/styled/PodcastList.tsx +58 -0
- package/src/ui/styled/PodcastShow.tsx +164 -0
- package/src/ui/styled/PostsFeed.tsx +63 -0
- package/src/ui/styled/ProductDetail.tsx +616 -0
- package/src/ui/styled/ProductGrid.tsx +82 -0
- package/src/ui/styled/PushOptIn.tsx +65 -0
- package/src/ui/styled/PwaRegistration.tsx +58 -0
- package/src/ui/styled/ReactionBar.tsx +93 -0
- package/src/ui/styled/ReplayList.tsx +134 -0
- package/src/ui/styled/ResetPasswordForm.tsx +127 -0
- package/src/ui/styled/ReviewForm.tsx +292 -0
- package/src/ui/styled/ReviewsSection.tsx +260 -0
- package/src/ui/styled/SectionedFormRenderer.tsx +146 -0
- package/src/ui/styled/SignupForm.tsx +109 -0
- package/src/ui/styled/UserMenu.tsx +166 -0
- package/src/ui/styled/chat/ChatAttachments.tsx +172 -0
- package/src/ui/styled/chat/ChatComposer.tsx +153 -0
- package/src/ui/styled/chat/ChatThreadPanel.tsx +94 -0
- package/src/ui/styled/chat/MessageBubble.tsx +66 -0
- package/src/ui/styled/chat/NewDmDialog.tsx +78 -0
- package/src/ui/styled/community/CommunityComposer.tsx +358 -0
- package/src/ui/styled/community/CommunityFeed.tsx +483 -0
- package/src/ui/styled/community/CommunityLeaderboard.tsx +109 -0
- package/src/ui/styled/community/CommunityMediaGallery.tsx +116 -0
- package/src/ui/styled/community/CommunityMemberProfile.tsx +285 -0
- package/src/ui/styled/community/CommunityNotifications.tsx +222 -0
- package/src/ui/styled/community/CommunityPostCard.tsx +165 -0
- package/src/ui/styled/community/CommunityPostDetail.tsx +337 -0
- package/src/ui/styled/community/CommunityReactionBar.tsx +107 -0
- package/src/ui/styled/community/CommunityRichText.tsx +157 -0
- package/src/ui/styled/community/CommunitySpaces.tsx +174 -0
- package/src/ui/styled/community/MemberAvatar.tsx +74 -0
- package/src/ui/styled/community/index.ts +23 -0
- package/src/ui/styled/community/util.ts +15 -0
- package/src/ui/styled/members/MemberHomeLayout.tsx +98 -0
- package/src/ui/styled/members/MemberPostCard.tsx +392 -0
- package/src/ui/styled/members/MemberPostsFeed.tsx +219 -0
- package/src/ui/styled/members/PostCollectionDetail.tsx +227 -0
- package/src/ui/styled/members/PostCollectionsGrid.tsx +287 -0
- package/src/ui/styled/members/index.ts +7 -0
- package/src/ui/styled/rich-text.css +150 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +222 -0
- package/src/ui/styled/work/WorkPortalProjects.tsx +163 -0
- package/src/ui/styled/work/WorkProjectInvoices.tsx +113 -0
- package/src/ui/styled/work/WorkProjectReport.tsx +66 -0
- package/src/ui/styled/work/WorkReportBody.tsx +327 -0
- package/src/ui/styled/work/WorkTaskAttachments.tsx +67 -0
- package/src/ui/styled/work/WorkTaskComments.tsx +151 -0
- package/src/ui/styled/work/WorkTaskDetail.tsx +92 -0
- package/src/ui/styled/work/WorkTokenReport.tsx +49 -0
- package/src/ui/styled/work/index.ts +10 -0
- package/src/ui/theme/ForgeThemeProvider.tsx +121 -0
- package/src/ui/theme/contrast.ts +22 -0
- package/src/utils/analyticsBus.ts +31 -0
- package/src/utils/attribution.ts +68 -0
- package/src/utils/cookieConsent.ts +83 -0
- package/src/utils/landing.ts +153 -0
- package/src/utils/metaPixel.ts +122 -0
- 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";
|