@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,115 @@
|
|
|
1
|
+
import type { MembershipTier } from "../../types/models";
|
|
2
|
+
import { useGetMembershipTiers } from "../../data/queries/useMembership";
|
|
3
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
4
|
+
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
5
|
+
import { priceTaxCaption } from "../format/PriceDisplay";
|
|
6
|
+
import { usePricesIncludeTax } from "../../data/queries/useWebsite";
|
|
7
|
+
import { Button } from "./Button";
|
|
8
|
+
import { Loading } from "./Loading";
|
|
9
|
+
|
|
10
|
+
export interface MembershipTiersProps {
|
|
11
|
+
/** Format a numeric amount for display. Defaults to `$X.XX`. */
|
|
12
|
+
formatAmount?: (amount: number) => string;
|
|
13
|
+
/**
|
|
14
|
+
* Where "Select" sends the buyer. The chosen tier id is appended as the
|
|
15
|
+
* `membershipTierId` query param. Default `/i/membership/checkout`.
|
|
16
|
+
*/
|
|
17
|
+
checkoutPath?: string;
|
|
18
|
+
/** Optional override for tier selection (e.g. router navigation). */
|
|
19
|
+
onSelect?: (tier: MembershipTier) => void;
|
|
20
|
+
/** Optional page heading. Pass `null` to omit. Default `"Membership"`. */
|
|
21
|
+
heading?: string | null;
|
|
22
|
+
className?: string;
|
|
23
|
+
style?: React.CSSProperties;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Drop-in membership page. Renders every membership tier as a centered,
|
|
28
|
+
* responsive card row (a single tier sits at a natural card width rather than
|
|
29
|
+
* stretching full-screen). "Select" routes to `checkoutPath` with the tier id,
|
|
30
|
+
* or calls `onSelect` when provided.
|
|
31
|
+
*/
|
|
32
|
+
export function MembershipTiers({
|
|
33
|
+
formatAmount,
|
|
34
|
+
checkoutPath = "/i/membership/checkout",
|
|
35
|
+
onSelect,
|
|
36
|
+
heading = "Membership",
|
|
37
|
+
className,
|
|
38
|
+
style,
|
|
39
|
+
}: MembershipTiersProps) {
|
|
40
|
+
const t = useThemeTokens();
|
|
41
|
+
const fmt = useAmountFormatter(formatAmount);
|
|
42
|
+
// Display-only: inclusive stores caption tier prices as tax-inclusive.
|
|
43
|
+
const pricesIncludeTax = usePricesIncludeTax();
|
|
44
|
+
const taxCaption = priceTaxCaption({ pricesIncludeTax });
|
|
45
|
+
const { data: tiers, isLoading } = useGetMembershipTiers();
|
|
46
|
+
|
|
47
|
+
if (isLoading) return <Loading fullPage />;
|
|
48
|
+
if (!tiers?.length) return <p style={{ color: t.text }}>No membership tiers available.</p>;
|
|
49
|
+
|
|
50
|
+
const priceLabel = (tier: MembershipTier) => {
|
|
51
|
+
if (tier.payWhatYouWant) {
|
|
52
|
+
return `Pay what you want from ${fmt(tier.payWhatYouWantMinimum ?? 0)}/mo`;
|
|
53
|
+
}
|
|
54
|
+
if (tier.priceMonthly) return `${fmt(tier.priceMonthly)}/mo`;
|
|
55
|
+
if (tier.priceYearly) return `${fmt(tier.priceYearly)}/yr`;
|
|
56
|
+
return "Free";
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const select = (tier: MembershipTier) => {
|
|
60
|
+
if (onSelect) return onSelect(tier);
|
|
61
|
+
if (typeof window !== "undefined") {
|
|
62
|
+
const sep = checkoutPath.includes("?") ? "&" : "?";
|
|
63
|
+
window.location.href = `${checkoutPath}${sep}membershipTierId=${encodeURIComponent(tier.id)}`;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className={className} style={{ color: t.text, fontFamily: t.fontFamily, ...style }}>
|
|
69
|
+
{heading && <h1 style={{ fontSize: 32, fontWeight: 800, marginBottom: 24, fontFamily: t.headingFontFamily }}>{heading}</h1>}
|
|
70
|
+
<div style={{ display: "flex", flexWrap: "wrap", gap: 20, justifyContent: "center" }}>
|
|
71
|
+
{tiers.map((tier) => (
|
|
72
|
+
<div
|
|
73
|
+
key={tier.id}
|
|
74
|
+
style={{
|
|
75
|
+
flex: "1 1 260px",
|
|
76
|
+
maxWidth: 340,
|
|
77
|
+
width: "100%",
|
|
78
|
+
background: t.surface,
|
|
79
|
+
border: `1px solid ${t.primary}30`,
|
|
80
|
+
borderRadius: t.cornerRadius,
|
|
81
|
+
padding: 24,
|
|
82
|
+
display: "flex",
|
|
83
|
+
flexDirection: "column",
|
|
84
|
+
gap: 12,
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
<h2 style={{ fontSize: 22, fontWeight: 700, fontFamily: t.headingFontFamily }}>{tier.name}</h2>
|
|
88
|
+
<div style={{ fontSize: 24, fontWeight: 800, color: t.primary }}>
|
|
89
|
+
{priceLabel(tier)}
|
|
90
|
+
{taxCaption && priceLabel(tier) !== "Free" && (
|
|
91
|
+
<span style={{ display: "block", fontSize: 12, fontWeight: 400, color: t.text, opacity: 0.65 }}>
|
|
92
|
+
{taxCaption}
|
|
93
|
+
</span>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
{tier.description && <p style={{ opacity: 0.75 }}>{tier.description}</p>}
|
|
97
|
+
{!!tier.benefits?.length && (
|
|
98
|
+
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 6 }}>
|
|
99
|
+
{tier.benefits.map((b) => (
|
|
100
|
+
<li key={b.id} style={{ display: "flex", gap: 8, opacity: 0.85 }}>
|
|
101
|
+
<span style={{ color: t.primary }}>✓</span>
|
|
102
|
+
{b.title}
|
|
103
|
+
</li>
|
|
104
|
+
))}
|
|
105
|
+
</ul>
|
|
106
|
+
)}
|
|
107
|
+
<Button fullWidth style={{ marginTop: "auto" }} onClick={() => select(tier)}>
|
|
108
|
+
Select
|
|
109
|
+
</Button>
|
|
110
|
+
</div>
|
|
111
|
+
))}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Offer, useOfferContext, type OfferPaymentRenderProps } from "../headless/offer";
|
|
3
|
+
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
|
+
import { Loading } from "./Loading";
|
|
5
|
+
import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
|
|
6
|
+
import { readableTextOn } from "../theme/contrast";
|
|
7
|
+
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
8
|
+
|
|
9
|
+
export interface OfferButtonProps {
|
|
10
|
+
productId: string;
|
|
11
|
+
text?: string;
|
|
12
|
+
finalisePath?: string;
|
|
13
|
+
onSuccess?: () => void;
|
|
14
|
+
/** Optional — falls back to a registered <ForgePaymentProvider>. */
|
|
15
|
+
renderPayment?: (props: OfferPaymentRenderProps) => ReactNode;
|
|
16
|
+
formatAmount?: (amount: number) => string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One-line themed product-purchase button + modal, built on the `<Offer>` compound. */
|
|
20
|
+
export function OfferButton({ productId, text, finalisePath, onSuccess, renderPayment, formatAmount }: OfferButtonProps) {
|
|
21
|
+
const theme = useForgeTheme();
|
|
22
|
+
const registered = usePaymentRenderer();
|
|
23
|
+
const fmt = useAmountFormatter(formatAmount);
|
|
24
|
+
const renderPay =
|
|
25
|
+
renderPayment ?? (registered ? (p: OfferPaymentRenderProps) => registered({ ...p, mode: "redirect" }) : undefined);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<Offer productId={productId} finalisePath={finalisePath} onSuccess={onSuccess}>
|
|
29
|
+
<Offer.Trigger asChild>
|
|
30
|
+
<button
|
|
31
|
+
style={{
|
|
32
|
+
background: theme.colors.primary,
|
|
33
|
+
color: readableTextOn(theme.colors.primary),
|
|
34
|
+
border: "none",
|
|
35
|
+
borderRadius: theme.cornerRadius,
|
|
36
|
+
padding: "10px 18px",
|
|
37
|
+
fontWeight: 600,
|
|
38
|
+
cursor: "pointer",
|
|
39
|
+
fontFamily: theme.fontFamily,
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
{text ?? "Buy now"}
|
|
43
|
+
</button>
|
|
44
|
+
</Offer.Trigger>
|
|
45
|
+
|
|
46
|
+
<Offer.Modal
|
|
47
|
+
overlayStyle={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 50 }}
|
|
48
|
+
style={{
|
|
49
|
+
position: "fixed",
|
|
50
|
+
top: "50%",
|
|
51
|
+
left: "50%",
|
|
52
|
+
transform: "translate(-50%, -50%)",
|
|
53
|
+
width: "min(480px, calc(100vw - 32px))",
|
|
54
|
+
maxHeight: "85vh",
|
|
55
|
+
overflowY: "auto",
|
|
56
|
+
background: theme.colors.background,
|
|
57
|
+
color: theme.colors.text,
|
|
58
|
+
borderRadius: theme.cornerRadius,
|
|
59
|
+
padding: 24,
|
|
60
|
+
zIndex: 51,
|
|
61
|
+
fontFamily: theme.fontFamily,
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
<OfferBody fmt={fmt} renderPayment={renderPay} />
|
|
65
|
+
</Offer.Modal>
|
|
66
|
+
</Offer>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function OfferBody({
|
|
71
|
+
fmt,
|
|
72
|
+
renderPayment,
|
|
73
|
+
}: {
|
|
74
|
+
fmt: (n: number) => string;
|
|
75
|
+
renderPayment?: (props: OfferPaymentRenderProps) => ReactNode;
|
|
76
|
+
}) {
|
|
77
|
+
const c = useOfferContext();
|
|
78
|
+
const theme = useForgeTheme();
|
|
79
|
+
const inputStyle = {
|
|
80
|
+
width: "100%",
|
|
81
|
+
padding: 10,
|
|
82
|
+
border: `1px solid ${theme.colors.primary}40`,
|
|
83
|
+
borderRadius: theme.cornerRadius,
|
|
84
|
+
background: theme.colors.background,
|
|
85
|
+
color: theme.colors.text,
|
|
86
|
+
} as const;
|
|
87
|
+
|
|
88
|
+
if (c.isLoading) return <Loading />;
|
|
89
|
+
if (!c.product) return <p style={{ marginTop: 8 }}>Product not found.</p>;
|
|
90
|
+
|
|
91
|
+
// ── Payment (redirect) ──
|
|
92
|
+
if (c.step === "payment") {
|
|
93
|
+
return (
|
|
94
|
+
<div>
|
|
95
|
+
<Offer.Title className="">{c.product.title}</Offer.Title>
|
|
96
|
+
<div style={{ display: "flex", justifyContent: "space-between", margin: "12px 0" }}>
|
|
97
|
+
<span>Total</span>
|
|
98
|
+
<strong style={{ color: theme.colors.primary }}>{fmt(c.totalPrice)}</strong>
|
|
99
|
+
</div>
|
|
100
|
+
{renderPayment ? (
|
|
101
|
+
<Offer.PaymentStep>{(p) => renderPayment(p)}</Offer.PaymentStep>
|
|
102
|
+
) : (
|
|
103
|
+
<p style={{ opacity: 0.7 }}>Payment is not configured.</p>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Details ──
|
|
110
|
+
return (
|
|
111
|
+
<Offer.DetailsStep>
|
|
112
|
+
<Offer.Title className="">{c.product.title}</Offer.Title>
|
|
113
|
+
<div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 12 }}>
|
|
114
|
+
{c.variants.length > 1 && (
|
|
115
|
+
<label>
|
|
116
|
+
Option
|
|
117
|
+
<select
|
|
118
|
+
value={c.selectedVariant?.id ?? ""}
|
|
119
|
+
onChange={(e) => c.setSelectedVariantId(e.target.value)}
|
|
120
|
+
style={inputStyle}
|
|
121
|
+
>
|
|
122
|
+
{c.variants.map((v) => (
|
|
123
|
+
<option key={v.id} value={v.id}>
|
|
124
|
+
{v.title || [v.color, v.size].filter(Boolean).join(" / ") || "Default"} — {fmt(v.price)}
|
|
125
|
+
</option>
|
|
126
|
+
))}
|
|
127
|
+
</select>
|
|
128
|
+
</label>
|
|
129
|
+
)}
|
|
130
|
+
|
|
131
|
+
<label>
|
|
132
|
+
Quantity
|
|
133
|
+
<select value={c.quantity} onChange={(e) => c.setQuantity(Number(e.target.value))} style={inputStyle}>
|
|
134
|
+
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((n) => (
|
|
135
|
+
<option key={n} value={n}>
|
|
136
|
+
{n}
|
|
137
|
+
</option>
|
|
138
|
+
))}
|
|
139
|
+
</select>
|
|
140
|
+
</label>
|
|
141
|
+
|
|
142
|
+
<div style={{ display: "flex", gap: 8 }}>
|
|
143
|
+
<input placeholder="First name" value={c.firstName} onChange={(e) => c.setFirstName(e.target.value)} style={inputStyle} />
|
|
144
|
+
<input placeholder="Last name" value={c.lastName} onChange={(e) => c.setLastName(e.target.value)} style={inputStyle} />
|
|
145
|
+
</div>
|
|
146
|
+
<input placeholder="Email" type="email" value={c.email} onChange={(e) => c.setEmail(e.target.value)} style={inputStyle} />
|
|
147
|
+
<input
|
|
148
|
+
placeholder="Confirm email"
|
|
149
|
+
type="email"
|
|
150
|
+
value={c.confirmEmail}
|
|
151
|
+
onChange={(e) => c.setConfirmEmail(e.target.value)}
|
|
152
|
+
style={inputStyle}
|
|
153
|
+
/>
|
|
154
|
+
|
|
155
|
+
<div style={{ display: "flex", justifyContent: "space-between", fontWeight: 600 }}>
|
|
156
|
+
<span>Total</span>
|
|
157
|
+
<span style={{ color: theme.colors.primary }}>{fmt(c.totalPrice)}</span>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{c.error && <p style={{ color: "#ef4444", fontSize: 14 }}>{c.error}</p>}
|
|
161
|
+
|
|
162
|
+
<button
|
|
163
|
+
onClick={c.continueToPayment}
|
|
164
|
+
disabled={c.isProcessing}
|
|
165
|
+
style={{
|
|
166
|
+
background: theme.colors.primary,
|
|
167
|
+
color: readableTextOn(theme.colors.primary),
|
|
168
|
+
border: "none",
|
|
169
|
+
borderRadius: theme.cornerRadius,
|
|
170
|
+
padding: "12px 18px",
|
|
171
|
+
fontWeight: 600,
|
|
172
|
+
cursor: "pointer",
|
|
173
|
+
opacity: c.isProcessing ? 0.6 : 1,
|
|
174
|
+
}}
|
|
175
|
+
>
|
|
176
|
+
{c.isProcessing ? "Processing…" : "Continue to payment"}
|
|
177
|
+
</button>
|
|
178
|
+
</div>
|
|
179
|
+
</Offer.DetailsStep>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { type CSSProperties } from "react";
|
|
2
|
+
import { usePageActions, type PageActionDescriptor } from "../../data/queries/usePageActions";
|
|
3
|
+
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
|
+
import { readableTextOn } from "../theme/contrast";
|
|
5
|
+
import { EmailListForm } from "./EmailListForm";
|
|
6
|
+
import { OfferButton } from "./OfferButton";
|
|
7
|
+
import { ProductGrid } from "./ProductGrid";
|
|
8
|
+
import { DonationButton } from "./DonationButton";
|
|
9
|
+
import { MembershipTiers } from "./MembershipTiers";
|
|
10
|
+
|
|
11
|
+
export interface PageActionsProps {
|
|
12
|
+
pageType: string;
|
|
13
|
+
entityId?: string;
|
|
14
|
+
placement?: string;
|
|
15
|
+
className?: string;
|
|
16
|
+
style?: CSSProperties;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function ActionRenderer({ action }: { action: PageActionDescriptor }) {
|
|
20
|
+
const theme = useForgeTheme();
|
|
21
|
+
const c = action.config as Record<string, any>;
|
|
22
|
+
|
|
23
|
+
switch (action.type) {
|
|
24
|
+
case "newsletter":
|
|
25
|
+
return (
|
|
26
|
+
<EmailListForm
|
|
27
|
+
emailListId={c.emailListId}
|
|
28
|
+
title={c.title}
|
|
29
|
+
description={c.description}
|
|
30
|
+
buttonText={c.buttonText}
|
|
31
|
+
successMessage={c.successMessage}
|
|
32
|
+
includeName={c.includeName}
|
|
33
|
+
includePhoneNumber={c.includePhoneNumber}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
case "lead_magnet":
|
|
37
|
+
return (
|
|
38
|
+
<EmailListForm
|
|
39
|
+
leadMagnetId={c.leadMagnetId}
|
|
40
|
+
emailListId={c.emailListId}
|
|
41
|
+
title={c.title}
|
|
42
|
+
description={c.description}
|
|
43
|
+
buttonText={c.buttonText}
|
|
44
|
+
successMessage={c.successMessage}
|
|
45
|
+
includeName={c.includeName}
|
|
46
|
+
includePhoneNumber={c.includePhoneNumber}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
case "offer":
|
|
50
|
+
return <OfferButton productId={c.productId} text={c.text} />;
|
|
51
|
+
case "product_cards":
|
|
52
|
+
return (
|
|
53
|
+
<div>
|
|
54
|
+
{c.title && <h3 style={{ fontWeight: 600, marginBottom: 12 }}>{c.title}</h3>}
|
|
55
|
+
<ProductGrid columns={c.columns} limit={c.limit} />
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
case "donation":
|
|
59
|
+
return <DonationButton donationId={c.donationId} text={c.text} />;
|
|
60
|
+
case "membership":
|
|
61
|
+
return <MembershipTiers heading={c.heading ?? null} />;
|
|
62
|
+
case "button":
|
|
63
|
+
return (
|
|
64
|
+
<a
|
|
65
|
+
href={c.href}
|
|
66
|
+
target={c.target ?? "_self"}
|
|
67
|
+
rel={c.target === "_blank" ? "noopener noreferrer" : undefined}
|
|
68
|
+
style={{
|
|
69
|
+
display: "inline-block",
|
|
70
|
+
background: theme.colors.primary,
|
|
71
|
+
color: theme.colors.textPrimary ?? readableTextOn(theme.colors.primary),
|
|
72
|
+
borderRadius: theme.cornerRadius,
|
|
73
|
+
padding: "10px 20px",
|
|
74
|
+
fontWeight: 600,
|
|
75
|
+
textDecoration: "none",
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
{c.text}
|
|
79
|
+
</a>
|
|
80
|
+
);
|
|
81
|
+
default:
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Compact CTAs (forms + single buttons) read better constrained; the product
|
|
87
|
+
// grid and membership tiers want the full width.
|
|
88
|
+
const COMPACT_TYPES = new Set<PageActionDescriptor["type"]>([
|
|
89
|
+
"newsletter",
|
|
90
|
+
"lead_magnet",
|
|
91
|
+
"donation",
|
|
92
|
+
"offer",
|
|
93
|
+
"button",
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
// SSR-safe responsive rule (no window/JS): compact items take at most half the
|
|
97
|
+
// width on desktop, full width on tablet/mobile.
|
|
98
|
+
const RESPONSIVE_CSS = `
|
|
99
|
+
.forge-pa-compact { width: 100%; }
|
|
100
|
+
@media (min-width: 768px) { .forge-pa-compact { max-width: 50%; } }
|
|
101
|
+
`;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Renders an artist's configured call-to-action blocks for a page slot. The one
|
|
105
|
+
* renderer shared across every Forge surface (apps/client, site-starter,
|
|
106
|
+
* AI-code-built sites) — each descriptor maps to an existing Forge primitive.
|
|
107
|
+
* Renders nothing when the slot is empty.
|
|
108
|
+
*/
|
|
109
|
+
export function PageActions({ pageType, entityId, placement = "after_content", className, style }: PageActionsProps) {
|
|
110
|
+
const { data } = usePageActions(pageType, entityId, placement);
|
|
111
|
+
const actions = data?.actions ?? [];
|
|
112
|
+
if (actions.length === 0) return null;
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div className={className} style={{ display: "flex", flexDirection: "column", gap: 32, textAlign: "left", ...style }}>
|
|
116
|
+
<style dangerouslySetInnerHTML={{ __html: RESPONSIVE_CSS }} />
|
|
117
|
+
{actions.map((action) => (
|
|
118
|
+
<div key={action.id} className={COMPACT_TYPES.has(action.type) ? "forge-pa-compact" : undefined}>
|
|
119
|
+
<ActionRenderer action={action} />
|
|
120
|
+
</div>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
2
|
+
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
3
|
+
import { Loading } from "./Loading";
|
|
4
|
+
import { usePaymentLinkFinalize } from "../../data/queries/usePaymentLink";
|
|
5
|
+
|
|
6
|
+
export interface PaymentLinkConfirmationProps {
|
|
7
|
+
paymentLinkId: string;
|
|
8
|
+
/**
|
|
9
|
+
* Formats a monetary amount for display. Receives the payment's own currency
|
|
10
|
+
* as a second argument so the shell can honor it (defaults to plain USD-style).
|
|
11
|
+
*/
|
|
12
|
+
formatAmount?: (n: number, currency?: string) => string;
|
|
13
|
+
/** Where the "back to payment" action links. Defaults to the payment-link page. */
|
|
14
|
+
paymentPath?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function PaymentLinkConfirmation({
|
|
18
|
+
paymentLinkId,
|
|
19
|
+
formatAmount,
|
|
20
|
+
paymentPath = `/i/payment-link/${paymentLinkId}`,
|
|
21
|
+
}: PaymentLinkConfirmationProps) {
|
|
22
|
+
const theme = useForgeTheme();
|
|
23
|
+
const fmt = useAmountFormatter(formatAmount);
|
|
24
|
+
const { data, isLoading, error } = usePaymentLinkFinalize(paymentLinkId);
|
|
25
|
+
|
|
26
|
+
if (isLoading) return <Loading fullPage />;
|
|
27
|
+
if (error || !data) return <p style={{ color: theme.colors.text }}>We couldn’t load this payment.</p>;
|
|
28
|
+
|
|
29
|
+
const paid = data.status === "paid" || !!data.paidAt;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div style={{ maxWidth: 520, margin: "0 auto", textAlign: "center", color: theme.colors.text }}>
|
|
33
|
+
<h1 style={{ fontSize: 28, fontWeight: 800, marginBottom: 8 }}>
|
|
34
|
+
{paid ? "Payment received" : "Payment not completed"}
|
|
35
|
+
</h1>
|
|
36
|
+
<p style={{ opacity: 0.75, marginBottom: 16 }}>
|
|
37
|
+
{paid
|
|
38
|
+
? `Thank you, ${data.recipientName}. Your payment to ${data.profileName} is complete.`
|
|
39
|
+
: `This payment is ${data.status}. If you were charged, please contact support.`}
|
|
40
|
+
</p>
|
|
41
|
+
<p style={{ fontWeight: 700, color: theme.colors.primary }}>{fmt(Number(data.amount), data.currency)}</p>
|
|
42
|
+
<a
|
|
43
|
+
href={paymentPath}
|
|
44
|
+
style={{ display: "inline-block", marginTop: 24, color: theme.colors.primary, textDecoration: "underline" }}
|
|
45
|
+
>
|
|
46
|
+
Back to payment
|
|
47
|
+
</a>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { usePaymentLinkPayment } from "../headless/paymentLink/usePaymentLinkPayment";
|
|
2
|
+
import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
|
|
3
|
+
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
|
+
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
5
|
+
import { summarizeTaxQuote } from "../format/PriceDisplay";
|
|
6
|
+
import { Loading } from "./Loading";
|
|
7
|
+
|
|
8
|
+
export interface PaymentLinkPaymentProps {
|
|
9
|
+
paymentLinkId: string;
|
|
10
|
+
/** Currency formatter (e.g. `(n) => formatCurrency(n, CURRENCY)`). Falls back to the link's own currency. */
|
|
11
|
+
formatAmount?: (n: number) => string;
|
|
12
|
+
/** Return path after payment. Forwarded to `usePaymentLinkPayment`. */
|
|
13
|
+
returnPath?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Themed payment-request view + payment surface, built on
|
|
18
|
+
* `usePaymentLinkPayment` and the app-registered payment renderer
|
|
19
|
+
* (`usePaymentRenderer`).
|
|
20
|
+
*/
|
|
21
|
+
export function PaymentLinkPayment({ paymentLinkId, formatAmount, returnPath }: PaymentLinkPaymentProps) {
|
|
22
|
+
const theme = useForgeTheme();
|
|
23
|
+
const fmt = useAmountFormatter(formatAmount);
|
|
24
|
+
const renderPayment = usePaymentRenderer();
|
|
25
|
+
const { link, clientSecret, returnUrl, isStarting, error, taxQuote } = usePaymentLinkPayment(paymentLinkId, {
|
|
26
|
+
returnPath,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (link.isLoading) return <Loading fullPage />;
|
|
30
|
+
if (link.error || !link.data) return <p style={{ color: theme.colors.text }}>Payment link not found.</p>;
|
|
31
|
+
|
|
32
|
+
const data = link.data;
|
|
33
|
+
const isPaid = data.status === "paid" || !!data.paidAt;
|
|
34
|
+
const money = (n: number) => fmt(n, data.currency);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div style={{ maxWidth: 520, margin: "0 auto", color: theme.colors.text }}>
|
|
38
|
+
<h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 4 }}>Payment request</h1>
|
|
39
|
+
<p style={{ opacity: 0.7, fontSize: 14, marginBottom: 16 }}>from {data.profileName}</p>
|
|
40
|
+
|
|
41
|
+
<div
|
|
42
|
+
style={{
|
|
43
|
+
border: `1px solid ${theme.colors.primary}20`,
|
|
44
|
+
borderRadius: theme.cornerRadius,
|
|
45
|
+
padding: 20,
|
|
46
|
+
marginBottom: 20,
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
<div style={{ fontSize: 32, fontWeight: 800, color: theme.colors.primary }}>
|
|
50
|
+
{money(Number(data.amount))}
|
|
51
|
+
{(() => {
|
|
52
|
+
// Authoritative tax quote from start-payment (display only).
|
|
53
|
+
const taxSummary = summarizeTaxQuote(taxQuote, money);
|
|
54
|
+
return taxSummary ? (
|
|
55
|
+
<span style={{ display: "block", fontSize: 13, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}>
|
|
56
|
+
{taxSummary.mode === "inclusive" ? taxSummary.label : `+ ${taxSummary.formattedTax} tax`}
|
|
57
|
+
</span>
|
|
58
|
+
) : null;
|
|
59
|
+
})()}
|
|
60
|
+
</div>
|
|
61
|
+
<div style={{ fontSize: 14, opacity: 0.8, marginTop: 8 }}>Billed to {data.recipientName}</div>
|
|
62
|
+
{data.message && <p style={{ marginTop: 12, lineHeight: 1.6, opacity: 0.9 }}>{data.message}</p>}
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
{isPaid ? (
|
|
66
|
+
<p style={{ fontWeight: 700, color: theme.colors.primary }}>This payment has been completed. Thank you!</p>
|
|
67
|
+
) : (
|
|
68
|
+
<div>
|
|
69
|
+
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>Payment</h2>
|
|
70
|
+
{error && <p style={{ color: "#ef4444", fontSize: 14, marginBottom: 8 }}>{error}</p>}
|
|
71
|
+
{clientSecret ? (
|
|
72
|
+
renderPayment?.({
|
|
73
|
+
clientSecret,
|
|
74
|
+
returnUrl,
|
|
75
|
+
amount: Number(data.amount),
|
|
76
|
+
mode: "redirect",
|
|
77
|
+
})
|
|
78
|
+
) : (
|
|
79
|
+
<>{isStarting ? <Loading label="Preparing payment…" size={22} /> : <p style={{ opacity: 0.7 }}>Payment is not available right now.</p>}</>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
2
|
+
import { readableTextOn } from "../theme/contrast";
|
|
3
|
+
|
|
4
|
+
export interface PaywallProps {
|
|
5
|
+
title?: string;
|
|
6
|
+
message?: string;
|
|
7
|
+
actionText?: string;
|
|
8
|
+
onAction?: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A themed "members only" card — pair with `<MembershipGate fallback={...}>`. */
|
|
12
|
+
export function Paywall({
|
|
13
|
+
title = "Members only",
|
|
14
|
+
message = "This content is available to members. Join to unlock it.",
|
|
15
|
+
actionText = "Become a member",
|
|
16
|
+
onAction,
|
|
17
|
+
}: PaywallProps) {
|
|
18
|
+
const theme = useForgeTheme();
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
style={{
|
|
22
|
+
border: `1px solid ${theme.colors.primary}30`,
|
|
23
|
+
borderRadius: theme.cornerRadius,
|
|
24
|
+
padding: 24,
|
|
25
|
+
textAlign: "center",
|
|
26
|
+
color: theme.colors.text,
|
|
27
|
+
background: theme.colors.background,
|
|
28
|
+
fontFamily: theme.fontFamily,
|
|
29
|
+
}}
|
|
30
|
+
>
|
|
31
|
+
<div style={{ fontSize: 24, marginBottom: 8 }} aria-hidden>
|
|
32
|
+
🔒
|
|
33
|
+
</div>
|
|
34
|
+
<h3 style={{ fontWeight: 600, marginBottom: 6 }}>{title}</h3>
|
|
35
|
+
<p style={{ opacity: 0.8, marginBottom: 16 }}>{message}</p>
|
|
36
|
+
{onAction && (
|
|
37
|
+
<button
|
|
38
|
+
onClick={onAction}
|
|
39
|
+
style={{
|
|
40
|
+
background: theme.colors.primary,
|
|
41
|
+
color: readableTextOn(theme.colors.primary),
|
|
42
|
+
border: "none",
|
|
43
|
+
borderRadius: theme.cornerRadius,
|
|
44
|
+
padding: "10px 18px",
|
|
45
|
+
fontWeight: 600,
|
|
46
|
+
cursor: "pointer",
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
{actionText}
|
|
50
|
+
</button>
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useAudioPlayer, type AudioTrack } from "../../contexts/AudioPlayerContext";
|
|
2
|
+
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
3
|
+
import { readableTextOn } from "../theme/contrast";
|
|
4
|
+
|
|
5
|
+
export interface PlayButtonProps {
|
|
6
|
+
track: AudioTrack;
|
|
7
|
+
/** Optional queue to continue with after this track. */
|
|
8
|
+
queue?: AudioTrack[];
|
|
9
|
+
label?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Play/pause toggle for a single track, bound to the Forge audio player. */
|
|
13
|
+
export function PlayButton({ track, queue, label }: PlayButtonProps) {
|
|
14
|
+
const theme = useForgeTheme();
|
|
15
|
+
const a = useAudioPlayer();
|
|
16
|
+
const isCurrent = a.currentTrack?.id === track.id;
|
|
17
|
+
const playing = isCurrent && a.isPlaying;
|
|
18
|
+
|
|
19
|
+
const onClick = () => {
|
|
20
|
+
if (isCurrent) {
|
|
21
|
+
playing ? a.pause() : a.play();
|
|
22
|
+
} else {
|
|
23
|
+
a.loadAndPlay(track, queue);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<button
|
|
29
|
+
onClick={onClick}
|
|
30
|
+
style={{
|
|
31
|
+
display: "inline-flex",
|
|
32
|
+
alignItems: "center",
|
|
33
|
+
gap: 8,
|
|
34
|
+
background: theme.colors.primary,
|
|
35
|
+
color: readableTextOn(theme.colors.primary),
|
|
36
|
+
border: "none",
|
|
37
|
+
borderRadius: theme.cornerRadius,
|
|
38
|
+
padding: "8px 14px",
|
|
39
|
+
fontWeight: 600,
|
|
40
|
+
cursor: "pointer",
|
|
41
|
+
fontFamily: theme.fontFamily,
|
|
42
|
+
}}
|
|
43
|
+
>
|
|
44
|
+
<span>{playing ? "⏸" : "▶"}</span>
|
|
45
|
+
{label ?? track.title}
|
|
46
|
+
</button>
|
|
47
|
+
);
|
|
48
|
+
}
|