@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.
Files changed (255) hide show
  1. package/package.json +42 -0
  2. package/src/client/createForgeClient.ts +54 -0
  3. package/src/content/ForgeContentProvider.tsx +118 -0
  4. package/src/content/ForgeEditBridge.tsx +134 -0
  5. package/src/content/editFlag.ts +23 -0
  6. package/src/content/index.ts +28 -0
  7. package/src/content/resolve.ts +67 -0
  8. package/src/content/types.ts +97 -0
  9. package/src/content/useField.ts +64 -0
  10. package/src/contexts/AppAuthContext.tsx +185 -0
  11. package/src/contexts/AudioPlayerContext.tsx +389 -0
  12. package/src/contexts/CartContext.tsx +131 -0
  13. package/src/contexts/PublicAuthContext.tsx +333 -0
  14. package/src/contexts/useAppAdminGuard.ts +56 -0
  15. package/src/css.d.ts +5 -0
  16. package/src/data/collectionParams.ts +57 -0
  17. package/src/data/queries/useAccount.ts +71 -0
  18. package/src/data/queries/useAccountSettings.ts +82 -0
  19. package/src/data/queries/useAnalytics.ts +86 -0
  20. package/src/data/queries/useAuthActions.ts +28 -0
  21. package/src/data/queries/useBlog.ts +103 -0
  22. package/src/data/queries/useBlogComments.ts +96 -0
  23. package/src/data/queries/useBroadcasts.ts +31 -0
  24. package/src/data/queries/useCertificates.ts +58 -0
  25. package/src/data/queries/useChat.ts +267 -0
  26. package/src/data/queries/useCoachingAvailability.ts +69 -0
  27. package/src/data/queries/useCoachingProducts.ts +67 -0
  28. package/src/data/queries/useCohortPage.ts +17 -0
  29. package/src/data/queries/useCollections.ts +211 -0
  30. package/src/data/queries/useCommunity.ts +794 -0
  31. package/src/data/queries/useCoupons.ts +20 -0
  32. package/src/data/queries/useCourseAccess.ts +50 -0
  33. package/src/data/queries/useCourses.ts +91 -0
  34. package/src/data/queries/useCurrencies.ts +33 -0
  35. package/src/data/queries/useDocuments.ts +208 -0
  36. package/src/data/queries/useDonations.ts +29 -0
  37. package/src/data/queries/useEngagement.ts +115 -0
  38. package/src/data/queries/useEvents.ts +69 -0
  39. package/src/data/queries/useFinalize.ts +109 -0
  40. package/src/data/queries/useForms.ts +31 -0
  41. package/src/data/queries/useInvoice.ts +65 -0
  42. package/src/data/queries/useLeadMagnet.ts +96 -0
  43. package/src/data/queries/useMembership.ts +20 -0
  44. package/src/data/queries/useNotifications.ts +267 -0
  45. package/src/data/queries/useOffers.ts +56 -0
  46. package/src/data/queries/useOrders.ts +71 -0
  47. package/src/data/queries/usePWA.ts +101 -0
  48. package/src/data/queries/usePageActions.ts +36 -0
  49. package/src/data/queries/usePaymentFlow.ts +80 -0
  50. package/src/data/queries/usePaymentLink.ts +47 -0
  51. package/src/data/queries/usePodcast.ts +85 -0
  52. package/src/data/queries/usePostCollections.ts +48 -0
  53. package/src/data/queries/usePosts.ts +94 -0
  54. package/src/data/queries/useProducts.ts +108 -0
  55. package/src/data/queries/useReplay.ts +19 -0
  56. package/src/data/queries/useReviews.ts +122 -0
  57. package/src/data/queries/useShipping.ts +109 -0
  58. package/src/data/queries/useSubscriptions.ts +66 -0
  59. package/src/data/queries/useWebsite.ts +128 -0
  60. package/src/data/queries/useWebsiteAgent.ts +100 -0
  61. package/src/index.ts +98 -0
  62. package/src/provider/ForgeAppProvider.tsx +149 -0
  63. package/src/provider/ForgeProvider.tsx +91 -0
  64. package/src/provider/SiteConfigProvider.tsx +27 -0
  65. package/src/server/_tests/buildPwaHead.spec.ts +182 -0
  66. package/src/server/_tests/buildWebManifest.spec.ts +313 -0
  67. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +251 -0
  68. package/src/server/index.ts +260 -0
  69. package/src/server/jobs.ts +104 -0
  70. package/src/server/pwa.ts +332 -0
  71. package/src/types/index.ts +74 -0
  72. package/src/types/models.ts +1313 -0
  73. package/src/ui/analytics/ForgeAnalytics.tsx +208 -0
  74. package/src/ui/analytics/PageMetaPixel.tsx +92 -0
  75. package/src/ui/content/Editable.tsx +90 -0
  76. package/src/ui/format/PriceDisplay.tsx +142 -0
  77. package/src/ui/format/useFormatCurrency.ts +92 -0
  78. package/src/ui/headless/agent/useAiAgent.ts +106 -0
  79. package/src/ui/headless/auth/useLoginFlow.ts +72 -0
  80. package/src/ui/headless/auth/useSignupForm.ts +85 -0
  81. package/src/ui/headless/broadcast/useBroadcastWatch.ts +59 -0
  82. package/src/ui/headless/chat/attachmentKind.ts +27 -0
  83. package/src/ui/headless/chat/useChatAttachmentUpload.ts +132 -0
  84. package/src/ui/headless/chat/useChatRoom.ts +223 -0
  85. package/src/ui/headless/chat/useMessageThread.ts +60 -0
  86. package/src/ui/headless/checkout/useCheckout.ts +438 -0
  87. package/src/ui/headless/coaching/useCoachingBooking.ts +223 -0
  88. package/src/ui/headless/community/index.ts +20 -0
  89. package/src/ui/headless/community/lexical.ts +131 -0
  90. package/src/ui/headless/community/replyTree.ts +51 -0
  91. package/src/ui/headless/community/richLexical.ts +314 -0
  92. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +137 -0
  93. package/src/ui/headless/community/useCommunitySpaceFeed.ts +122 -0
  94. package/src/ui/headless/community/useCommunityThread.ts +95 -0
  95. package/src/ui/headless/community/useMentionComposer.ts +105 -0
  96. package/src/ui/headless/community/useRichMentionComposer.ts +193 -0
  97. package/src/ui/headless/consent/useCookieConsent.ts +81 -0
  98. package/src/ui/headless/course/useCourseCheckout.ts +99 -0
  99. package/src/ui/headless/dialog.tsx +14 -0
  100. package/src/ui/headless/document/index.ts +7 -0
  101. package/src/ui/headless/document/useContractSignature.ts +68 -0
  102. package/src/ui/headless/document/useDocumentSigningFlow.ts +92 -0
  103. package/src/ui/headless/document/useOtpCodeInput.ts +86 -0
  104. package/src/ui/headless/document/useSignaturePad.ts +180 -0
  105. package/src/ui/headless/donation/Donation.tsx +115 -0
  106. package/src/ui/headless/donation/DonationContext.tsx +223 -0
  107. package/src/ui/headless/donation/fees.ts +15 -0
  108. package/src/ui/headless/donation/index.ts +10 -0
  109. package/src/ui/headless/engagement/usePostEngagement.ts +119 -0
  110. package/src/ui/headless/event/useEventCheckout.ts +141 -0
  111. package/src/ui/headless/forms/useContactForm.ts +58 -0
  112. package/src/ui/headless/forms/useEmailListForm.ts +75 -0
  113. package/src/ui/headless/forms/useSectionedForm.ts +256 -0
  114. package/src/ui/headless/index.ts +60 -0
  115. package/src/ui/headless/invoice/useInvoicePayment.ts +48 -0
  116. package/src/ui/headless/memberHome/index.ts +7 -0
  117. package/src/ui/headless/memberHome/useMemberHomeNav.ts +82 -0
  118. package/src/ui/headless/membership/MembershipGate.tsx +39 -0
  119. package/src/ui/headless/membership/useMembershipCheckout.ts +121 -0
  120. package/src/ui/headless/offer/Offer.tsx +88 -0
  121. package/src/ui/headless/offer/OfferContext.tsx +162 -0
  122. package/src/ui/headless/offer/index.ts +8 -0
  123. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +46 -0
  124. package/src/ui/headless/podcast/usePodcastPlayer.ts +96 -0
  125. package/src/ui/headless/posts/index.ts +13 -0
  126. package/src/ui/headless/posts/upsell.ts +41 -0
  127. package/src/ui/headless/posts/useMemberPostsFeed.ts +110 -0
  128. package/src/ui/headless/posts/usePostCollectionDetail.ts +56 -0
  129. package/src/ui/headless/posts/usePostCollectionsList.ts +90 -0
  130. package/src/ui/headless/posts/usePostItem.ts +81 -0
  131. package/src/ui/headless/reviews/index.ts +6 -0
  132. package/src/ui/headless/reviews/useHelpfulVote.ts +45 -0
  133. package/src/ui/headless/reviews/useReviewForm.ts +108 -0
  134. package/src/ui/headless/reviews/useReviewsList.ts +79 -0
  135. package/src/ui/headless/work/index.ts +25 -0
  136. package/src/ui/headless/work/useWorkPortal.ts +349 -0
  137. package/src/ui/index.ts +144 -0
  138. package/src/ui/payment/ForgePaymentProvider.tsx +45 -0
  139. package/src/ui/payment/ForgeStripePayment.tsx +171 -0
  140. package/src/ui/shell/TribeNestApp.tsx +121 -0
  141. package/src/ui/shell/shellGating.spec.ts +28 -0
  142. package/src/ui/shell/shellGating.ts +11 -0
  143. package/src/ui/styled/AccountDashboard.tsx +575 -0
  144. package/src/ui/styled/AiAgentWidget.tsx +152 -0
  145. package/src/ui/styled/AudioPlayer.tsx +123 -0
  146. package/src/ui/styled/BlogCategory.tsx +79 -0
  147. package/src/ui/styled/BlogComments.tsx +236 -0
  148. package/src/ui/styled/BlogList.tsx +101 -0
  149. package/src/ui/styled/BlogPost.tsx +160 -0
  150. package/src/ui/styled/BlogPostFooter.tsx +33 -0
  151. package/src/ui/styled/BotProtection.tsx +101 -0
  152. package/src/ui/styled/Button.tsx +88 -0
  153. package/src/ui/styled/Cart.tsx +276 -0
  154. package/src/ui/styled/ChatRoom.tsx +252 -0
  155. package/src/ui/styled/Checkout.tsx +988 -0
  156. package/src/ui/styled/CheckoutConfirmation.tsx +452 -0
  157. package/src/ui/styled/CoachingBooking.tsx +605 -0
  158. package/src/ui/styled/CoachingConfirmation.tsx +151 -0
  159. package/src/ui/styled/CoachingDetail.tsx +99 -0
  160. package/src/ui/styled/CohortPage.tsx +82 -0
  161. package/src/ui/styled/ConfirmSubscription.tsx +134 -0
  162. package/src/ui/styled/Confirmation.tsx +171 -0
  163. package/src/ui/styled/ContactForm.tsx +81 -0
  164. package/src/ui/styled/CookieConsent.tsx +239 -0
  165. package/src/ui/styled/CourseAccess.tsx +169 -0
  166. package/src/ui/styled/CourseCheckout.tsx +425 -0
  167. package/src/ui/styled/CourseConfirmation.tsx +191 -0
  168. package/src/ui/styled/CourseDetail.tsx +140 -0
  169. package/src/ui/styled/CoursesGrid.tsx +85 -0
  170. package/src/ui/styled/CurrencySwitcher.tsx +51 -0
  171. package/src/ui/styled/DonationButton.tsx +247 -0
  172. package/src/ui/styled/DonationPage.tsx +29 -0
  173. package/src/ui/styled/EmailListForm.tsx +88 -0
  174. package/src/ui/styled/EventConfirmation.tsx +228 -0
  175. package/src/ui/styled/EventCountdown.tsx +98 -0
  176. package/src/ui/styled/EventDetail.tsx +141 -0
  177. package/src/ui/styled/EventTickets.tsx +548 -0
  178. package/src/ui/styled/EventsList.tsx +83 -0
  179. package/src/ui/styled/ForgotPasswordForm.tsx +105 -0
  180. package/src/ui/styled/FormRenderer.tsx +249 -0
  181. package/src/ui/styled/InstallBanner.tsx +114 -0
  182. package/src/ui/styled/InvoiceConfirmation.tsx +50 -0
  183. package/src/ui/styled/InvoicePayment.tsx +141 -0
  184. package/src/ui/styled/LeadMagnet.tsx +93 -0
  185. package/src/ui/styled/Loading.tsx +56 -0
  186. package/src/ui/styled/LoginForm.tsx +134 -0
  187. package/src/ui/styled/Markdown.tsx +103 -0
  188. package/src/ui/styled/MembershipCheckout.tsx +197 -0
  189. package/src/ui/styled/MembershipTiers.tsx +115 -0
  190. package/src/ui/styled/OfferButton.tsx +181 -0
  191. package/src/ui/styled/PageActions.tsx +124 -0
  192. package/src/ui/styled/PaymentLinkConfirmation.tsx +50 -0
  193. package/src/ui/styled/PaymentLinkPayment.tsx +85 -0
  194. package/src/ui/styled/Paywall.tsx +54 -0
  195. package/src/ui/styled/PlayButton.tsx +48 -0
  196. package/src/ui/styled/PodcastEpisode.tsx +137 -0
  197. package/src/ui/styled/PodcastList.tsx +58 -0
  198. package/src/ui/styled/PodcastShow.tsx +164 -0
  199. package/src/ui/styled/PostsFeed.tsx +63 -0
  200. package/src/ui/styled/ProductDetail.tsx +616 -0
  201. package/src/ui/styled/ProductGrid.tsx +82 -0
  202. package/src/ui/styled/PushOptIn.tsx +65 -0
  203. package/src/ui/styled/PwaRegistration.tsx +58 -0
  204. package/src/ui/styled/ReactionBar.tsx +93 -0
  205. package/src/ui/styled/ReplayList.tsx +134 -0
  206. package/src/ui/styled/ResetPasswordForm.tsx +127 -0
  207. package/src/ui/styled/ReviewForm.tsx +292 -0
  208. package/src/ui/styled/ReviewsSection.tsx +260 -0
  209. package/src/ui/styled/SectionedFormRenderer.tsx +146 -0
  210. package/src/ui/styled/SignupForm.tsx +109 -0
  211. package/src/ui/styled/UserMenu.tsx +166 -0
  212. package/src/ui/styled/chat/ChatAttachments.tsx +172 -0
  213. package/src/ui/styled/chat/ChatComposer.tsx +153 -0
  214. package/src/ui/styled/chat/ChatThreadPanel.tsx +94 -0
  215. package/src/ui/styled/chat/MessageBubble.tsx +66 -0
  216. package/src/ui/styled/chat/NewDmDialog.tsx +78 -0
  217. package/src/ui/styled/community/CommunityComposer.tsx +358 -0
  218. package/src/ui/styled/community/CommunityFeed.tsx +483 -0
  219. package/src/ui/styled/community/CommunityLeaderboard.tsx +109 -0
  220. package/src/ui/styled/community/CommunityMediaGallery.tsx +116 -0
  221. package/src/ui/styled/community/CommunityMemberProfile.tsx +285 -0
  222. package/src/ui/styled/community/CommunityNotifications.tsx +222 -0
  223. package/src/ui/styled/community/CommunityPostCard.tsx +165 -0
  224. package/src/ui/styled/community/CommunityPostDetail.tsx +337 -0
  225. package/src/ui/styled/community/CommunityReactionBar.tsx +107 -0
  226. package/src/ui/styled/community/CommunityRichText.tsx +157 -0
  227. package/src/ui/styled/community/CommunitySpaces.tsx +174 -0
  228. package/src/ui/styled/community/MemberAvatar.tsx +74 -0
  229. package/src/ui/styled/community/index.ts +23 -0
  230. package/src/ui/styled/community/util.ts +15 -0
  231. package/src/ui/styled/members/MemberHomeLayout.tsx +98 -0
  232. package/src/ui/styled/members/MemberPostCard.tsx +392 -0
  233. package/src/ui/styled/members/MemberPostsFeed.tsx +219 -0
  234. package/src/ui/styled/members/PostCollectionDetail.tsx +227 -0
  235. package/src/ui/styled/members/PostCollectionsGrid.tsx +287 -0
  236. package/src/ui/styled/members/index.ts +7 -0
  237. package/src/ui/styled/rich-text.css +150 -0
  238. package/src/ui/styled/work/WorkInviteAccept.tsx +222 -0
  239. package/src/ui/styled/work/WorkPortalProjects.tsx +163 -0
  240. package/src/ui/styled/work/WorkProjectInvoices.tsx +113 -0
  241. package/src/ui/styled/work/WorkProjectReport.tsx +66 -0
  242. package/src/ui/styled/work/WorkReportBody.tsx +327 -0
  243. package/src/ui/styled/work/WorkTaskAttachments.tsx +67 -0
  244. package/src/ui/styled/work/WorkTaskComments.tsx +151 -0
  245. package/src/ui/styled/work/WorkTaskDetail.tsx +92 -0
  246. package/src/ui/styled/work/WorkTokenReport.tsx +49 -0
  247. package/src/ui/styled/work/index.ts +10 -0
  248. package/src/ui/theme/ForgeThemeProvider.tsx +121 -0
  249. package/src/ui/theme/contrast.ts +22 -0
  250. package/src/utils/analyticsBus.ts +31 -0
  251. package/src/utils/attribution.ts +68 -0
  252. package/src/utils/cookieConsent.ts +83 -0
  253. package/src/utils/landing.ts +153 -0
  254. package/src/utils/metaPixel.ts +122 -0
  255. 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
+ }