@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,81 @@
|
|
|
1
|
+
import { useContactForm } from "../headless/forms/useContactForm";
|
|
2
|
+
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
3
|
+
import { readableTextOn } from "../theme/contrast";
|
|
4
|
+
|
|
5
|
+
export interface ContactFormProps {
|
|
6
|
+
buttonText?: string;
|
|
7
|
+
successMessage?: string;
|
|
8
|
+
showJoinEmailList?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** One-line themed website contact form, built on `useContactForm`. */
|
|
12
|
+
export function ContactForm({
|
|
13
|
+
buttonText = "Send Message",
|
|
14
|
+
successMessage = "Message sent — we'll be in touch.",
|
|
15
|
+
showJoinEmailList = true,
|
|
16
|
+
}: ContactFormProps) {
|
|
17
|
+
const theme = useForgeTheme();
|
|
18
|
+
const f = useContactForm();
|
|
19
|
+
|
|
20
|
+
const inputStyle = {
|
|
21
|
+
width: "100%",
|
|
22
|
+
padding: 10,
|
|
23
|
+
border: `1px solid ${theme.colors.primary}40`,
|
|
24
|
+
borderRadius: theme.cornerRadius,
|
|
25
|
+
background: theme.colors.background,
|
|
26
|
+
color: theme.colors.text,
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
if (f.isSuccess) {
|
|
30
|
+
return (
|
|
31
|
+
<div style={{ color: theme.colors.text, fontFamily: theme.fontFamily, textAlign: "center", padding: 8 }}>
|
|
32
|
+
{successMessage}
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 10, color: theme.colors.text, fontFamily: theme.fontFamily }}>
|
|
39
|
+
<input placeholder="Your name" value={f.name} onChange={(e) => f.setName(e.target.value)} style={inputStyle} />
|
|
40
|
+
{f.errors.name && <p style={{ color: "#ef4444", fontSize: 13 }}>{f.errors.name}</p>}
|
|
41
|
+
|
|
42
|
+
<input type="email" placeholder="Email" value={f.email} onChange={(e) => f.setEmail(e.target.value)} style={inputStyle} />
|
|
43
|
+
{f.errors.email && <p style={{ color: "#ef4444", fontSize: 13 }}>{f.errors.email}</p>}
|
|
44
|
+
|
|
45
|
+
<textarea
|
|
46
|
+
placeholder="Your message"
|
|
47
|
+
value={f.message}
|
|
48
|
+
onChange={(e) => f.setMessage(e.target.value)}
|
|
49
|
+
rows={4}
|
|
50
|
+
style={inputStyle}
|
|
51
|
+
/>
|
|
52
|
+
{f.errors.message && <p style={{ color: "#ef4444", fontSize: 13 }}>{f.errors.message}</p>}
|
|
53
|
+
|
|
54
|
+
{showJoinEmailList && (
|
|
55
|
+
<label style={{ display: "flex", gap: 8, alignItems: "center", fontSize: 14 }}>
|
|
56
|
+
<input type="checkbox" checked={f.joinEmailList} onChange={(e) => f.setJoinEmailList(e.target.checked)} />
|
|
57
|
+
Also join the email list
|
|
58
|
+
</label>
|
|
59
|
+
)}
|
|
60
|
+
|
|
61
|
+
{f.errors.form && <p style={{ color: "#ef4444", fontSize: 14 }}>{f.errors.form}</p>}
|
|
62
|
+
|
|
63
|
+
<button
|
|
64
|
+
onClick={f.submit}
|
|
65
|
+
disabled={f.isSubmitting}
|
|
66
|
+
style={{
|
|
67
|
+
background: theme.colors.primary,
|
|
68
|
+
color: readableTextOn(theme.colors.primary),
|
|
69
|
+
border: "none",
|
|
70
|
+
borderRadius: theme.cornerRadius,
|
|
71
|
+
padding: "10px 18px",
|
|
72
|
+
fontWeight: 600,
|
|
73
|
+
cursor: "pointer",
|
|
74
|
+
opacity: f.isSubmitting ? 0.6 : 1,
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
{f.isSubmitting ? "Sending…" : buttonText}
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { X } from "lucide-react";
|
|
3
|
+
import { useCookieConsent } from "../headless/consent/useCookieConsent";
|
|
4
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
5
|
+
import { readableTextOn } from "../theme/contrast";
|
|
6
|
+
|
|
7
|
+
export interface CookieConsentProps {
|
|
8
|
+
/** Heading text. */
|
|
9
|
+
title?: string;
|
|
10
|
+
/** Body copy above the toggles. */
|
|
11
|
+
description?: React.ReactNode;
|
|
12
|
+
/** Show the Performance/analytics toggle. Default `true`. Set `false` when
|
|
13
|
+
* first-party analytics runs cookieless (it needs no storage consent), so the
|
|
14
|
+
* banner only governs Marketing. */
|
|
15
|
+
showPerformance?: boolean;
|
|
16
|
+
/** Link to the site's own privacy policy (rendered after the description). */
|
|
17
|
+
privacyPolicyHref?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Fall back to TribeNest's hosted privacy policy
|
|
20
|
+
* (https://www.tribenest.co/privacy) when the site doesn't have its own.
|
|
21
|
+
* Ignored if `privacyPolicyHref` is set. Opens in a new tab.
|
|
22
|
+
*/
|
|
23
|
+
useTribeNestPrivacy?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Controlled visibility. Leave undefined for the default behaviour (show once
|
|
26
|
+
* until the visitor decides). Set `true` to force it open again so a visitor
|
|
27
|
+
* can change their choice — wire a "Cookie settings" link to a boolean state
|
|
28
|
+
* and pass it here (see the site-starter footer for the pattern).
|
|
29
|
+
*/
|
|
30
|
+
open?: boolean;
|
|
31
|
+
/** Called after the visitor makes a choice or dismisses — use it to reset your
|
|
32
|
+
* controlled `open` state back to false. */
|
|
33
|
+
onClose?: () => void;
|
|
34
|
+
/** Extra class on the outer wrapper. */
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Draft = { performance: boolean; marketing: boolean };
|
|
39
|
+
|
|
40
|
+
const TRIBENEST_PRIVACY_URL = "https://www.tribenest.co/privacy";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Drop-in cookie-consent banner. Shows once until the visitor decides, writes to
|
|
44
|
+
* the shared consent store, and — because `<ForgeAnalytics />` reads the same
|
|
45
|
+
* store — actually gates first-party analytics (performance) and the Meta Pixel
|
|
46
|
+
* (marketing). Themed via Forge tokens. Drop it into the site root:
|
|
47
|
+
* `<CookieConsent privacyPolicyHref="/privacy" />`.
|
|
48
|
+
*/
|
|
49
|
+
export function CookieConsent({
|
|
50
|
+
title = "Cookie Consent",
|
|
51
|
+
description = "This site uses cookies for performance, analytics, and personalization to improve the site and give you a better experience. Accept all, reject all but strictly necessary, or choose which to allow.",
|
|
52
|
+
showPerformance = true,
|
|
53
|
+
privacyPolicyHref,
|
|
54
|
+
useTribeNestPrivacy,
|
|
55
|
+
open,
|
|
56
|
+
onClose,
|
|
57
|
+
className,
|
|
58
|
+
}: CookieConsentProps) {
|
|
59
|
+
const t = useThemeTokens();
|
|
60
|
+
const privacyHref = privacyPolicyHref ?? (useTribeNestPrivacy ? TRIBENEST_PRIVACY_URL : undefined);
|
|
61
|
+
// External TribeNest link opens in a new tab; a same-origin custom page stays.
|
|
62
|
+
const privacyExternal = !privacyPolicyHref && useTribeNestPrivacy;
|
|
63
|
+
const { ready, decided, consent, acceptAll, rejectAll, save } = useCookieConsent();
|
|
64
|
+
const [draft, setDraft] = useState<Draft>({
|
|
65
|
+
performance: consent?.performance ?? false,
|
|
66
|
+
marketing: consent?.marketing ?? false,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Consent loads from storage after mount (SSR-safe). Sync the toggles to the
|
|
70
|
+
// stored choice so reopening the panel shows the visitor's current settings.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (consent) setDraft({ performance: consent.performance, marketing: consent.marketing });
|
|
73
|
+
}, [consent]);
|
|
74
|
+
|
|
75
|
+
// Uncontrolled: only auto-show once the stored choice has loaded (post-mount),
|
|
76
|
+
// so a returning visitor who already decided never sees a flash. A controlled
|
|
77
|
+
// `open` (the "Cookie settings" trigger) bypasses the gate.
|
|
78
|
+
const visible = open ?? (ready && !decided);
|
|
79
|
+
if (!visible) return null;
|
|
80
|
+
|
|
81
|
+
const onPrimary = t.textPrimary || readableTextOn(t.primary);
|
|
82
|
+
// Wrap each action so a controlled parent (a "Cookie settings" trigger) can
|
|
83
|
+
// reset its `open` state once the visitor is done.
|
|
84
|
+
const done = (fn: () => void) => () => {
|
|
85
|
+
fn();
|
|
86
|
+
onClose?.();
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div
|
|
91
|
+
className={className}
|
|
92
|
+
role="dialog"
|
|
93
|
+
aria-label="Cookie consent"
|
|
94
|
+
style={{
|
|
95
|
+
position: "fixed",
|
|
96
|
+
left: 0,
|
|
97
|
+
right: 0,
|
|
98
|
+
bottom: 0,
|
|
99
|
+
zIndex: 70,
|
|
100
|
+
padding: 16,
|
|
101
|
+
display: "flex",
|
|
102
|
+
justifyContent: "flex-start",
|
|
103
|
+
fontFamily: t.fontFamily,
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<div
|
|
107
|
+
style={{
|
|
108
|
+
width: "100%",
|
|
109
|
+
maxWidth: 560,
|
|
110
|
+
background: t.surface,
|
|
111
|
+
color: t.text,
|
|
112
|
+
border: `1px solid ${t.border}`,
|
|
113
|
+
borderRadius: t.cornerRadius,
|
|
114
|
+
boxShadow: "0 16px 40px -16px rgba(0,0,0,0.45)",
|
|
115
|
+
padding: 20,
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 12 }}>
|
|
119
|
+
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 800, fontFamily: t.headingFontFamily }}>{title}</h2>
|
|
120
|
+
{/* Close is only meaningful once a choice exists (reopened panel) —
|
|
121
|
+
on first run the visitor must pick an option. */}
|
|
122
|
+
{decided && (
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
aria-label="Close"
|
|
126
|
+
onClick={done(() => {})}
|
|
127
|
+
style={{ background: "transparent", border: "none", cursor: "pointer", color: t.muted, padding: 2, flexShrink: 0 }}
|
|
128
|
+
>
|
|
129
|
+
<X size={18} />
|
|
130
|
+
</button>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
<p style={{ margin: "8px 0 0", fontSize: 14, lineHeight: 1.5, color: t.muted }}>
|
|
134
|
+
{description}
|
|
135
|
+
{privacyHref && (
|
|
136
|
+
<>
|
|
137
|
+
{" "}
|
|
138
|
+
<a
|
|
139
|
+
href={privacyHref}
|
|
140
|
+
style={{ color: t.primary, textDecoration: "underline" }}
|
|
141
|
+
{...(privacyExternal ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
142
|
+
>
|
|
143
|
+
Privacy Policy
|
|
144
|
+
</a>
|
|
145
|
+
.
|
|
146
|
+
</>
|
|
147
|
+
)}
|
|
148
|
+
</p>
|
|
149
|
+
|
|
150
|
+
<div style={{ display: "flex", flexWrap: "wrap", gap: 20, margin: "16px 0 4px" }}>
|
|
151
|
+
<Toggle label="Functional" checked disabled tokens={t} />
|
|
152
|
+
{showPerformance && (
|
|
153
|
+
<Toggle
|
|
154
|
+
label="Performance"
|
|
155
|
+
checked={draft.performance}
|
|
156
|
+
tokens={t}
|
|
157
|
+
onChange={(v) => setDraft((d) => ({ ...d, performance: v }))}
|
|
158
|
+
/>
|
|
159
|
+
)}
|
|
160
|
+
<Toggle
|
|
161
|
+
label="Marketing"
|
|
162
|
+
checked={draft.marketing}
|
|
163
|
+
tokens={t}
|
|
164
|
+
onChange={(v) => setDraft((d) => ({ ...d, marketing: v }))}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div style={{ display: "flex", flexWrap: "wrap", gap: 10, justifyContent: "flex-end", marginTop: 16 }}>
|
|
169
|
+
<button type="button" onClick={done(rejectAll)} style={ghostBtn(t)}>
|
|
170
|
+
Reject all
|
|
171
|
+
</button>
|
|
172
|
+
<button type="button" onClick={done(() => save(draft))} style={ghostBtn(t)}>
|
|
173
|
+
Save settings
|
|
174
|
+
</button>
|
|
175
|
+
<button type="button" onClick={done(acceptAll)} style={{ ...ghostBtn(t), background: t.primary, color: onPrimary, border: "none" }}>
|
|
176
|
+
Accept all
|
|
177
|
+
</button>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function ghostBtn(t: ReturnType<typeof useThemeTokens>): React.CSSProperties {
|
|
185
|
+
return {
|
|
186
|
+
padding: "9px 16px",
|
|
187
|
+
borderRadius: t.cornerRadius,
|
|
188
|
+
border: `1px solid ${t.border}`,
|
|
189
|
+
background: "transparent",
|
|
190
|
+
color: t.text,
|
|
191
|
+
fontWeight: 600,
|
|
192
|
+
fontSize: 14,
|
|
193
|
+
cursor: "pointer",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function Toggle({
|
|
198
|
+
label,
|
|
199
|
+
checked,
|
|
200
|
+
disabled,
|
|
201
|
+
onChange,
|
|
202
|
+
tokens: t,
|
|
203
|
+
}: {
|
|
204
|
+
label: string;
|
|
205
|
+
checked: boolean;
|
|
206
|
+
disabled?: boolean;
|
|
207
|
+
onChange?: (v: boolean) => void;
|
|
208
|
+
tokens: ReturnType<typeof useThemeTokens>;
|
|
209
|
+
}) {
|
|
210
|
+
return (
|
|
211
|
+
<label style={{ display: "inline-flex", alignItems: "center", gap: 8, cursor: disabled ? "default" : "pointer" }}>
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
role="switch"
|
|
215
|
+
aria-checked={checked}
|
|
216
|
+
aria-label={label}
|
|
217
|
+
disabled={disabled}
|
|
218
|
+
onClick={() => onChange?.(!checked)}
|
|
219
|
+
style={{
|
|
220
|
+
width: 38,
|
|
221
|
+
height: 22,
|
|
222
|
+
borderRadius: 999,
|
|
223
|
+
border: "none",
|
|
224
|
+
padding: 2,
|
|
225
|
+
cursor: disabled ? "default" : "pointer",
|
|
226
|
+
background: checked ? t.primary : t.border,
|
|
227
|
+
opacity: disabled ? 0.6 : 1,
|
|
228
|
+
transition: "background .15s",
|
|
229
|
+
display: "inline-flex",
|
|
230
|
+
justifyContent: checked ? "flex-end" : "flex-start",
|
|
231
|
+
alignItems: "center",
|
|
232
|
+
}}
|
|
233
|
+
>
|
|
234
|
+
<span style={{ width: 18, height: 18, borderRadius: "50%", background: "#fff", display: "block" }} />
|
|
235
|
+
</button>
|
|
236
|
+
<span style={{ fontSize: 13, color: t.muted }}>{label}</span>
|
|
237
|
+
</label>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import { useCourseAccess, useUpdateCourseProgress } from "../../data/queries/useCourseAccess";
|
|
3
|
+
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
|
+
import { Loading } from "./Loading";
|
|
5
|
+
|
|
6
|
+
export interface CourseAccessProps {
|
|
7
|
+
/** Course access id (route param). */
|
|
8
|
+
accessId: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Enrolled course player — module/lesson sidebar, video progress tracking. Built on `useCourseAccess` + `useUpdateCourseProgress`. */
|
|
12
|
+
export function CourseAccess({ accessId }: CourseAccessProps) {
|
|
13
|
+
const theme = useForgeTheme();
|
|
14
|
+
const { data, isLoading, error } = useCourseAccess(accessId);
|
|
15
|
+
const updateProgress = useUpdateCourseProgress(accessId);
|
|
16
|
+
const lastSent = useRef(0);
|
|
17
|
+
|
|
18
|
+
const modules = data?.course.modules ?? [];
|
|
19
|
+
const allLessons = modules.flatMap((m) => m.lessons);
|
|
20
|
+
const initialLessonId = data?.access.currentLessonId || allLessons[0]?.id || null;
|
|
21
|
+
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(initialLessonId);
|
|
22
|
+
|
|
23
|
+
if (isLoading) return <Loading fullPage />;
|
|
24
|
+
if (error || !data) return <p style={{ color: theme.colors.text }}>Course access not found.</p>;
|
|
25
|
+
|
|
26
|
+
const selected = allLessons.find((l) => l.id === selectedLessonId) ?? allLessons[0];
|
|
27
|
+
const videoUrl = selected?.media?.find((m) => m.type === "video")?.url;
|
|
28
|
+
const files = selected?.media?.filter((m) => m.type !== "video") ?? [];
|
|
29
|
+
const completedCount = allLessons.filter((l) => l.progress?.isCompleted).length;
|
|
30
|
+
|
|
31
|
+
const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
|
|
32
|
+
const el = e.currentTarget;
|
|
33
|
+
if (!selected || !el.duration) return;
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
if (now - lastSent.current < 1000) return;
|
|
36
|
+
lastSent.current = now;
|
|
37
|
+
const percent = (el.currentTime / el.duration) * 100;
|
|
38
|
+
const isCompleted = percent >= 95;
|
|
39
|
+
updateProgress.mutate({ lessonId: selected.id, videoProgress: percent, isCompleted });
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
style={{
|
|
45
|
+
display: "flex",
|
|
46
|
+
flexDirection: "column",
|
|
47
|
+
gap: 24,
|
|
48
|
+
maxWidth: 1100,
|
|
49
|
+
margin: "0 auto",
|
|
50
|
+
color: theme.colors.text,
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
<header>
|
|
54
|
+
<h1 style={{ fontSize: 26, fontWeight: 800 }}>{data.course.title}</h1>
|
|
55
|
+
<p style={{ fontSize: 14, opacity: 0.7 }}>
|
|
56
|
+
{completedCount} of {allLessons.length} lessons completed
|
|
57
|
+
</p>
|
|
58
|
+
</header>
|
|
59
|
+
|
|
60
|
+
<div style={{ display: "flex", gap: 24, flexWrap: "wrap" }}>
|
|
61
|
+
{/* Sidebar */}
|
|
62
|
+
<aside style={{ flex: "1 1 260px", minWidth: 240 }}>
|
|
63
|
+
{modules.map((module) => (
|
|
64
|
+
<div key={module.id} style={{ marginBottom: 16 }}>
|
|
65
|
+
<h2 style={{ fontSize: 14, fontWeight: 700, marginBottom: 6 }}>{module.title}</h2>
|
|
66
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
|
67
|
+
{module.lessons.map((lesson) => {
|
|
68
|
+
const isSelected = lesson.id === selected?.id;
|
|
69
|
+
const done = lesson.progress?.isCompleted;
|
|
70
|
+
return (
|
|
71
|
+
<button
|
|
72
|
+
key={lesson.id}
|
|
73
|
+
onClick={() => setSelectedLessonId(lesson.id)}
|
|
74
|
+
style={{
|
|
75
|
+
textAlign: "left",
|
|
76
|
+
padding: "8px 10px",
|
|
77
|
+
borderRadius: theme.cornerRadius,
|
|
78
|
+
border: "none",
|
|
79
|
+
cursor: "pointer",
|
|
80
|
+
fontSize: 14,
|
|
81
|
+
background: isSelected ? `${theme.colors.primary}20` : "transparent",
|
|
82
|
+
color: isSelected ? theme.colors.primary : theme.colors.text,
|
|
83
|
+
fontWeight: isSelected ? 600 : 400,
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
{done ? "✓ " : ""}
|
|
87
|
+
{lesson.title}
|
|
88
|
+
</button>
|
|
89
|
+
);
|
|
90
|
+
})}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
))}
|
|
94
|
+
</aside>
|
|
95
|
+
|
|
96
|
+
{/* Main */}
|
|
97
|
+
<main style={{ flex: "2 1 480px", minWidth: 320 }}>
|
|
98
|
+
{selected ? (
|
|
99
|
+
<>
|
|
100
|
+
{videoUrl ? (
|
|
101
|
+
<video
|
|
102
|
+
key={selected.id}
|
|
103
|
+
src={videoUrl}
|
|
104
|
+
controls
|
|
105
|
+
playsInline
|
|
106
|
+
onTimeUpdate={handleTimeUpdate}
|
|
107
|
+
style={{ width: "100%", aspectRatio: "16/9", background: "#000", borderRadius: theme.cornerRadius }}
|
|
108
|
+
/>
|
|
109
|
+
) : (
|
|
110
|
+
<div
|
|
111
|
+
style={{
|
|
112
|
+
width: "100%",
|
|
113
|
+
aspectRatio: "16/9",
|
|
114
|
+
display: "flex",
|
|
115
|
+
alignItems: "center",
|
|
116
|
+
justifyContent: "center",
|
|
117
|
+
background: `${theme.colors.text}0d`,
|
|
118
|
+
borderRadius: theme.cornerRadius,
|
|
119
|
+
opacity: 0.7,
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
No video available for this lesson
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
<h2 style={{ fontSize: 20, fontWeight: 700, margin: "16px 0 8px" }}>{selected.title}</h2>
|
|
127
|
+
|
|
128
|
+
{files.length > 0 && (
|
|
129
|
+
<div style={{ marginBottom: 16 }}>
|
|
130
|
+
<h3 style={{ fontWeight: 600, marginBottom: 8 }}>Additional Resources</h3>
|
|
131
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
132
|
+
{files.map((file) => (
|
|
133
|
+
<a
|
|
134
|
+
key={file.id}
|
|
135
|
+
href={file.url}
|
|
136
|
+
target="_blank"
|
|
137
|
+
rel="noopener noreferrer"
|
|
138
|
+
style={{
|
|
139
|
+
display: "flex",
|
|
140
|
+
justifyContent: "space-between",
|
|
141
|
+
padding: "10px 12px",
|
|
142
|
+
borderRadius: theme.cornerRadius,
|
|
143
|
+
border: `1px solid ${theme.colors.primary}40`,
|
|
144
|
+
background: `${theme.colors.primary}10`,
|
|
145
|
+
color: theme.colors.text,
|
|
146
|
+
textDecoration: "none",
|
|
147
|
+
fontSize: 14,
|
|
148
|
+
}}
|
|
149
|
+
>
|
|
150
|
+
<span>{file.filename}</span>
|
|
151
|
+
<span style={{ opacity: 0.6 }}>{(file.size / 1024 / 1024).toFixed(2)} MB</span>
|
|
152
|
+
</a>
|
|
153
|
+
))}
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
{selected.content && (
|
|
159
|
+
<div dangerouslySetInnerHTML={{ __html: selected.content }} style={{ lineHeight: 1.7 }} />
|
|
160
|
+
)}
|
|
161
|
+
</>
|
|
162
|
+
) : (
|
|
163
|
+
<p style={{ opacity: 0.7 }}>No lesson selected.</p>
|
|
164
|
+
)}
|
|
165
|
+
</main>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|