@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,82 @@
1
+ import { useGetProducts } from "../../data/queries/useProducts";
2
+ import type { IPublicProduct } from "../../types/models";
3
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
4
+ import { useAmountFormatter } from "../format/useFormatCurrency";
5
+ import { PriceDisplay } from "../format/PriceDisplay";
6
+ import { usePricesIncludeTax } from "../../data/queries/useWebsite";
7
+ import { Loading } from "./Loading";
8
+
9
+ export interface ProductGridProps {
10
+ columns?: number;
11
+ limit?: number;
12
+ formatAmount?: (amount: number) => string;
13
+ onSelect?: (product: IPublicProduct) => void;
14
+ className?: string;
15
+ style?: React.CSSProperties;
16
+ }
17
+
18
+ function minPrice(p: IPublicProduct): number {
19
+ const prices = (p.variants ?? []).map((v) => v.price).filter((n) => typeof n === "number");
20
+ return prices.length ? Math.min(...prices) : 0;
21
+ }
22
+
23
+ /** Themed product grid, built on `useGetProducts`. */
24
+ export function ProductGrid({ columns = 3, limit, formatAmount, onSelect, className, style }: ProductGridProps) {
25
+ const t = useThemeTokens();
26
+ const fmt = useAmountFormatter(formatAmount);
27
+ const pricesIncludeTax = usePricesIncludeTax();
28
+ const { data, isLoading } = useGetProducts({ page: 1 });
29
+ const products = (data?.data ?? []).slice(0, limit ?? 100);
30
+
31
+ if (isLoading) return <Loading />;
32
+ if (!products.length) return <p style={{ color: t.text, opacity: 0.7 }}>No products yet.</p>;
33
+
34
+ // Responsive: each card is at least ~150px wide, but also at least
35
+ // (container / columns), so it renders `columns` across on wide screens and
36
+ // wraps to fewer on narrow ones — no fixed column count that cramps mobile.
37
+ const gap = 16;
38
+ const track = `minmax(max(150px, calc((100% - ${(columns - 1) * gap}px) / ${columns})), 1fr)`;
39
+
40
+ return (
41
+ <div
42
+ className={className}
43
+ style={{
44
+ display: "grid",
45
+ gridTemplateColumns: `repeat(auto-fill, ${track})`,
46
+ gap,
47
+ fontFamily: t.fontFamily,
48
+ ...style,
49
+ }}
50
+ >
51
+ {products.map((p) => {
52
+ const cover = p.media?.find((m) => m.type === "image")?.url ?? p.media?.[0]?.url;
53
+ return (
54
+ <button
55
+ key={p.id}
56
+ onClick={() => onSelect?.(p)}
57
+ style={{
58
+ textAlign: "left",
59
+ cursor: onSelect ? "pointer" : "default",
60
+ background: t.surface,
61
+ border: `1px solid ${t.primary}20`,
62
+ borderRadius: t.cornerRadius,
63
+ overflow: "hidden",
64
+ padding: 0,
65
+ color: t.text,
66
+ }}
67
+ >
68
+ <div style={{ aspectRatio: "1 / 1", background: `${t.primary}10` }}>
69
+ {cover && <img src={cover} alt={p.title} style={{ width: "100%", height: "100%", objectFit: "cover", objectPosition: "top" }} />}
70
+ </div>
71
+ <div style={{ padding: 10 }}>
72
+ <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 2 }}>{p.title}</div>
73
+ <div style={{ color: t.primary, fontWeight: 600, fontSize: 14 }}>
74
+ <PriceDisplay amount={minPrice(p)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
75
+ </div>
76
+ </div>
77
+ </button>
78
+ );
79
+ })}
80
+ </div>
81
+ );
82
+ }
@@ -0,0 +1,65 @@
1
+ import { useForge } from "../../provider/ForgeProvider";
2
+ import { usePublicAuth } from "../../contexts/PublicAuthContext";
3
+ import { usePushSubscription } from "../../data/queries/useNotifications";
4
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
+ import { Button } from "./Button";
6
+
7
+ export interface PushOptInProps {
8
+ /** Row heading. */
9
+ title?: string;
10
+ /** Supporting copy. */
11
+ description?: string;
12
+ }
13
+
14
+ /**
15
+ * A small opt-in row for push notifications, for account/membership pages. Uses
16
+ * the FROZEN `usePushSubscription()` contract (baked `profileId`, fan auth token
17
+ * flows automatically). Subscribe requires a logged-in fan (the subscribe API is
18
+ * fan-authenticated), so this renders nothing when unauthenticated or when push
19
+ * isn't supported. On iOS, push works only from the installed PWA (16.4+) — pair
20
+ * with `<InstallBanner/>`.
21
+ *
22
+ * The membership-notifications spec's full preference UI supersedes this row on
23
+ * `/i/account` later; this is the minimal subscription primitive it plugs into.
24
+ */
25
+ export function PushOptIn({
26
+ title = "Push notifications",
27
+ description = "Get notified about new posts, replies, and messages.",
28
+ }: PushOptInProps) {
29
+ const t = useThemeTokens();
30
+ const { profileId } = useForge();
31
+ const { isAuthenticated } = usePublicAuth();
32
+ const { isSupported, isSubscribed, isLoading, subscribe, unsubscribe } = usePushSubscription(profileId ?? "");
33
+
34
+ if (!isAuthenticated || !isSupported || !profileId) return null;
35
+
36
+ return (
37
+ <div
38
+ style={{
39
+ display: "flex",
40
+ alignItems: "center",
41
+ justifyContent: "space-between",
42
+ gap: 16,
43
+ padding: 16,
44
+ borderRadius: t.cornerRadius,
45
+ border: `1px solid ${t.border}`,
46
+ background: t.surface,
47
+ color: t.text,
48
+ }}
49
+ >
50
+ <div>
51
+ <div style={{ fontWeight: 600, fontSize: 15 }}>{title}</div>
52
+ <div style={{ fontSize: 13, color: t.muted, marginTop: 2 }}>{description}</div>
53
+ </div>
54
+ {isSubscribed ? (
55
+ <Button size="sm" variant="outline" loading={isLoading} onClick={() => unsubscribe()}>
56
+ Turn off
57
+ </Button>
58
+ ) : (
59
+ <Button size="sm" loading={isLoading} onClick={() => subscribe()}>
60
+ Enable
61
+ </Button>
62
+ )}
63
+ </div>
64
+ );
65
+ }
@@ -0,0 +1,58 @@
1
+ import { useEffect } from "react";
2
+
3
+ export interface PwaRegistrationProps {
4
+ /**
5
+ * Register the service worker only for the live published site. The starter
6
+ * passes `enabled={!FORGE_EDIT && FORGE_STATE === "published"}` so the HMR
7
+ * edit sandbox and `preview-<versionId>.*` review Workers (deployed with
8
+ * state:"draft") never install a SW or cache a preview. Defaults to `false`
9
+ * so an un-gated mount is a no-op, never an accidental prod SW.
10
+ */
11
+ enabled?: boolean;
12
+ /** Path the SW is served from (default `/sw.js`, scope `/`). */
13
+ swUrl?: string;
14
+ }
15
+
16
+ /**
17
+ * Registers the Forge service worker (`/sw.js`) on the published site. Renders
18
+ * nothing. The SW source lives in `@tribe-nest/forge/server`
19
+ * (`forgeServiceWorkerScript()`) and is served by the starter's `/sw.js` route,
20
+ * so a Forge version bump propagates SW fixes to every deployed site — never
21
+ * frozen into per-site files.
22
+ *
23
+ * Gating (locked decision): registration is skipped unless `enabled` is true,
24
+ * so reviewers on a preview Worker or the HMR sandbox are never SW-cached or
25
+ * install-prompted. Root-path SW ⇒ scope `/` with no `Service-Worker-Allowed`
26
+ * games; each origin (subdomain + custom domains) registers independently.
27
+ */
28
+ export function PwaRegistration({ enabled = false, swUrl = "/sw.js" }: PwaRegistrationProps) {
29
+ useEffect(() => {
30
+ if (!enabled) return;
31
+ if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
32
+ let cancelled = false;
33
+ const register = () => {
34
+ navigator.serviceWorker.register(swUrl, { scope: "/" }).catch((err) => {
35
+ // Registration failure must never break the page.
36
+ console.error("SW registration failed:", err);
37
+ });
38
+ };
39
+ // Register after load so it never contends with the initial navigation.
40
+ if (document.readyState === "complete") {
41
+ if (!cancelled) register();
42
+ } else {
43
+ const onLoad = () => {
44
+ if (!cancelled) register();
45
+ };
46
+ window.addEventListener("load", onLoad, { once: true });
47
+ return () => {
48
+ cancelled = true;
49
+ window.removeEventListener("load", onLoad);
50
+ };
51
+ }
52
+ return () => {
53
+ cancelled = true;
54
+ };
55
+ }, [enabled, swUrl]);
56
+
57
+ return null;
58
+ }
@@ -0,0 +1,93 @@
1
+ import { useState } from "react";
2
+ import { usePostEngagement, type UsePostEngagementOptions } from "../headless/engagement/usePostEngagement";
3
+ import type { EngagementEntityType } from "../../types/models";
4
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
5
+ import { readableTextOn } from "../theme/contrast";
6
+
7
+ export interface ReactionBarProps extends UsePostEngagementOptions {
8
+ entityId: string;
9
+ entityType?: EngagementEntityType;
10
+ showComments?: boolean;
11
+ }
12
+
13
+ /** Themed like / save / comment bar built on `usePostEngagement`. */
14
+ export function ReactionBar({ entityId, entityType = "post", showComments = true, ...opts }: ReactionBarProps) {
15
+ const theme = useForgeTheme();
16
+ const e = usePostEngagement(entityId, entityType, opts);
17
+ const [draft, setDraft] = useState("");
18
+
19
+ const iconBtn = {
20
+ display: "inline-flex",
21
+ alignItems: "center",
22
+ gap: 6,
23
+ background: "transparent",
24
+ border: "none",
25
+ cursor: "pointer",
26
+ color: theme.colors.text,
27
+ fontSize: 15,
28
+ } as const;
29
+
30
+ return (
31
+ <div style={{ color: theme.colors.text, fontFamily: theme.fontFamily }}>
32
+ <div style={{ display: "flex", gap: 20, alignItems: "center" }}>
33
+ <button onClick={e.toggleLike} style={iconBtn} aria-pressed={e.isLiked}>
34
+ <span style={{ color: e.isLiked ? theme.colors.primary : "inherit" }}>{e.isLiked ? "♥" : "♡"}</span>
35
+ {e.likeCount}
36
+ </button>
37
+
38
+ {showComments && (
39
+ <button onClick={e.openComments} style={iconBtn}>
40
+ 💬 {e.commentCount}
41
+ </button>
42
+ )}
43
+
44
+ <button onClick={e.toggleSave} style={iconBtn} aria-pressed={e.isSaved}>
45
+ <span style={{ color: e.isSaved ? theme.colors.primary : "inherit" }}>{e.isSaved ? "★" : "☆"}</span>
46
+ </button>
47
+ </div>
48
+
49
+ {showComments && e.showComments && (
50
+ <div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 8 }}>
51
+ {e.comments.map((c) => (
52
+ <div key={c.id} style={{ fontSize: 14 }}>
53
+ <strong>{c.fullName}</strong> {c.content}
54
+ </div>
55
+ ))}
56
+ <div style={{ display: "flex", gap: 8 }}>
57
+ <input
58
+ value={draft}
59
+ onChange={(ev) => setDraft(ev.target.value)}
60
+ placeholder="Add a comment…"
61
+ style={{
62
+ flex: 1,
63
+ padding: 8,
64
+ border: `1px solid ${theme.colors.primary}40`,
65
+ borderRadius: theme.cornerRadius,
66
+ background: theme.colors.background,
67
+ color: theme.colors.text,
68
+ }}
69
+ />
70
+ <button
71
+ onClick={async () => {
72
+ await e.addComment(draft);
73
+ setDraft("");
74
+ }}
75
+ disabled={e.isAddingComment || !draft.trim()}
76
+ style={{
77
+ background: theme.colors.primary,
78
+ color: readableTextOn(theme.colors.primary),
79
+ border: "none",
80
+ borderRadius: theme.cornerRadius,
81
+ padding: "8px 14px",
82
+ cursor: "pointer",
83
+ opacity: e.isAddingComment || !draft.trim() ? 0.6 : 1,
84
+ }}
85
+ >
86
+ Post
87
+ </button>
88
+ </div>
89
+ </div>
90
+ )}
91
+ </div>
92
+ );
93
+ }
@@ -0,0 +1,134 @@
1
+ import { useState } from "react";
2
+ import { useReplayBroadcasts } from "../../data/queries/useReplay";
3
+ import type { ReplayBroadcast } from "../../types/models";
4
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
5
+ import { Loading } from "./Loading";
6
+
7
+ export interface ReplayListProps {
8
+ /** Ticket id gating access to the replay broadcasts (from the URL search param). */
9
+ ticketId?: string;
10
+ }
11
+
12
+ /** Locale date-time formatting. */
13
+ function formatDateTime(value: string | number | Date, timezone?: string): string {
14
+ return new Date(value).toLocaleString("en-US", {
15
+ weekday: "long",
16
+ year: "numeric",
17
+ month: "long",
18
+ day: "numeric",
19
+ hour: "numeric",
20
+ minute: "2-digit",
21
+ hour12: true,
22
+ timeZone: timezone || "UTC",
23
+ timeZoneName: "short",
24
+ });
25
+ }
26
+
27
+ /** Replay broadcasts gallery, gated by ticket id. Built on `useReplayBroadcasts`. */
28
+ export function ReplayList({ ticketId }: ReplayListProps) {
29
+ const theme = useForgeTheme();
30
+ const [selected, setSelected] = useState<ReplayBroadcast | null>(null);
31
+
32
+ const { data, isLoading, error } = useReplayBroadcasts(ticketId);
33
+
34
+ if (!ticketId) {
35
+ return (
36
+ <div style={{ maxWidth: 520, margin: "0 auto", textAlign: "center", color: theme.colors.text }}>
37
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>Enter Your Ticket ID</h1>
38
+ <p style={{ opacity: 0.7 }}>
39
+ Add your ticket id to the URL, e.g. <code>/i/replay?ticketId=&lt;ticketId&gt;</code>.
40
+ </p>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ if (isLoading) return <Loading fullPage />;
46
+
47
+ if (error || !data) {
48
+ return (
49
+ <p style={{ color: theme.colors.text }}>
50
+ Invalid ticket id or no broadcasts found. Please check your ticket id and try again.
51
+ </p>
52
+ );
53
+ }
54
+
55
+ if (selected) {
56
+ return (
57
+ <div style={{ maxWidth: 960, margin: "0 auto", color: theme.colors.text }}>
58
+ <button
59
+ onClick={() => setSelected(null)}
60
+ style={{
61
+ marginBottom: 16,
62
+ cursor: "pointer",
63
+ background: "none",
64
+ border: "none",
65
+ color: theme.colors.primary,
66
+ fontSize: 14,
67
+ }}
68
+ >
69
+ ← Back to broadcasts
70
+ </button>
71
+ <div style={{ borderRadius: theme.cornerRadius, overflow: "hidden", border: `1px solid ${theme.colors.text}1a` }}>
72
+ {selected.vodUrl ? (
73
+ <video src={selected.vodUrl} controls playsInline style={{ width: "100%", background: "#000" }} />
74
+ ) : (
75
+ <div style={{ aspectRatio: "16/9", display: "flex", alignItems: "center", justifyContent: "center", background: "#000", color: "#fff" }}>
76
+ Video not available
77
+ </div>
78
+ )}
79
+ <div style={{ padding: 24 }}>
80
+ <h1 style={{ fontSize: 22, fontWeight: 700 }}>{selected.title}</h1>
81
+ {selected.endedAt && (
82
+ <p style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>Ended {formatDateTime(selected.endedAt)}</p>
83
+ )}
84
+ </div>
85
+ </div>
86
+ </div>
87
+ );
88
+ }
89
+
90
+ if (data.broadcasts.length === 0) {
91
+ return <p style={{ color: theme.colors.text }}>No replay broadcasts available for this ticket.</p>;
92
+ }
93
+
94
+ return (
95
+ <div style={{ maxWidth: 960, margin: "0 auto", color: theme.colors.text }}>
96
+ <h1 style={{ fontSize: 28, fontWeight: 700, marginBottom: 24 }}>Replay Broadcasts</h1>
97
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: 16 }}>
98
+ {data.broadcasts.map((broadcast) => {
99
+ const thumb = broadcast.thumbnailUrl || broadcast.generatedThumbnailUrl;
100
+ return (
101
+ <button
102
+ key={broadcast.id}
103
+ onClick={() => setSelected(broadcast)}
104
+ style={{
105
+ textAlign: "left",
106
+ cursor: "pointer",
107
+ padding: 0,
108
+ background: "none",
109
+ color: theme.colors.text,
110
+ border: `1px solid ${theme.colors.text}1a`,
111
+ borderRadius: theme.cornerRadius,
112
+ overflow: "hidden",
113
+ }}
114
+ >
115
+ <div style={{ aspectRatio: "16/9", background: "#000", display: "flex", alignItems: "center", justifyContent: "center" }}>
116
+ {thumb ? (
117
+ <img src={thumb} alt={broadcast.title} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
118
+ ) : (
119
+ <span style={{ color: "#fff", opacity: 0.6 }}>▶</span>
120
+ )}
121
+ </div>
122
+ <div style={{ padding: 16 }}>
123
+ <h3 style={{ fontWeight: 600 }}>{broadcast.title}</h3>
124
+ {broadcast.startedAt && (
125
+ <p style={{ fontSize: 12, opacity: 0.7, marginTop: 4 }}>{formatDateTime(broadcast.startedAt)}</p>
126
+ )}
127
+ </div>
128
+ </button>
129
+ );
130
+ })}
131
+ </div>
132
+ </div>
133
+ );
134
+ }
@@ -0,0 +1,127 @@
1
+ import { useState } from "react";
2
+ import { useResetPassword } from "../../data/queries/useAuthActions";
3
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
+
5
+ export interface ResetPasswordFormProps {
6
+ /** Reset token from the email link (read from search params by the shell). */
7
+ token?: string;
8
+ /** Href for the "Back to login" link (shown on success). */
9
+ loginHref: string;
10
+ /** Href for the "Request a new reset link" link (shown when token is missing). */
11
+ forgotHref: string;
12
+ }
13
+
14
+ const msg = (e: unknown) => (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
15
+
16
+ /** Themed reset-password form, built on `useResetPassword`. */
17
+ export function ResetPasswordForm({ token, loginHref, forgotHref }: ResetPasswordFormProps) {
18
+ const theme = useForgeTheme();
19
+ const resetPassword = useResetPassword();
20
+ const [password, setPassword] = useState("");
21
+ const [confirmPassword, setConfirmPassword] = useState("");
22
+ const [error, setError] = useState("");
23
+ const [isSubmitting, setIsSubmitting] = useState(false);
24
+ const [isSuccess, setIsSuccess] = useState(false);
25
+
26
+ const card: React.CSSProperties = {
27
+ maxWidth: 420,
28
+ margin: "40px auto 0",
29
+ padding: 24,
30
+ border: `1px solid ${theme.colors.primary}40`,
31
+ borderRadius: theme.cornerRadius,
32
+ color: theme.colors.text,
33
+ };
34
+ const input: React.CSSProperties = {
35
+ width: "100%",
36
+ height: 40,
37
+ padding: "8px 12px",
38
+ borderRadius: theme.cornerRadius,
39
+ border: `1px solid ${theme.colors.primary}40`,
40
+ background: "transparent",
41
+ color: theme.colors.text,
42
+ outline: "none",
43
+ };
44
+ const button: React.CSSProperties = {
45
+ width: "100%",
46
+ padding: "10px 14px",
47
+ borderRadius: theme.cornerRadius,
48
+ background: theme.colors.primary,
49
+ color: theme.colors.background,
50
+ border: "none",
51
+ fontWeight: 600,
52
+ cursor: "pointer",
53
+ };
54
+ const link: React.CSSProperties = { color: theme.colors.primary, textDecoration: "underline" };
55
+
56
+ if (!token) {
57
+ return (
58
+ <div style={card}>
59
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>Invalid link</h1>
60
+ <p style={{ fontSize: 14, marginBottom: 24 }}>This password reset link is invalid or has expired.</p>
61
+ <a href={forgotHref} style={link}>
62
+ Request a new reset link
63
+ </a>
64
+ </div>
65
+ );
66
+ }
67
+
68
+ if (isSuccess) {
69
+ return (
70
+ <div style={card}>
71
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>Password reset</h1>
72
+ <p style={{ fontSize: 14, marginBottom: 24 }}>Your password has been reset successfully.</p>
73
+ <a href={loginHref} style={link}>
74
+ Back to login
75
+ </a>
76
+ </div>
77
+ );
78
+ }
79
+
80
+ const onSubmit = async (e: React.FormEvent) => {
81
+ e.preventDefault();
82
+ setError("");
83
+ if (password.length < 8) {
84
+ setError("Password must be at least 8 characters.");
85
+ return;
86
+ }
87
+ if (password !== confirmPassword) {
88
+ setError("Passwords don't match.");
89
+ return;
90
+ }
91
+ setIsSubmitting(true);
92
+ try {
93
+ await resetPassword.mutateAsync({ token, password });
94
+ setIsSuccess(true);
95
+ } catch (err) {
96
+ setError(msg(err) || "Something went wrong. Please try again.");
97
+ } finally {
98
+ setIsSubmitting(false);
99
+ }
100
+ };
101
+
102
+ return (
103
+ <div style={card}>
104
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>Reset password</h1>
105
+ {error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{error}</p>}
106
+ <form onSubmit={onSubmit} style={{ display: "flex", flexDirection: "column", gap: 16 }}>
107
+ <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
108
+ <label style={{ fontSize: 14 }}>Password</label>
109
+ <input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} style={input} />
110
+ </div>
111
+ <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
112
+ <label style={{ fontSize: 14 }}>Confirm password</label>
113
+ <input
114
+ type="password"
115
+ placeholder="Confirm password"
116
+ value={confirmPassword}
117
+ onChange={(e) => setConfirmPassword(e.target.value)}
118
+ style={input}
119
+ />
120
+ </div>
121
+ <button type="submit" disabled={isSubmitting} style={button}>
122
+ {isSubmitting ? "Resetting…" : "Reset password"}
123
+ </button>
124
+ </form>
125
+ </div>
126
+ );
127
+ }