@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,101 @@
|
|
|
1
|
+
import { useFeaturedBlogPost, useBlogPosts } from "../../data/queries/useBlog";
|
|
2
|
+
import type { BlogPost } from "../../types/models";
|
|
3
|
+
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
|
+
import { Loading } from "./Loading";
|
|
5
|
+
|
|
6
|
+
export interface BlogListProps {
|
|
7
|
+
/** Build the href for a blog post detail page from its slug. */
|
|
8
|
+
blogPostHref?: (slug: string) => string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Short date only, e.g. "Jun 30, 2026". */
|
|
12
|
+
function formatDate(value: string | number | Date): string {
|
|
13
|
+
return new Date(value).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function excerpt(html: string, len: number) {
|
|
17
|
+
return html.replace(/<[^>]+>/g, "").slice(0, len);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Blog index — featured post + grid of remaining posts. Built on `useFeaturedBlogPost` + `useBlogPosts`. */
|
|
21
|
+
export function BlogList({ blogPostHref = (slug) => `/i/blog/${slug}` }: BlogListProps = {}) {
|
|
22
|
+
const theme = useForgeTheme();
|
|
23
|
+
const { data: featured, isLoading: loadingFeatured } = useFeaturedBlogPost();
|
|
24
|
+
const { data, isLoading: loadingPosts } = useBlogPosts({ page: 1, limit: 12 });
|
|
25
|
+
|
|
26
|
+
if (loadingFeatured || loadingPosts) return <Loading fullPage />;
|
|
27
|
+
|
|
28
|
+
const posts = data?.data ?? [];
|
|
29
|
+
const regular = featured ? posts.filter((p) => p.id !== featured.id) : posts;
|
|
30
|
+
|
|
31
|
+
const card: React.CSSProperties = {
|
|
32
|
+
color: theme.colors.text,
|
|
33
|
+
textDecoration: "none",
|
|
34
|
+
border: `1px solid ${theme.colors.text}1a`,
|
|
35
|
+
borderRadius: theme.cornerRadius,
|
|
36
|
+
overflow: "hidden",
|
|
37
|
+
display: "block",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div style={{ maxWidth: 900, margin: "0 auto", color: theme.colors.text }}>
|
|
42
|
+
<h1 style={{ fontSize: 32, fontWeight: 800, marginBottom: 24 }}>Blog</h1>
|
|
43
|
+
|
|
44
|
+
{featured && (
|
|
45
|
+
<a href={blogPostHref(featured.slug)} style={{ ...card, marginBottom: 32 }}>
|
|
46
|
+
{featured.media?.[0]?.url && (
|
|
47
|
+
<img
|
|
48
|
+
src={featured.media[0].url}
|
|
49
|
+
alt={featured.title}
|
|
50
|
+
style={{ width: "100%", aspectRatio: "16/9", objectFit: "cover" }}
|
|
51
|
+
/>
|
|
52
|
+
)}
|
|
53
|
+
<div style={{ padding: 24 }}>
|
|
54
|
+
<div style={{ display: "flex", gap: 8, alignItems: "center", marginBottom: 8 }}>
|
|
55
|
+
<span
|
|
56
|
+
style={{
|
|
57
|
+
fontSize: 12,
|
|
58
|
+
padding: "2px 8px",
|
|
59
|
+
borderRadius: 4,
|
|
60
|
+
background: theme.colors.primary,
|
|
61
|
+
color: theme.colors.background,
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
Featured
|
|
65
|
+
</span>
|
|
66
|
+
{featured.publishedAt && (
|
|
67
|
+
<span style={{ fontSize: 13, opacity: 0.6 }}>{formatDate(featured.publishedAt)}</span>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
<h2 style={{ fontSize: 26, fontWeight: 700, marginBottom: 8 }}>{featured.title}</h2>
|
|
71
|
+
<p style={{ opacity: 0.7 }}>{excerpt(featured.content, 200)}…</p>
|
|
72
|
+
</div>
|
|
73
|
+
</a>
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
{regular.length ? (
|
|
77
|
+
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: 20 }}>
|
|
78
|
+
{regular.map((post: BlogPost) => {
|
|
79
|
+
const cover = post.media?.[0]?.url;
|
|
80
|
+
return (
|
|
81
|
+
<a key={post.id} href={blogPostHref(post.slug)} style={card}>
|
|
82
|
+
{cover && (
|
|
83
|
+
<img src={cover} alt={post.title} style={{ width: "100%", aspectRatio: "16/9", objectFit: "cover" }} />
|
|
84
|
+
)}
|
|
85
|
+
<div style={{ padding: 16 }}>
|
|
86
|
+
{post.publishedAt && (
|
|
87
|
+
<span style={{ fontSize: 12, opacity: 0.6 }}>{formatDate(post.publishedAt)}</span>
|
|
88
|
+
)}
|
|
89
|
+
<h3 style={{ fontSize: 18, fontWeight: 700, margin: "6px 0" }}>{post.title}</h3>
|
|
90
|
+
<p style={{ fontSize: 14, opacity: 0.7 }}>{excerpt(post.content, 120)}…</p>
|
|
91
|
+
</div>
|
|
92
|
+
</a>
|
|
93
|
+
);
|
|
94
|
+
})}
|
|
95
|
+
</div>
|
|
96
|
+
) : (
|
|
97
|
+
!featured && <p style={{ opacity: 0.7 }}>No blog posts available yet.</p>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { Play, Pause } from "lucide-react";
|
|
2
|
+
import { useBlogPost } from "../../data/queries/useBlog";
|
|
3
|
+
import { useAudioPlayer } from "../../contexts/AudioPlayerContext";
|
|
4
|
+
import type { BlogPost as BlogPostModel } from "../../types/models";
|
|
5
|
+
import { useForgeTheme, useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
6
|
+
import { Loading } from "./Loading";
|
|
7
|
+
// Baseline `.rich-text` content styles ship with Forge (WYSIWYG with the admin
|
|
8
|
+
// editor) — no host-loaded stylesheet needed.
|
|
9
|
+
import "./rich-text.css";
|
|
10
|
+
|
|
11
|
+
export interface BlogPostProps {
|
|
12
|
+
/** Post slug — the public API resolves the post by slug. */
|
|
13
|
+
slug: string;
|
|
14
|
+
/** Href for the "Back to Blog" link. */
|
|
15
|
+
blogHref?: string;
|
|
16
|
+
/** Server-fetched post to seed the query for SSR (no client loading flash). */
|
|
17
|
+
initialPost?: BlogPostModel | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Short date only, e.g. "Jun 30, 2026". */
|
|
21
|
+
function formatDate(value: string | number | Date): string {
|
|
22
|
+
return new Date(value).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** "m:ss" / "h:mm:ss" from seconds; empty string when unknown. */
|
|
26
|
+
function formatDuration(seconds: number | null | undefined): string {
|
|
27
|
+
if (!seconds || seconds <= 0) return "";
|
|
28
|
+
const h = Math.floor(seconds / 3600);
|
|
29
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
30
|
+
const s = Math.floor(seconds % 60);
|
|
31
|
+
const pad = (n: number) => n.toString().padStart(2, "0");
|
|
32
|
+
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Play/pause bar for a post's audio attachment (audio posts / podcast-style
|
|
37
|
+
* episodes). Plays through the shared Forge audio player, so playback persists
|
|
38
|
+
* in the global `<AudioPlayer />` bar while browsing.
|
|
39
|
+
*/
|
|
40
|
+
function AudioAttachmentBar({
|
|
41
|
+
post,
|
|
42
|
+
audio,
|
|
43
|
+
coverImage,
|
|
44
|
+
}: {
|
|
45
|
+
post: BlogPostModel;
|
|
46
|
+
audio: BlogPostModel["media"][number];
|
|
47
|
+
coverImage?: string;
|
|
48
|
+
}) {
|
|
49
|
+
const t = useThemeTokens();
|
|
50
|
+
const player = useAudioPlayer();
|
|
51
|
+
|
|
52
|
+
const isCurrent = player.currentTrack?.id === post.id;
|
|
53
|
+
const isPlaying = isCurrent && player.isPlaying;
|
|
54
|
+
const duration = formatDuration(audio.durationSec);
|
|
55
|
+
|
|
56
|
+
const onClick = () => {
|
|
57
|
+
if (!audio.url) return;
|
|
58
|
+
if (isCurrent) {
|
|
59
|
+
if (isPlaying) player.pause();
|
|
60
|
+
else player.play();
|
|
61
|
+
} else {
|
|
62
|
+
player.loadAndPlay({ id: post.id, title: post.title, url: audio.url, coverImage });
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div
|
|
68
|
+
style={{
|
|
69
|
+
display: "flex",
|
|
70
|
+
alignItems: "center",
|
|
71
|
+
gap: 16,
|
|
72
|
+
padding: 16,
|
|
73
|
+
marginBottom: 24,
|
|
74
|
+
border: `1px solid ${t.border}`,
|
|
75
|
+
borderRadius: t.cornerRadius,
|
|
76
|
+
background: t.surface,
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
onClick={onClick}
|
|
82
|
+
aria-label={isPlaying ? "Pause" : "Play"}
|
|
83
|
+
style={{
|
|
84
|
+
display: "inline-flex",
|
|
85
|
+
alignItems: "center",
|
|
86
|
+
gap: 8,
|
|
87
|
+
padding: "10px 20px",
|
|
88
|
+
borderRadius: 999,
|
|
89
|
+
border: "none",
|
|
90
|
+
cursor: "pointer",
|
|
91
|
+
fontWeight: 600,
|
|
92
|
+
background: t.primary,
|
|
93
|
+
color: t.textPrimary,
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
|
|
97
|
+
{isPlaying ? "Pause" : "Play"}
|
|
98
|
+
</button>
|
|
99
|
+
{duration && <span style={{ fontSize: 14, opacity: 0.6, fontVariantNumeric: "tabular-nums" }}>{duration}</span>}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Blog post detail, resolved by slug. Built on `useBlogPost`. */
|
|
105
|
+
export function BlogPost({ slug, blogHref = "/i/blog", initialPost }: BlogPostProps) {
|
|
106
|
+
const theme = useForgeTheme();
|
|
107
|
+
// Detail is resolved by slug (the public API looks posts up by slug).
|
|
108
|
+
const { data: post, isLoading } = useBlogPost(slug, { initialData: initialPost });
|
|
109
|
+
|
|
110
|
+
if (isLoading) return <Loading fullPage />;
|
|
111
|
+
if (!post) return <p>Post not found.</p>;
|
|
112
|
+
|
|
113
|
+
// Cover is the first image media — audio posts put the audio file first in
|
|
114
|
+
// `media`, which must not be rendered as an <img>. `type` may be a bare kind
|
|
115
|
+
// ("image") or a mime ("image/jpeg"), so match by prefix.
|
|
116
|
+
const cover = post.media?.find((m) => (m.type || "").startsWith("image"))?.url;
|
|
117
|
+
// An audio attachment (audio posts / podcast-style episodes) gets a play bar.
|
|
118
|
+
const audio = post.media?.find((m) => (m.type || "").startsWith("audio"));
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<article style={{ maxWidth: 720, margin: "0 auto", color: theme.colors.text }}>
|
|
122
|
+
<a href={blogHref} style={{ color: theme.colors.primary, fontSize: 14 }}>
|
|
123
|
+
← Back to Blog
|
|
124
|
+
</a>
|
|
125
|
+
|
|
126
|
+
{cover && (
|
|
127
|
+
<img
|
|
128
|
+
src={cover}
|
|
129
|
+
alt={post.title}
|
|
130
|
+
style={{ width: "100%", borderRadius: theme.cornerRadius, margin: "24px 0", objectFit: "cover" }}
|
|
131
|
+
/>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
<div style={{ display: "flex", gap: 12, alignItems: "center", margin: "16px 0" }}>
|
|
135
|
+
{post.publishedAt && <span style={{ fontSize: 14, opacity: 0.6 }}>{formatDate(post.publishedAt)}</span>}
|
|
136
|
+
{post.isFeatured && (
|
|
137
|
+
<span
|
|
138
|
+
style={{
|
|
139
|
+
fontSize: 12,
|
|
140
|
+
padding: "2px 8px",
|
|
141
|
+
borderRadius: 4,
|
|
142
|
+
background: theme.colors.primary,
|
|
143
|
+
color: theme.colors.background,
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
Featured
|
|
147
|
+
</span>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<h1 style={{ fontSize: 34, fontWeight: 800, marginBottom: 24 }}>{post.title}</h1>
|
|
152
|
+
|
|
153
|
+
{audio && <AudioAttachmentBar post={post} audio={audio} coverImage={cover} />}
|
|
154
|
+
|
|
155
|
+
{/* `rich-text` = shared baseline content styles (WYSIWYG with the editor),
|
|
156
|
+
bundled with Forge via the ./rich-text.css import above. */}
|
|
157
|
+
<div className="rich-text" dangerouslySetInnerHTML={{ __html: post.content || "" }} />
|
|
158
|
+
</article>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type CSSProperties } from "react";
|
|
2
|
+
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
3
|
+
import { PageActions } from "./PageActions";
|
|
4
|
+
import { BlogComments } from "./BlogComments";
|
|
5
|
+
|
|
6
|
+
export interface BlogPostFooterProps {
|
|
7
|
+
blogPostId: string;
|
|
8
|
+
/** Where "Sign in to comment" links when the profile requires sign-in. */
|
|
9
|
+
signInHref?: string;
|
|
10
|
+
className?: string;
|
|
11
|
+
style?: CSSProperties;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The single drop-in for everything rendered after a blog post's body — the
|
|
16
|
+
* artist's configured page actions (CTAs) followed by the comments section.
|
|
17
|
+
* Owns its own spacing/divider so BOTH the customer app and site-starter (and
|
|
18
|
+
* AI-built sites) mount exactly this one component with no per-app wrappers,
|
|
19
|
+
* guaranteeing identical layout.
|
|
20
|
+
*/
|
|
21
|
+
export function BlogPostFooter({ blogPostId, signInHref, className, style }: BlogPostFooterProps) {
|
|
22
|
+
const theme = useForgeTheme();
|
|
23
|
+
const border = theme.colors.border ?? `${theme.colors.primary}33`;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className={className} style={{ marginTop: 48, textAlign: "left", ...style }}>
|
|
27
|
+
<PageActions pageType="blog_post" entityId={blogPostId} />
|
|
28
|
+
<div style={{ marginTop: 48, paddingTop: 32, borderTop: `1px solid ${border}` }}>
|
|
29
|
+
<BlogComments blogPostId={blogPostId} signInHref={signInHref} />
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
3
|
+
import { useSiteConfig } from "../../data/queries/useWebsite";
|
|
4
|
+
|
|
5
|
+
export interface BotFields {
|
|
6
|
+
/** Altcha proof-of-work payload (base64), empty until solved / when disabled. */
|
|
7
|
+
botToken: string;
|
|
8
|
+
/** Honeypot value — always "" for a human; any value marks a bot. */
|
|
9
|
+
botField: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const EMPTY_BOT_FIELDS: BotFields = { botToken: "", botField: "" };
|
|
13
|
+
|
|
14
|
+
type Challenge = {
|
|
15
|
+
algorithm: string;
|
|
16
|
+
challenge: string;
|
|
17
|
+
salt: string;
|
|
18
|
+
signature: string;
|
|
19
|
+
maxnumber: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Brute-force the proof-of-work: find n where SHA-256(salt + n) === challenge.
|
|
23
|
+
async function solvePow(salt: string, target: string, maxnumber: number): Promise<number | null> {
|
|
24
|
+
const enc = new TextEncoder();
|
|
25
|
+
for (let n = 0; n <= maxnumber; n++) {
|
|
26
|
+
const digest = await crypto.subtle.digest("SHA-256", enc.encode(salt + n));
|
|
27
|
+
const hex = Array.from(new Uint8Array(digest))
|
|
28
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
29
|
+
.join("");
|
|
30
|
+
if (hex === target) return n;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Drop-in bot protection for public forms — a hidden honeypot plus (when the
|
|
37
|
+
* platform has Altcha enabled) an invisible proof-of-work challenge solved in
|
|
38
|
+
* the browser. Reports the fields up via `onChange`; spread them into the form's
|
|
39
|
+
* submit body ({ botToken, botField }). SSR-safe (the PoW runs client-only). One
|
|
40
|
+
* self-hosted verifier works across every tenant subdomain + custom domain — no
|
|
41
|
+
* per-domain config, no external service.
|
|
42
|
+
*/
|
|
43
|
+
export function BotProtection({ onChange }: { onChange: (fields: BotFields) => void }) {
|
|
44
|
+
const { apiUrl } = useForge();
|
|
45
|
+
const { data: siteConfig } = useSiteConfig();
|
|
46
|
+
const altchaEnabled = siteConfig?.altchaEnabled ?? false;
|
|
47
|
+
|
|
48
|
+
const [token, setToken] = useState("");
|
|
49
|
+
const [honeypot, setHoneypot] = useState("");
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
onChange({ botToken: token, botField: honeypot });
|
|
53
|
+
}, [token, honeypot, onChange]);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!altchaEnabled || typeof window === "undefined" || !apiUrl) return;
|
|
57
|
+
let cancelled = false;
|
|
58
|
+
(async () => {
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/public/altcha/challenge`);
|
|
61
|
+
if (!res.ok) return;
|
|
62
|
+
const ch = (await res.json()) as Partial<Challenge>;
|
|
63
|
+
if (!ch.challenge || !ch.salt || !ch.signature || ch.maxnumber == null) return; // disabled
|
|
64
|
+
const number = await solvePow(ch.salt, ch.challenge, ch.maxnumber);
|
|
65
|
+
if (cancelled || number == null) return;
|
|
66
|
+
const payload = btoa(
|
|
67
|
+
JSON.stringify({
|
|
68
|
+
algorithm: ch.algorithm,
|
|
69
|
+
challenge: ch.challenge,
|
|
70
|
+
number,
|
|
71
|
+
salt: ch.salt,
|
|
72
|
+
signature: ch.signature,
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
setToken(payload);
|
|
76
|
+
} catch {
|
|
77
|
+
// Solve failures leave botToken empty; the server decides whether to
|
|
78
|
+
// enforce. Never block form render on bot protection.
|
|
79
|
+
}
|
|
80
|
+
})();
|
|
81
|
+
return () => {
|
|
82
|
+
cancelled = true;
|
|
83
|
+
};
|
|
84
|
+
}, [altchaEnabled, apiUrl]);
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<>
|
|
88
|
+
{/* Honeypot — off-screen, not announced, skipped by keyboard/autofill. */}
|
|
89
|
+
<input
|
|
90
|
+
type="text"
|
|
91
|
+
name="botField"
|
|
92
|
+
tabIndex={-1}
|
|
93
|
+
autoComplete="off"
|
|
94
|
+
aria-hidden="true"
|
|
95
|
+
value={honeypot}
|
|
96
|
+
onChange={(e) => setHoneypot(e.target.value)}
|
|
97
|
+
style={{ position: "absolute", left: "-9999px", top: "auto", width: 1, height: 1, opacity: 0 }}
|
|
98
|
+
/>
|
|
99
|
+
</>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type ButtonHTMLAttributes, type CSSProperties, forwardRef } from "react";
|
|
2
|
+
import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
|
|
3
|
+
import { readableTextOn } from "../theme/contrast";
|
|
4
|
+
|
|
5
|
+
export type ButtonVariant = "primary" | "secondary" | "outline" | "ghost";
|
|
6
|
+
export type ButtonSize = "sm" | "md" | "lg";
|
|
7
|
+
|
|
8
|
+
const alpha = (hex: string, a: number) => hex + Math.round(a * 255).toString(16).padStart(2, "0");
|
|
9
|
+
|
|
10
|
+
const SIZES: Record<ButtonSize, { padding: string; fontSize: number }> = {
|
|
11
|
+
sm: { padding: "8px 14px", fontSize: 14 },
|
|
12
|
+
md: { padding: "12px 18px", fontSize: 15 },
|
|
13
|
+
lg: { padding: "14px 22px", fontSize: 16 },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The style object for a themed button — exported so existing styled blocks can
|
|
18
|
+
* adopt it without swapping their markup. Primary buttons use a contrast-aware
|
|
19
|
+
* text color (never a hardcoded #fff on the tenant primary).
|
|
20
|
+
*/
|
|
21
|
+
export function buttonStyle(
|
|
22
|
+
t: ForgeTheme,
|
|
23
|
+
opts: { variant?: ButtonVariant; size?: ButtonSize; fullWidth?: boolean; disabled?: boolean } = {},
|
|
24
|
+
): CSSProperties {
|
|
25
|
+
const { variant = "primary", size = "md", fullWidth, disabled } = opts;
|
|
26
|
+
const s = SIZES[size];
|
|
27
|
+
const base: CSSProperties = {
|
|
28
|
+
display: "inline-flex",
|
|
29
|
+
alignItems: "center",
|
|
30
|
+
justifyContent: "center",
|
|
31
|
+
gap: 8,
|
|
32
|
+
width: fullWidth ? "100%" : undefined,
|
|
33
|
+
padding: s.padding,
|
|
34
|
+
fontSize: s.fontSize,
|
|
35
|
+
fontWeight: 600,
|
|
36
|
+
lineHeight: 1.2,
|
|
37
|
+
borderRadius: t.cornerRadius,
|
|
38
|
+
fontFamily: t.fontFamily,
|
|
39
|
+
cursor: disabled ? "not-allowed" : "pointer",
|
|
40
|
+
opacity: disabled ? 0.6 : 1,
|
|
41
|
+
transition: "opacity .15s ease, background .15s ease",
|
|
42
|
+
textDecoration: "none",
|
|
43
|
+
boxSizing: "border-box",
|
|
44
|
+
};
|
|
45
|
+
switch (variant) {
|
|
46
|
+
case "secondary":
|
|
47
|
+
return { ...base, background: alpha(t.colors.primary, 0.12), color: t.colors.text, border: "none" };
|
|
48
|
+
case "outline":
|
|
49
|
+
return { ...base, background: "transparent", color: t.colors.text, border: `1px solid ${alpha(t.colors.primary, 0.5)}` };
|
|
50
|
+
case "ghost":
|
|
51
|
+
return { ...base, background: "transparent", color: t.colors.primary, border: "none" };
|
|
52
|
+
case "primary":
|
|
53
|
+
default:
|
|
54
|
+
// Creator's chosen "text on primary" wins; auto-contrast is the fallback.
|
|
55
|
+
return {
|
|
56
|
+
...base,
|
|
57
|
+
background: t.colors.primary,
|
|
58
|
+
color: t.colors.textPrimary || readableTextOn(t.colors.primary),
|
|
59
|
+
border: "none",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
65
|
+
variant?: ButtonVariant;
|
|
66
|
+
size?: ButtonSize;
|
|
67
|
+
fullWidth?: boolean;
|
|
68
|
+
loading?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Unified themed button — drop-in from `@tribe-nest/forge/ui`. Contrast-safe. */
|
|
72
|
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
|
73
|
+
{ variant, size, fullWidth, loading, disabled, style, children, ...rest },
|
|
74
|
+
ref,
|
|
75
|
+
) {
|
|
76
|
+
const theme = useForgeTheme();
|
|
77
|
+
const isDisabled = disabled || loading;
|
|
78
|
+
return (
|
|
79
|
+
<button
|
|
80
|
+
ref={ref}
|
|
81
|
+
disabled={isDisabled}
|
|
82
|
+
style={{ ...buttonStyle(theme, { variant, size, fullWidth, disabled: isDisabled }), ...style }}
|
|
83
|
+
{...rest}
|
|
84
|
+
>
|
|
85
|
+
{loading ? "…" : children}
|
|
86
|
+
</button>
|
|
87
|
+
);
|
|
88
|
+
});
|