@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,88 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
DialogRoot,
|
|
4
|
+
DialogTrigger,
|
|
5
|
+
DialogPortal,
|
|
6
|
+
DialogOverlay,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogClose,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
} from "../dialog";
|
|
11
|
+
import { OfferProvider, useOfferContext, type OfferProviderProps } from "./OfferContext";
|
|
12
|
+
|
|
13
|
+
/** Render-prop payload for `<Offer.PaymentStep>` (redirect-mode payment). */
|
|
14
|
+
export interface OfferPaymentRenderProps {
|
|
15
|
+
clientSecret?: string;
|
|
16
|
+
returnUrl: string;
|
|
17
|
+
amount: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type OfferComponent = ((props: OfferProviderProps) => React.ReactElement) & {
|
|
21
|
+
Trigger: (props: { children: ReactNode; asChild?: boolean }) => React.ReactElement;
|
|
22
|
+
Modal: (props: {
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
className?: string;
|
|
25
|
+
overlayClassName?: string;
|
|
26
|
+
style?: CSSProperties;
|
|
27
|
+
overlayStyle?: CSSProperties;
|
|
28
|
+
}) => React.ReactElement;
|
|
29
|
+
Title: (props: { children: ReactNode; className?: string }) => React.ReactElement;
|
|
30
|
+
Close: (props: { children: ReactNode; asChild?: boolean }) => React.ReactElement;
|
|
31
|
+
DetailsStep: (props: { children: ReactNode }) => React.ReactElement | null;
|
|
32
|
+
PaymentStep: (props: {
|
|
33
|
+
children: ReactNode | ((p: OfferPaymentRenderProps) => ReactNode);
|
|
34
|
+
}) => React.ReactElement | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function OfferRoot({ children }: { children: ReactNode }) {
|
|
38
|
+
const { open, setOpen } = useOfferContext();
|
|
39
|
+
return (
|
|
40
|
+
<DialogRoot open={open} onOpenChange={setOpen}>
|
|
41
|
+
{children}
|
|
42
|
+
</DialogRoot>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Headless single-product purchase flow (details → redirect payment). */
|
|
47
|
+
export const Offer = (({ productId, finalisePath, onSuccess, children }: OfferProviderProps) => (
|
|
48
|
+
<OfferProvider productId={productId} finalisePath={finalisePath} onSuccess={onSuccess}>
|
|
49
|
+
<OfferRoot>{children}</OfferRoot>
|
|
50
|
+
</OfferProvider>
|
|
51
|
+
)) as OfferComponent;
|
|
52
|
+
|
|
53
|
+
Offer.Trigger = function OfferTrigger({ children, asChild }) {
|
|
54
|
+
return <DialogTrigger asChild={asChild}>{children}</DialogTrigger>;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
Offer.Modal = function OfferModal({ children, className, overlayClassName, style, overlayStyle }) {
|
|
58
|
+
return (
|
|
59
|
+
<DialogPortal>
|
|
60
|
+
<DialogOverlay className={overlayClassName} style={overlayStyle} />
|
|
61
|
+
<DialogContent className={className} style={style}>
|
|
62
|
+
{children}
|
|
63
|
+
</DialogContent>
|
|
64
|
+
</DialogPortal>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
Offer.Title = function OfferTitle({ children, className }) {
|
|
69
|
+
return <DialogTitle className={className}>{children}</DialogTitle>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
Offer.Close = function OfferClose({ children, asChild }) {
|
|
73
|
+
return <DialogClose asChild={asChild}>{children}</DialogClose>;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
Offer.DetailsStep = function OfferDetailsStep({ children }) {
|
|
77
|
+
const { step } = useOfferContext();
|
|
78
|
+
return step === "details" ? <>{children}</> : null;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
Offer.PaymentStep = function OfferPaymentStep({ children }) {
|
|
82
|
+
const ctx = useOfferContext();
|
|
83
|
+
if (ctx.step !== "payment") return null;
|
|
84
|
+
if (typeof children === "function") {
|
|
85
|
+
return <>{children({ clientSecret: ctx.clientSecret, returnUrl: ctx.returnUrl, amount: ctx.totalPrice })}</>;
|
|
86
|
+
}
|
|
87
|
+
return <>{children}</>;
|
|
88
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
|
|
2
|
+
import type { IPublicProduct, IPublicProductVariant } from "../../../types/models";
|
|
3
|
+
import { useGetProduct } from "../../../data/queries/useProducts";
|
|
4
|
+
import { useCreateOrder } from "../../../data/queries/useOrders";
|
|
5
|
+
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
6
|
+
|
|
7
|
+
export type OfferStep = "details" | "payment";
|
|
8
|
+
|
|
9
|
+
export interface OfferContextValue {
|
|
10
|
+
product: IPublicProduct | undefined;
|
|
11
|
+
isLoading: boolean;
|
|
12
|
+
|
|
13
|
+
open: boolean;
|
|
14
|
+
setOpen: (open: boolean) => void;
|
|
15
|
+
step: OfferStep;
|
|
16
|
+
|
|
17
|
+
variants: IPublicProductVariant[];
|
|
18
|
+
selectedVariantId: string;
|
|
19
|
+
setSelectedVariantId: (id: string) => void;
|
|
20
|
+
selectedVariant: IPublicProductVariant | undefined;
|
|
21
|
+
quantity: number;
|
|
22
|
+
setQuantity: (n: number) => void;
|
|
23
|
+
unitPrice: number;
|
|
24
|
+
totalPrice: number;
|
|
25
|
+
|
|
26
|
+
firstName: string;
|
|
27
|
+
setFirstName: (s: string) => void;
|
|
28
|
+
lastName: string;
|
|
29
|
+
setLastName: (s: string) => void;
|
|
30
|
+
email: string;
|
|
31
|
+
setEmail: (s: string) => void;
|
|
32
|
+
confirmEmail: string;
|
|
33
|
+
setConfirmEmail: (s: string) => void;
|
|
34
|
+
|
|
35
|
+
isProcessing: boolean;
|
|
36
|
+
error: string | null;
|
|
37
|
+
/** Payment intent once the order is created (Stripe). */
|
|
38
|
+
clientSecret?: string;
|
|
39
|
+
returnUrl: string;
|
|
40
|
+
continueToPayment: () => Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const OfferContext = createContext<OfferContextValue | null>(null);
|
|
44
|
+
|
|
45
|
+
export function useOfferContext(): OfferContextValue {
|
|
46
|
+
const ctx = useContext(OfferContext);
|
|
47
|
+
if (!ctx) throw new Error("Offer components must be used within <Offer>.");
|
|
48
|
+
return ctx;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface OfferProviderProps {
|
|
52
|
+
productId: string;
|
|
53
|
+
children: ReactNode;
|
|
54
|
+
/** Base used for the post-payment return URL. Defaults to /checkout/finalise. */
|
|
55
|
+
finalisePath?: string;
|
|
56
|
+
onSuccess?: () => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function OfferProvider({ productId, children, finalisePath }: OfferProviderProps) {
|
|
60
|
+
const { data: product, isLoading } = useGetProduct(productId);
|
|
61
|
+
const createOrder = useCreateOrder();
|
|
62
|
+
const flow = usePaymentFlow({ path: "/public/orders/start-payment", autoStart: false });
|
|
63
|
+
|
|
64
|
+
const [open, setOpen] = useState(false);
|
|
65
|
+
const [step, setStep] = useState<OfferStep>("details");
|
|
66
|
+
const variants = useMemo(() => product?.variants ?? [], [product?.variants]);
|
|
67
|
+
const [selectedVariantId, setSelectedVariantId] = useState("");
|
|
68
|
+
const [quantity, setQuantity] = useState(1);
|
|
69
|
+
const [firstName, setFirstName] = useState("");
|
|
70
|
+
const [lastName, setLastName] = useState("");
|
|
71
|
+
const [email, setEmail] = useState("");
|
|
72
|
+
const [confirmEmail, setConfirmEmail] = useState("");
|
|
73
|
+
const [error, setError] = useState<string | null>(null);
|
|
74
|
+
const [returnUrl, setReturnUrl] = useState("");
|
|
75
|
+
|
|
76
|
+
const selectedVariant = useMemo(
|
|
77
|
+
() => variants.find((v) => v.id === selectedVariantId) ?? variants[0],
|
|
78
|
+
[variants, selectedVariantId],
|
|
79
|
+
);
|
|
80
|
+
const unitPrice = selectedVariant?.price ?? 0;
|
|
81
|
+
const totalPrice = unitPrice * quantity;
|
|
82
|
+
|
|
83
|
+
const continueToPayment = async () => {
|
|
84
|
+
setError(null);
|
|
85
|
+
if (!product || !selectedVariant) return;
|
|
86
|
+
if (!firstName.trim() || !lastName.trim()) {
|
|
87
|
+
setError("Please enter your name.");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (!email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
91
|
+
setError("Please enter a valid email.");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (email !== confirmEmail) {
|
|
95
|
+
setError("Emails do not match.");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const created = await createOrder.mutateAsync({
|
|
100
|
+
amount: totalPrice,
|
|
101
|
+
email,
|
|
102
|
+
firstName,
|
|
103
|
+
lastName,
|
|
104
|
+
cartItems: [
|
|
105
|
+
{
|
|
106
|
+
productId: product.id,
|
|
107
|
+
productVariantId: selectedVariant.id,
|
|
108
|
+
quantity,
|
|
109
|
+
price: unitPrice,
|
|
110
|
+
title: product.title,
|
|
111
|
+
coverImage: product.media?.[0]?.url,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
const ru = `${typeof window !== "undefined" ? window.location.origin : ""}${finalisePath ?? "/checkout/finalise"}?orderId=${created.orderId}`;
|
|
116
|
+
setReturnUrl(ru);
|
|
117
|
+
// Free checkout — skip payment.
|
|
118
|
+
if (created.subTotal === 0 && typeof window !== "undefined") {
|
|
119
|
+
window.location.href = ru;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const result = await flow.start({ orderId: created.orderId, returnUrl: ru });
|
|
123
|
+
// Paystack redirects itself; Stripe shows the payment step.
|
|
124
|
+
if (result.provider && result.provider !== "stripe") return;
|
|
125
|
+
setStep("payment");
|
|
126
|
+
} catch (e) {
|
|
127
|
+
const message = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
128
|
+
setError(message || "Something went wrong.");
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const value: OfferContextValue = {
|
|
133
|
+
product,
|
|
134
|
+
isLoading,
|
|
135
|
+
open,
|
|
136
|
+
setOpen,
|
|
137
|
+
step,
|
|
138
|
+
variants,
|
|
139
|
+
selectedVariantId,
|
|
140
|
+
setSelectedVariantId,
|
|
141
|
+
selectedVariant,
|
|
142
|
+
quantity,
|
|
143
|
+
setQuantity,
|
|
144
|
+
unitPrice,
|
|
145
|
+
totalPrice,
|
|
146
|
+
firstName,
|
|
147
|
+
setFirstName,
|
|
148
|
+
lastName,
|
|
149
|
+
setLastName,
|
|
150
|
+
email,
|
|
151
|
+
setEmail,
|
|
152
|
+
confirmEmail,
|
|
153
|
+
setConfirmEmail,
|
|
154
|
+
isProcessing: createOrder.isPending || flow.isStarting,
|
|
155
|
+
error,
|
|
156
|
+
clientSecret: flow.result?.paymentSecret,
|
|
157
|
+
returnUrl,
|
|
158
|
+
continueToPayment,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return <OfferContext.Provider value={value}>{children}</OfferContext.Provider>;
|
|
162
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { usePaymentLink } from "../../../data/queries/usePaymentLink";
|
|
2
|
+
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
3
|
+
|
|
4
|
+
export interface UsePaymentLinkPaymentOptions {
|
|
5
|
+
/** Return path after payment. Default `/i/payment-link/:id/finalise`. */
|
|
6
|
+
returnPath?: string;
|
|
7
|
+
/** Start payment as soon as the link loads (default true). */
|
|
8
|
+
autoStart?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Headless payment-link checkout: loads the link and starts its payment.
|
|
13
|
+
* Composes `usePaymentLink` + `usePaymentFlow`. Stripe UI renders from
|
|
14
|
+
* `clientSecret`; finalize on the return page with `usePaymentLinkFinalize`.
|
|
15
|
+
*/
|
|
16
|
+
export function usePaymentLinkPayment(paymentLinkId?: string, opts: UsePaymentLinkPaymentOptions = {}) {
|
|
17
|
+
const link = usePaymentLink(paymentLinkId);
|
|
18
|
+
|
|
19
|
+
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
20
|
+
const returnUrl = paymentLinkId
|
|
21
|
+
? `${origin}${opts.returnPath ?? `/i/payment-link/${paymentLinkId}/finalise`}`
|
|
22
|
+
: "";
|
|
23
|
+
|
|
24
|
+
const flow = usePaymentFlow({
|
|
25
|
+
path: `/public/payment-links/${paymentLinkId}/start-payment`,
|
|
26
|
+
returnUrl,
|
|
27
|
+
autoStart: opts.autoStart ?? true,
|
|
28
|
+
enabled: !!paymentLinkId && !!link.data,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
link,
|
|
33
|
+
returnUrl,
|
|
34
|
+
clientSecret: flow.clientSecret,
|
|
35
|
+
paymentId: flow.paymentId,
|
|
36
|
+
provider: flow.provider,
|
|
37
|
+
/** Authoritative sales-tax quote from start-payment (display only). */
|
|
38
|
+
taxQuote: flow.result?.taxQuote ?? null,
|
|
39
|
+
isStarting: flow.isStarting,
|
|
40
|
+
error: flow.error
|
|
41
|
+
? ((flow.error as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
|
42
|
+
"Payment could not be started.")
|
|
43
|
+
: null,
|
|
44
|
+
pay: flow.start,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useMemo } from "react";
|
|
3
|
+
import { useAudioPlayer } from "../../../contexts/AudioPlayerContext";
|
|
4
|
+
import { episodeToTrack } from "../../../data/queries/usePodcast";
|
|
5
|
+
import type { BlogPost, BlogCategory } from "../../../types/models";
|
|
6
|
+
|
|
7
|
+
export interface UsePodcastPlayerOptions {
|
|
8
|
+
episodes: BlogPost[];
|
|
9
|
+
show?: BlogCategory | { title?: string; author?: string | null; coverImage?: { url: string } | null } | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PodcastPlayerState {
|
|
13
|
+
/** The episode currently loaded in the shared player, if it belongs to this show. */
|
|
14
|
+
currentEpisode: BlogPost | null;
|
|
15
|
+
isPlaying: boolean;
|
|
16
|
+
isLoading: boolean;
|
|
17
|
+
currentTime: number;
|
|
18
|
+
duration: number;
|
|
19
|
+
/** Load an episode + the whole show as the queue, and play. next/previous then walk episodes. */
|
|
20
|
+
playEpisode: (episode: BlogPost) => void;
|
|
21
|
+
/** Play/pause toggle for a given episode (loads it if it isn't the current track). */
|
|
22
|
+
toggleEpisode: (episode: BlogPost) => void;
|
|
23
|
+
isCurrent: (episode: BlogPost) => boolean;
|
|
24
|
+
isEpisodePlaying: (episode: BlogPost) => boolean;
|
|
25
|
+
playNext: () => void;
|
|
26
|
+
playPrevious: () => void;
|
|
27
|
+
seek: (time: number) => void;
|
|
28
|
+
hasNext: boolean;
|
|
29
|
+
hasPrevious: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A podcast player over the shared `AudioPlayerContext`. Loading an episode seeds
|
|
34
|
+
* the queue with the full show, so next/previous move between episodes and the
|
|
35
|
+
* page never has to navigate. Because the underlying player is a global provider,
|
|
36
|
+
* playback persists across route changes (episode ↔ show).
|
|
37
|
+
*/
|
|
38
|
+
export function usePodcastPlayer({ episodes, show }: UsePodcastPlayerOptions): PodcastPlayerState {
|
|
39
|
+
const a = useAudioPlayer();
|
|
40
|
+
|
|
41
|
+
const tracks = useMemo(
|
|
42
|
+
() => episodes.map((e) => episodeToTrack(e, show ?? null)).filter((t): t is NonNullable<typeof t> => !!t),
|
|
43
|
+
[episodes, show],
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const currentEpisode = useMemo(
|
|
47
|
+
() => (a.currentTrack ? episodes.find((e) => e.id === a.currentTrack!.id) ?? null : null),
|
|
48
|
+
[a.currentTrack, episodes],
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const playEpisode = useCallback(
|
|
52
|
+
(episode: BlogPost) => {
|
|
53
|
+
const track = episodeToTrack(episode, show ?? null);
|
|
54
|
+
if (!track) return;
|
|
55
|
+
a.loadAndPlay(track, tracks);
|
|
56
|
+
},
|
|
57
|
+
[a, tracks, show],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const isCurrent = useCallback((episode: BlogPost) => a.currentTrack?.id === episode.id, [a.currentTrack]);
|
|
61
|
+
|
|
62
|
+
const isEpisodePlaying = useCallback(
|
|
63
|
+
(episode: BlogPost) => a.currentTrack?.id === episode.id && a.isPlaying,
|
|
64
|
+
[a.currentTrack, a.isPlaying],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const toggleEpisode = useCallback(
|
|
68
|
+
(episode: BlogPost) => {
|
|
69
|
+
if (isCurrent(episode)) {
|
|
70
|
+
a.isPlaying ? a.pause() : a.play();
|
|
71
|
+
} else {
|
|
72
|
+
playEpisode(episode);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
[a, isCurrent, playEpisode],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const currentIndex = currentEpisode ? episodes.findIndex((e) => e.id === currentEpisode.id) : -1;
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
currentEpisode,
|
|
82
|
+
isPlaying: a.isPlaying,
|
|
83
|
+
isLoading: a.isLoading,
|
|
84
|
+
currentTime: a.currentTime,
|
|
85
|
+
duration: a.duration,
|
|
86
|
+
playEpisode,
|
|
87
|
+
toggleEpisode,
|
|
88
|
+
isCurrent,
|
|
89
|
+
isEpisodePlaying,
|
|
90
|
+
playNext: a.playNext,
|
|
91
|
+
playPrevious: a.playPrevious,
|
|
92
|
+
seek: a.seek,
|
|
93
|
+
hasNext: currentIndex >= 0 && currentIndex < episodes.length - 1,
|
|
94
|
+
hasPrevious: currentIndex > 0,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Member posts pillar — headless primitives (port of the craft members area's
|
|
2
|
+
// PostsContent/PostItem semantics; gating stays server-side).
|
|
3
|
+
export { useMemberPostsFeed, type UseMemberPostsFeedOptions } from "./useMemberPostsFeed";
|
|
4
|
+
export { usePostItem, type UsePostItemOptions, type PostBodyKind } from "./usePostItem";
|
|
5
|
+
export { usePostCollectionsList, type UsePostCollectionsListOptions } from "./usePostCollectionsList";
|
|
6
|
+
export { usePostCollectionDetail, type UsePostCollectionDetailOptions } from "./usePostCollectionDetail";
|
|
7
|
+
export {
|
|
8
|
+
buildPostUpsell,
|
|
9
|
+
DEFAULT_CHECKOUT_PATH,
|
|
10
|
+
DEFAULT_SIGNUP_PATH,
|
|
11
|
+
type PostUpsell,
|
|
12
|
+
type UpsellPathOptions,
|
|
13
|
+
} from "./upsell";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { IPublicPost, MembershipTier } from "../../../types/models";
|
|
2
|
+
|
|
3
|
+
/** Where a locked post/collection sends the member to unlock it. */
|
|
4
|
+
export interface PostUpsell {
|
|
5
|
+
/** The first tier that unlocks the content (server orders `membershipTiers`). */
|
|
6
|
+
tierId: string | null;
|
|
7
|
+
/** Resolved tier (name/price) when the tiers list is available. */
|
|
8
|
+
tier: MembershipTier | null;
|
|
9
|
+
/** Membership checkout deep link, e.g. `/membership/checkout?membershipTierId=…`. */
|
|
10
|
+
checkoutHref: string;
|
|
11
|
+
/** Signup-first link for anonymous visitors (redirects into checkout after signup). */
|
|
12
|
+
signupHref: string;
|
|
13
|
+
/** The link to actually use: checkout when authenticated, signup otherwise. */
|
|
14
|
+
href: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UpsellPathOptions {
|
|
18
|
+
/** Membership checkout path (default `/membership/checkout`; site-starter uses `/i/membership/checkout`). */
|
|
19
|
+
checkoutPath?: string;
|
|
20
|
+
/** Signup path (default `/signup`; site-starter uses `/i/signup`). */
|
|
21
|
+
signupPath?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_CHECKOUT_PATH = "/membership/checkout";
|
|
25
|
+
export const DEFAULT_SIGNUP_PATH = "/signup";
|
|
26
|
+
|
|
27
|
+
/** Build the locked-content upsell state for a post. Pure — shared by the feed + item hooks. */
|
|
28
|
+
export function buildPostUpsell(
|
|
29
|
+
post: Pick<IPublicPost, "membershipTiers">,
|
|
30
|
+
tiers: MembershipTier[] | undefined,
|
|
31
|
+
isAuthenticated: boolean,
|
|
32
|
+
opts: UpsellPathOptions = {},
|
|
33
|
+
): PostUpsell {
|
|
34
|
+
const checkoutPath = opts.checkoutPath ?? DEFAULT_CHECKOUT_PATH;
|
|
35
|
+
const signupPath = opts.signupPath ?? DEFAULT_SIGNUP_PATH;
|
|
36
|
+
const tierId = post.membershipTiers?.[0] ?? null;
|
|
37
|
+
const tier = (tierId && tiers?.find((t) => t.id === tierId)) || null;
|
|
38
|
+
const checkoutHref = tierId ? `${checkoutPath}?membershipTierId=${tierId}` : checkoutPath;
|
|
39
|
+
const signupHref = `${signupPath}?redirect=${encodeURIComponent(checkoutHref)}`;
|
|
40
|
+
return { tierId, tier, checkoutHref, signupHref, href: isAuthenticated ? checkoutHref : signupHref };
|
|
41
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { IPublicPost, MembershipTier, PostType } from "../../../types/models";
|
|
3
|
+
import { useGetPostsInfinite, useGetSavedPosts } from "../../../data/queries/usePosts";
|
|
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 UseMemberPostsFeedOptions extends UpsellPathOptions {
|
|
9
|
+
/** Posts per page (default 10). */
|
|
10
|
+
pageSize?: number;
|
|
11
|
+
/** Debounce for the search input → query round trip (default 500ms, matching the craft members area). */
|
|
12
|
+
searchDebounceMs?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Headless member posts feed — the Forge port of the craft members area's
|
|
17
|
+
* `PostsContent` semantics: gated infinite paging over `GET /public/posts`
|
|
18
|
+
* (the server computes per-post `hasAccess` and ships no media for locked
|
|
19
|
+
* posts — never re-gate client-side), debounced search, post-type filter,
|
|
20
|
+
* membership-tier filter, a saved-posts view (`GET /public/posts/saved`),
|
|
21
|
+
* and the locked-post upsell target (which tier unlocks + membership
|
|
22
|
+
* checkout deep link). Bring your own UI.
|
|
23
|
+
*/
|
|
24
|
+
export function useMemberPostsFeed(opts: UseMemberPostsFeedOptions = {}) {
|
|
25
|
+
const { isAuthenticated } = usePublicAuth();
|
|
26
|
+
|
|
27
|
+
// ── Filters ────────────────────────────────────────────────────────────────
|
|
28
|
+
const [search, setSearchState] = useState("");
|
|
29
|
+
const [debouncedSearch, setDebouncedSearch] = useState("");
|
|
30
|
+
const [type, setType] = useState<PostType | "">("");
|
|
31
|
+
const [membershipTierId, setMembershipTierId] = useState("");
|
|
32
|
+
const [savedOnly, setSavedOnly] = useState(false);
|
|
33
|
+
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
34
|
+
|
|
35
|
+
const setSearch = (value: string) => {
|
|
36
|
+
setSearchState(value);
|
|
37
|
+
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
|
38
|
+
debounceTimer.current = setTimeout(() => setDebouncedSearch(value), opts.searchDebounceMs ?? 500);
|
|
39
|
+
};
|
|
40
|
+
useEffect(
|
|
41
|
+
() => () => {
|
|
42
|
+
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
|
43
|
+
},
|
|
44
|
+
[],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const clearFilters = () => {
|
|
48
|
+
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
|
49
|
+
setSearchState("");
|
|
50
|
+
setDebouncedSearch("");
|
|
51
|
+
setType("");
|
|
52
|
+
setMembershipTierId("");
|
|
53
|
+
setSavedOnly(false);
|
|
54
|
+
};
|
|
55
|
+
const hasActiveFilters = !!(search || type || membershipTierId || savedOnly);
|
|
56
|
+
|
|
57
|
+
// ── Data ───────────────────────────────────────────────────────────────────
|
|
58
|
+
const { data: tiers } = useGetMembershipTiers();
|
|
59
|
+
const feed = useGetPostsInfinite({
|
|
60
|
+
query: debouncedSearch || undefined,
|
|
61
|
+
type: type || undefined,
|
|
62
|
+
membershipTierId: membershipTierId || undefined,
|
|
63
|
+
pageSize: opts.pageSize,
|
|
64
|
+
});
|
|
65
|
+
const saved = useGetSavedPosts(savedOnly && isAuthenticated);
|
|
66
|
+
|
|
67
|
+
const posts: IPublicPost[] = useMemo(
|
|
68
|
+
() => (savedOnly ? (saved.data?.data ?? []) : (feed.data?.pages.flatMap((p) => p.data) ?? [])),
|
|
69
|
+
[savedOnly, saved.data, feed.data],
|
|
70
|
+
);
|
|
71
|
+
const total = savedOnly ? (saved.data?.total ?? 0) : (feed.data?.pages[0]?.total ?? 0);
|
|
72
|
+
|
|
73
|
+
/** Accessible audio posts in feed order — the play queue for audio playback. */
|
|
74
|
+
const audioPosts = useMemo(
|
|
75
|
+
() => posts.filter((p) => p.type === "audio" && p.hasAccess && p.media?.[0]?.url),
|
|
76
|
+
[posts],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
/** Locked-post upsell: which tier unlocks it + the checkout/signup deep link. */
|
|
80
|
+
const getUpsell = (post: IPublicPost): PostUpsell => buildPostUpsell(post, tiers, isAuthenticated, opts);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
posts,
|
|
84
|
+
total,
|
|
85
|
+
audioPosts,
|
|
86
|
+
isLoading: savedOnly ? saved.isLoading : feed.isLoading,
|
|
87
|
+
isError: savedOnly ? saved.isError : feed.isError,
|
|
88
|
+
// Paging (the saved view is a single page).
|
|
89
|
+
hasMore: savedOnly ? false : !!feed.hasNextPage,
|
|
90
|
+
isLoadingMore: !savedOnly && feed.isFetchingNextPage,
|
|
91
|
+
loadMore: () => {
|
|
92
|
+
if (!savedOnly && feed.hasNextPage && !feed.isFetchingNextPage) feed.fetchNextPage();
|
|
93
|
+
},
|
|
94
|
+
// Filters.
|
|
95
|
+
search,
|
|
96
|
+
setSearch,
|
|
97
|
+
type,
|
|
98
|
+
setType,
|
|
99
|
+
membershipTierId,
|
|
100
|
+
setMembershipTierId,
|
|
101
|
+
savedOnly,
|
|
102
|
+
setSavedOnly,
|
|
103
|
+
hasActiveFilters,
|
|
104
|
+
clearFilters,
|
|
105
|
+
/** Tiers for the tier-filter UI (and locked-card tier names). */
|
|
106
|
+
tiers: (tiers ?? []) as MembershipTier[],
|
|
107
|
+
isAuthenticated,
|
|
108
|
+
getUpsell,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { IPublicPost } from "../../../types/models";
|
|
3
|
+
import { useGetPostCollection } 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 type UsePostCollectionDetailOptions = UpsellPathOptions;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Headless single-collection drill-in — the Forge port of the craft members
|
|
12
|
+
* area's `CollectionsContent` detail semantics over
|
|
13
|
+
* `GET /public/post-collections/:id`. The server gates per-collection AND
|
|
14
|
+
* per-post (`hasAccess`; locked posts ship no media), so a member who can see
|
|
15
|
+
* the collection may still have locked posts inside it. Exposes the
|
|
16
|
+
* collection, its posts normalized for `usePostItem` (the collection payload
|
|
17
|
+
* carries no engagement counts), the accessible-audio play queue, and the
|
|
18
|
+
* locked-collection upsell. Bring your own UI.
|
|
19
|
+
*/
|
|
20
|
+
export function usePostCollectionDetail(collectionId: string | undefined, opts: UsePostCollectionDetailOptions = {}) {
|
|
21
|
+
const { isAuthenticated } = usePublicAuth();
|
|
22
|
+
const { data: tiers } = useGetMembershipTiers();
|
|
23
|
+
const detailQ = useGetPostCollection(collectionId);
|
|
24
|
+
|
|
25
|
+
const collection = detailQ.data ?? null;
|
|
26
|
+
|
|
27
|
+
/** Collection posts normalized to full `IPublicPost`s (counts default 0 like the craft port). */
|
|
28
|
+
const posts: IPublicPost[] = useMemo(
|
|
29
|
+
() => (collection?.posts ?? []).map((post) => ({ ...post, likes: post.likes ?? 0, comments: post.comments ?? 0 })),
|
|
30
|
+
[collection],
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
/** Accessible audio posts in collection order — the play queue for audio playback. */
|
|
34
|
+
const audioPosts = useMemo(
|
|
35
|
+
() => posts.filter((p) => p.type === "audio" && p.hasAccess && p.media?.[0]?.url),
|
|
36
|
+
[posts],
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const hasAccess = collection?.hasAccess ?? false;
|
|
40
|
+
const cover = collection?.media?.[0] ?? null;
|
|
41
|
+
|
|
42
|
+
/** Locked-collection upsell (null until the collection loads). */
|
|
43
|
+
const upsell: PostUpsell | null = collection ? buildPostUpsell(collection, tiers, isAuthenticated, opts) : null;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
collection,
|
|
47
|
+
posts,
|
|
48
|
+
audioPosts,
|
|
49
|
+
cover,
|
|
50
|
+
hasAccess,
|
|
51
|
+
upsell,
|
|
52
|
+
isLoading: detailQ.isLoading,
|
|
53
|
+
isError: detailQ.isError,
|
|
54
|
+
isAuthenticated,
|
|
55
|
+
};
|
|
56
|
+
}
|