@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,292 @@
1
+ import { useState } from "react";
2
+ import { CheckCircle2, Clock, Star } from "lucide-react";
3
+ import { useReviewForm as useReviewFormFlow } from "../headless/reviews/useReviewForm";
4
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
+ import { StarRow } from "./ReviewsSection";
6
+ import { Button } from "./Button";
7
+ import { Loading } from "./Loading";
8
+
9
+ const a = (hex: string, al: number) => hex + Math.round(al * 255).toString(16).padStart(2, "0");
10
+
11
+ export interface ReviewFormProps {
12
+ /** The review-request token from the emailed link. */
13
+ token?: string;
14
+ /** Extra class(es) appended to the root element. */
15
+ className?: string;
16
+ /** Inline style merged LAST into the root element. */
17
+ style?: React.CSSProperties;
18
+ }
19
+
20
+ /**
21
+ * Public review form behind an emailed token link (`/reviews/:token`). The
22
+ * token proves the verified purchase — no login required. Handles expired
23
+ * links, already-reviewed and edit-within-30-days states, and the spam-guard
24
+ * "held for moderation" outcome.
25
+ */
26
+ export function ReviewForm({ token, className, style }: ReviewFormProps) {
27
+ const t = useThemeTokens();
28
+ const {
29
+ status,
30
+ request,
31
+ existingReview: existing,
32
+ rating,
33
+ setRating,
34
+ title,
35
+ setTitle,
36
+ body,
37
+ setBody,
38
+ reviewerName,
39
+ setReviewerName,
40
+ canSubmit,
41
+ isSubmitting,
42
+ submit,
43
+ errorMessage,
44
+ submitted,
45
+ isHeld,
46
+ } = useReviewFormFlow(token);
47
+ const [hoverRating, setHoverRating] = useState(0);
48
+
49
+ const rootStyle: React.CSSProperties = {
50
+ maxWidth: 560,
51
+ margin: "0 auto",
52
+ padding: "24px 16px",
53
+ color: t.text,
54
+ fontFamily: t.fontFamily,
55
+ ...style,
56
+ };
57
+
58
+ if (status === "loading") return <Loading fullPage />;
59
+
60
+ if (status === "invalid" || !request) {
61
+ return (
62
+ <div className={className} style={rootStyle}>
63
+ <Panel>
64
+ <h1 style={h1Style(t)}>This review link isn't valid</h1>
65
+ <p style={{ opacity: 0.75, lineHeight: 1.6 }}>
66
+ The link may have been mistyped or is no longer active. If you received it by email, try opening it again
67
+ from the original message.
68
+ </p>
69
+ </Panel>
70
+ </div>
71
+ );
72
+ }
73
+
74
+ if (status === "expired") {
75
+ return (
76
+ <div className={className} style={rootStyle}>
77
+ <Panel>
78
+ <Clock size={32} color={t.primary} />
79
+ <h1 style={h1Style(t)}>This review link has expired</h1>
80
+ <p style={{ opacity: 0.75, lineHeight: 1.6 }}>
81
+ Review links are only valid for a limited time after your purchase
82
+ {request.entityTitle ? ` of “${request.entityTitle}”` : ""}. Thanks anyway for your support!
83
+ </p>
84
+ </Panel>
85
+ </div>
86
+ );
87
+ }
88
+
89
+ const afterSubmit = submitted;
90
+ if (status === "submitted" && afterSubmit) {
91
+ const held = isHeld;
92
+ return (
93
+ <div className={className} style={rootStyle}>
94
+ <Panel>
95
+ <CheckCircle2 size={36} color={t.primary} />
96
+ <h1 style={h1Style(t)}>Thank you for your review!</h1>
97
+ <StarRow rating={afterSubmit.rating} size={20} />
98
+ {held ? (
99
+ <p style={{ opacity: 0.75, lineHeight: 1.6 }}>
100
+ Your review has been received and is awaiting approval — it will appear once it's been checked.
101
+ </p>
102
+ ) : (
103
+ <p style={{ opacity: 0.75, lineHeight: 1.6 }}>
104
+ Your review is now live{request.entityTitle ? ` on “${request.entityTitle}”` : ""}.
105
+ </p>
106
+ )}
107
+ </Panel>
108
+ </div>
109
+ );
110
+ }
111
+
112
+ if (status === "locked" && existing) {
113
+ return (
114
+ <div className={className} style={rootStyle}>
115
+ <Panel>
116
+ <CheckCircle2 size={32} color={t.primary} />
117
+ <h1 style={h1Style(t)}>You've already reviewed this</h1>
118
+ {request.entityTitle && <p style={{ fontWeight: 600 }}>{request.entityTitle}</p>}
119
+ <StarRow rating={existing.rating} size={20} />
120
+ {existing.title && <p style={{ fontWeight: 700, margin: 0 }}>{existing.title}</p>}
121
+ {existing.body && <p style={{ opacity: 0.85, lineHeight: 1.6, margin: 0 }}>{existing.body}</p>}
122
+ <p style={{ opacity: 0.6, fontSize: 13 }}>
123
+ Reviews can be edited for 30 days after they're written — this one can no longer be changed.
124
+ </p>
125
+ </Panel>
126
+ </div>
127
+ );
128
+ }
129
+
130
+ const displayStars = hoverRating || rating;
131
+
132
+ const handleSubmit = async (e: React.FormEvent) => {
133
+ e.preventDefault();
134
+ await submit();
135
+ };
136
+
137
+ const inputStyle: React.CSSProperties = {
138
+ width: "100%",
139
+ padding: "10px 12px",
140
+ fontSize: 15,
141
+ fontFamily: t.fontFamily,
142
+ color: t.text,
143
+ background: t.surface,
144
+ border: `1px solid ${t.border}`,
145
+ borderRadius: t.cornerRadius,
146
+ outline: "none",
147
+ boxSizing: "border-box",
148
+ };
149
+
150
+ return (
151
+ <div className={className} style={rootStyle}>
152
+ <form
153
+ onSubmit={handleSubmit}
154
+ style={{
155
+ display: "flex",
156
+ flexDirection: "column",
157
+ gap: 18,
158
+ padding: 24,
159
+ border: `1px solid ${t.border}`,
160
+ borderRadius: t.cornerRadius,
161
+ background: t.surface,
162
+ }}
163
+ >
164
+ <div>
165
+ <h1 style={{ ...h1Style(t), marginBottom: 4 }}>
166
+ {existing ? "Edit your review" : "Leave a review"}
167
+ </h1>
168
+ {request.entityTitle && (
169
+ <p style={{ opacity: 0.75, margin: 0 }}>
170
+ How was <span style={{ fontWeight: 700 }}>{request.entityTitle}</span>?
171
+ </p>
172
+ )}
173
+ {existing && (
174
+ <p style={{ fontSize: 13, opacity: 0.6, marginTop: 6 }}>
175
+ You can edit your review within 30 days of writing it. Edits are re-checked before publishing.
176
+ </p>
177
+ )}
178
+ </div>
179
+
180
+ {/* Star picker */}
181
+ <div>
182
+ <label style={labelStyle}>Your rating *</label>
183
+ <div
184
+ style={{ display: "flex", gap: 6 }}
185
+ role="radiogroup"
186
+ aria-label="Rating"
187
+ onMouseLeave={() => setHoverRating(0)}
188
+ >
189
+ {[1, 2, 3, 4, 5].map((star) => (
190
+ <button
191
+ key={star}
192
+ type="button"
193
+ role="radio"
194
+ aria-checked={rating === star}
195
+ aria-label={`${star} star${star === 1 ? "" : "s"}`}
196
+ onClick={() => setRating(star)}
197
+ onMouseEnter={() => setHoverRating(star)}
198
+ style={{ background: "transparent", border: "none", padding: 2, cursor: "pointer", lineHeight: 0 }}
199
+ >
200
+ <Star
201
+ size={30}
202
+ fill={star <= displayStars ? t.primary : "transparent"}
203
+ color={star <= displayStars ? t.primary : a(t.text, 0.3)}
204
+ />
205
+ </button>
206
+ ))}
207
+ </div>
208
+ </div>
209
+
210
+ <div>
211
+ <label htmlFor="review-title" style={labelStyle}>
212
+ Title <span style={{ opacity: 0.5, fontWeight: 400 }}>(optional)</span>
213
+ </label>
214
+ <input
215
+ id="review-title"
216
+ type="text"
217
+ maxLength={200}
218
+ value={title}
219
+ onChange={(e) => setTitle(e.target.value)}
220
+ placeholder="Sum it up in a few words"
221
+ style={inputStyle}
222
+ />
223
+ </div>
224
+
225
+ <div>
226
+ <label htmlFor="review-body" style={labelStyle}>
227
+ Your review <span style={{ opacity: 0.5, fontWeight: 400 }}>(optional)</span>
228
+ </label>
229
+ <textarea
230
+ id="review-body"
231
+ rows={5}
232
+ maxLength={5000}
233
+ value={body}
234
+ onChange={(e) => setBody(e.target.value)}
235
+ placeholder="What did you like or dislike?"
236
+ style={{ ...inputStyle, resize: "vertical" }}
237
+ />
238
+ </div>
239
+
240
+ <div>
241
+ <label htmlFor="review-name" style={labelStyle}>Display name</label>
242
+ <input
243
+ id="review-name"
244
+ type="text"
245
+ maxLength={100}
246
+ value={reviewerName}
247
+ onChange={(e) => setReviewerName(e.target.value)}
248
+ style={inputStyle}
249
+ />
250
+ <p style={{ fontSize: 12, opacity: 0.55, marginTop: 4 }}>Shown publicly next to your review.</p>
251
+ </div>
252
+
253
+ {errorMessage && (
254
+ <p style={{ fontSize: 14, color: "#b91c1c", background: "#b91c1c14", padding: "8px 12px", borderRadius: t.cornerRadius, margin: 0 }}>
255
+ {errorMessage}
256
+ </p>
257
+ )}
258
+
259
+ <Button type="submit" disabled={!canSubmit} loading={isSubmitting} fullWidth>
260
+ {existing ? "Update review" : "Submit review"}
261
+ </Button>
262
+ </form>
263
+ </div>
264
+ );
265
+ }
266
+
267
+ const labelStyle: React.CSSProperties = { display: "block", fontSize: 14, fontWeight: 600, marginBottom: 6 };
268
+
269
+ function h1Style(t: { headingFontFamily?: string }): React.CSSProperties {
270
+ return { fontSize: 24, fontWeight: 800, fontFamily: t.headingFontFamily, margin: 0 };
271
+ }
272
+
273
+ function Panel({ children }: { children: React.ReactNode }) {
274
+ const t = useThemeTokens();
275
+ return (
276
+ <div
277
+ style={{
278
+ display: "flex",
279
+ flexDirection: "column",
280
+ alignItems: "center",
281
+ textAlign: "center",
282
+ gap: 12,
283
+ padding: 32,
284
+ border: `1px solid ${t.border}`,
285
+ borderRadius: t.cornerRadius,
286
+ background: t.surface,
287
+ }}
288
+ >
289
+ {children}
290
+ </div>
291
+ );
292
+ }
@@ -0,0 +1,260 @@
1
+ import { BadgeCheck, MessageSquare, Star, ThumbsUp } from "lucide-react";
2
+ import type { IPublicReview, IReviewAggregate, ReviewEntityType } from "../../types/models";
3
+ import { useReviewsList } from "../headless/reviews/useReviewsList";
4
+ import { useHelpfulVote } from "../headless/reviews/useHelpfulVote";
5
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
6
+ import { Button } from "./Button";
7
+
8
+ const a = (hex: string, al: number) => hex + Math.round(al * 255).toString(16).padStart(2, "0");
9
+
10
+ export interface ReviewsSectionProps {
11
+ /** Which kind of entity these reviews belong to. */
12
+ entityType: ReviewEntityType;
13
+ /** The entity's id (product/course/coaching product UUID). */
14
+ entityId?: string;
15
+ /** Aggregate attached to the detail response — used for instant paint (SSR). */
16
+ initialAggregate?: IReviewAggregate | null;
17
+ /** Section heading. Default "Reviews". */
18
+ title?: string;
19
+ /** Reviews per page. Default 10. */
20
+ pageSize?: number;
21
+ /** Extra class(es) appended to the root element. */
22
+ className?: string;
23
+ /** Inline style merged LAST into the root element. */
24
+ style?: React.CSSProperties;
25
+ }
26
+
27
+ /** Row of 5 stars, filled up to `rating` (rounded to nearest half → nearest whole for fill). */
28
+ export function StarRow({ rating, size = 16, color }: { rating: number; size?: number; color?: string }) {
29
+ const t = useThemeTokens();
30
+ const c = color ?? t.primary;
31
+ return (
32
+ <span style={{ display: "inline-flex", gap: 2, verticalAlign: "middle" }} aria-label={`${rating} out of 5 stars`}>
33
+ {[1, 2, 3, 4, 5].map((star) => (
34
+ <Star key={star} size={size} fill={star <= Math.round(rating) ? c : "transparent"} color={star <= Math.round(rating) ? c : a(c, 0.25)} />
35
+ ))}
36
+ </span>
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Verified-purchase reviews section for detail pages: aggregate header (avg +
42
+ * count + distribution bars), sortable paginated list, verified badges, creator
43
+ * replies and helpful votes (logged-in members). Renders nothing until the
44
+ * entity has at least one published review.
45
+ */
46
+ export function ReviewsSection({
47
+ entityType,
48
+ entityId,
49
+ initialAggregate,
50
+ title = "Reviews",
51
+ pageSize = 10,
52
+ className,
53
+ style,
54
+ }: ReviewsSectionProps) {
55
+ const t = useThemeTokens();
56
+ const { reviews, aggregate, isEmpty, distributionMax, sort, setSort, sortOptions, hasMore, loadMore, isFetching } =
57
+ useReviewsList({ entityType, entityId, pageSize, initialAggregate });
58
+
59
+ // Nothing published yet — keep the page clean.
60
+ if (isEmpty || !aggregate) return null;
61
+
62
+ const maxBucket = distributionMax;
63
+
64
+ return (
65
+ <section
66
+ className={className}
67
+ style={{ marginTop: 40, color: t.text, fontFamily: t.fontFamily, ...style }}
68
+ aria-label={title}
69
+ >
70
+ <h2 style={{ fontSize: 22, fontWeight: 800, marginBottom: 16, fontFamily: t.headingFontFamily }}>{title}</h2>
71
+
72
+ {/* Aggregate header */}
73
+ <div
74
+ style={{
75
+ display: "flex",
76
+ flexWrap: "wrap",
77
+ gap: 24,
78
+ alignItems: "center",
79
+ padding: 16,
80
+ border: `1px solid ${t.border}`,
81
+ borderRadius: t.cornerRadius,
82
+ background: t.surface,
83
+ }}
84
+ >
85
+ <div style={{ textAlign: "center", minWidth: 96 }}>
86
+ <div style={{ fontSize: 40, fontWeight: 800, lineHeight: 1.1 }}>{aggregate.avgRating.toFixed(1)}</div>
87
+ <StarRow rating={aggregate.avgRating} size={18} />
88
+ <div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
89
+ {aggregate.reviewCount} review{aggregate.reviewCount === 1 ? "" : "s"}
90
+ </div>
91
+ </div>
92
+ <div style={{ flex: 1, minWidth: 220, display: "flex", flexDirection: "column", gap: 4 }}>
93
+ {([5, 4, 3, 2, 1] as const).map((star) => {
94
+ const count = aggregate.distribution[String(star) as keyof IReviewAggregate["distribution"]] ?? 0;
95
+ return (
96
+ <div key={star} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13 }}>
97
+ <span style={{ width: 12, textAlign: "right", opacity: 0.8 }}>{star}</span>
98
+ <Star size={12} fill={t.primary} color={t.primary} style={{ flexShrink: 0 }} />
99
+ <div
100
+ style={{
101
+ flex: 1,
102
+ height: 8,
103
+ borderRadius: 4,
104
+ background: a(t.text, 0.08),
105
+ overflow: "hidden",
106
+ }}
107
+ >
108
+ <div
109
+ style={{
110
+ width: `${(count / maxBucket) * 100}%`,
111
+ height: "100%",
112
+ borderRadius: 4,
113
+ background: t.primary,
114
+ transition: "width .2s",
115
+ }}
116
+ />
117
+ </div>
118
+ <span style={{ width: 28, opacity: 0.7 }}>{count}</span>
119
+ </div>
120
+ );
121
+ })}
122
+ </div>
123
+ </div>
124
+
125
+ {/* Sort */}
126
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8, margin: "16px 0" }}>
127
+ {sortOptions.map((s) => {
128
+ const on = sort === s.value;
129
+ return (
130
+ <button
131
+ key={s.value}
132
+ type="button"
133
+ onClick={() => setSort(s.value)}
134
+ style={{
135
+ padding: "6px 12px",
136
+ fontSize: 13,
137
+ fontWeight: 600,
138
+ borderRadius: 999,
139
+ cursor: "pointer",
140
+ color: on ? t.textPrimary : t.text,
141
+ background: on ? t.primary : "transparent",
142
+ border: `1px solid ${on ? t.primary : t.border}`,
143
+ }}
144
+ >
145
+ {s.label}
146
+ </button>
147
+ );
148
+ })}
149
+ </div>
150
+
151
+ {/* List */}
152
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
153
+ {reviews.map((review) => (
154
+ <ReviewCard key={review.id} review={review} />
155
+ ))}
156
+ </div>
157
+
158
+ {hasMore && (
159
+ <div style={{ marginTop: 16 }}>
160
+ <Button variant="outline" size="sm" loading={isFetching} onClick={loadMore}>
161
+ Show more reviews
162
+ </Button>
163
+ </div>
164
+ )}
165
+ </section>
166
+ );
167
+ }
168
+
169
+ function ReviewCard({ review }: { review: IPublicReview }) {
170
+ const t = useThemeTokens();
171
+ const { voted, helpfulCount, toggle: toggleVote, canVote: isAuthenticated } = useHelpfulVote(review);
172
+
173
+ const date = review.publishedAt ?? review.createdAt;
174
+ const dateLabel = date
175
+ ? new Date(date).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
176
+ : "";
177
+
178
+ return (
179
+ <article
180
+ style={{
181
+ padding: 16,
182
+ border: `1px solid ${t.border}`,
183
+ borderRadius: t.cornerRadius,
184
+ display: "flex",
185
+ flexDirection: "column",
186
+ gap: 8,
187
+ }}
188
+ >
189
+ <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
190
+ <StarRow rating={review.rating} size={14} />
191
+ {review.title && <span style={{ fontWeight: 700, fontSize: 15 }}>{review.title}</span>}
192
+ </div>
193
+
194
+ {review.body && <p style={{ fontSize: 14, lineHeight: 1.6, opacity: 0.9, margin: 0 }}>{review.body}</p>}
195
+
196
+ <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 10, fontSize: 13, opacity: 0.85 }}>
197
+ <span style={{ fontWeight: 600 }}>{review.reviewerName || "Verified Buyer"}</span>
198
+ {review.isVerified && (
199
+ <span
200
+ style={{
201
+ display: "inline-flex",
202
+ alignItems: "center",
203
+ gap: 4,
204
+ fontSize: 12,
205
+ fontWeight: 600,
206
+ padding: "2px 8px",
207
+ borderRadius: 999,
208
+ color: t.primary,
209
+ background: a(t.primary, 0.1),
210
+ border: `1px solid ${a(t.primary, 0.3)}`,
211
+ }}
212
+ >
213
+ <BadgeCheck size={13} /> Verified purchase
214
+ </span>
215
+ )}
216
+ {dateLabel && <span style={{ opacity: 0.6 }}>{dateLabel}</span>}
217
+ <button
218
+ type="button"
219
+ onClick={toggleVote}
220
+ disabled={!isAuthenticated}
221
+ title={isAuthenticated ? (voted ? "Remove helpful vote" : "Mark as helpful") : "Log in to vote"}
222
+ style={{
223
+ display: "inline-flex",
224
+ alignItems: "center",
225
+ gap: 5,
226
+ marginLeft: "auto",
227
+ padding: "4px 10px",
228
+ fontSize: 12,
229
+ fontWeight: 600,
230
+ borderRadius: 999,
231
+ cursor: isAuthenticated ? "pointer" : "default",
232
+ color: voted ? t.textPrimary : t.text,
233
+ background: voted ? t.primary : "transparent",
234
+ border: `1px solid ${voted ? t.primary : t.border}`,
235
+ opacity: isAuthenticated ? 1 : 0.55,
236
+ }}
237
+ >
238
+ <ThumbsUp size={13} /> Helpful{helpfulCount > 0 ? ` (${helpfulCount})` : ""}
239
+ </button>
240
+ </div>
241
+
242
+ {review.reply && (
243
+ <div
244
+ style={{
245
+ marginTop: 4,
246
+ padding: 12,
247
+ borderRadius: Math.max(6, t.cornerRadius - 2),
248
+ background: a(t.text, 0.05),
249
+ borderLeft: `3px solid ${t.primary}`,
250
+ }}
251
+ >
252
+ <div style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 12, fontWeight: 700, marginBottom: 4 }}>
253
+ <MessageSquare size={13} color={t.primary} /> Response from the creator
254
+ </div>
255
+ <p style={{ fontSize: 13, lineHeight: 1.6, margin: 0, opacity: 0.9 }}>{review.reply.body}</p>
256
+ </div>
257
+ )}
258
+ </article>
259
+ );
260
+ }
@@ -0,0 +1,146 @@
1
+ import { usePublicForm } from "../../data/queries/useForms";
2
+ import { useSectionedForm } from "../headless/forms/useSectionedForm";
3
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
4
+ import { readableTextOn } from "../theme/contrast";
5
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
6
+ import { Loading } from "./Loading";
7
+ import { FormRenderer, FieldInput } from "./FormRenderer";
8
+
9
+ export interface SectionedFormRendererProps {
10
+ formId: string;
11
+ className?: string;
12
+ style?: React.CSSProperties;
13
+ }
14
+
15
+ /**
16
+ * Themed multi-section form wizard: renders one section (page) of fields at a
17
+ * time with Back/Next, in-page field visibility, per-section validation, and
18
+ * section→section branching — built on `useSectionedForm`. Forms with no
19
+ * sections fall back to the flat `<FormRenderer>`, so this is a safe drop-in for
20
+ * any form.
21
+ */
22
+ export function SectionedFormRenderer({ formId, className, style }: SectionedFormRendererProps) {
23
+ const { data: form, isLoading } = usePublicForm(formId);
24
+ if (isLoading || !form) return <Loading fullPage />;
25
+ // No sections → the flat renderer (single page, one submit).
26
+ if (!form.sections || form.sections.length === 0) return <FormRenderer formId={formId} />;
27
+ return <Wizard formId={formId} className={className} style={style} />;
28
+ }
29
+
30
+ function Wizard({ formId, className, style }: SectionedFormRendererProps) {
31
+ const t = useThemeTokens();
32
+ const theme = useForgeTheme();
33
+ const {
34
+ form,
35
+ currentSection,
36
+ visibleFields,
37
+ answers,
38
+ setAnswer,
39
+ error,
40
+ next,
41
+ back,
42
+ canGoBack,
43
+ isSubmitStep,
44
+ isSubmitting,
45
+ isCompleted,
46
+ progress,
47
+ } = useSectionedForm(formId);
48
+
49
+ const settings = typeof form?.settings === "string" ? JSON.parse(form.settings) : form?.settings || {};
50
+ const wrap: React.CSSProperties = {
51
+ maxWidth: 560,
52
+ margin: "0 auto",
53
+ color: t.text,
54
+ fontFamily: t.fontFamily,
55
+ ...style,
56
+ };
57
+ const onPrimary = t.textPrimary || readableTextOn(t.primary);
58
+
59
+ if (isCompleted) {
60
+ return (
61
+ <div className={className} style={{ ...wrap, textAlign: "center" }}>
62
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12, fontFamily: t.headingFontFamily }}>
63
+ {settings.thankYouTitle || "Thank you!"}
64
+ </h1>
65
+ <p style={{ opacity: 0.8 }}>{settings.thankYouMessage || "Your response has been recorded."}</p>
66
+ </div>
67
+ );
68
+ }
69
+
70
+ return (
71
+ <div className={className} style={wrap}>
72
+ {/* Progress */}
73
+ {settings.showProgressBar !== false && (
74
+ <div style={{ height: 4, borderRadius: 2, background: `${t.primary}22`, marginBottom: 20 }}>
75
+ <div
76
+ style={{
77
+ width: `${Math.round(progress * 100)}%`,
78
+ height: "100%",
79
+ borderRadius: 2,
80
+ background: t.primary,
81
+ transition: "width .2s ease",
82
+ }}
83
+ />
84
+ </div>
85
+ )}
86
+
87
+ {currentSection?.title && (
88
+ <h2 style={{ fontSize: 22, fontWeight: 800, marginBottom: 6, fontFamily: t.headingFontFamily }}>
89
+ {currentSection.title}
90
+ </h2>
91
+ )}
92
+ {currentSection?.description && <p style={{ opacity: 0.7, marginBottom: 20 }}>{currentSection.description}</p>}
93
+
94
+ <div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
95
+ {visibleFields.map((field) => (
96
+ <FieldInput
97
+ key={field.id}
98
+ field={field}
99
+ value={answers[field.id] || ""}
100
+ onChange={(v) => setAnswer(field.id, v)}
101
+ theme={theme}
102
+ />
103
+ ))}
104
+ </div>
105
+
106
+ {error && <p style={{ color: "#dc2626", marginTop: 16 }}>{error}</p>}
107
+
108
+ <div style={{ display: "flex", justifyContent: "space-between", gap: 12, marginTop: 24 }}>
109
+ <button
110
+ type="button"
111
+ onClick={back}
112
+ disabled={!canGoBack || isSubmitting}
113
+ style={{
114
+ padding: "12px 20px",
115
+ borderRadius: t.cornerRadius,
116
+ border: `1px solid ${t.border}`,
117
+ background: "transparent",
118
+ color: t.text,
119
+ fontWeight: 600,
120
+ cursor: canGoBack ? "pointer" : "not-allowed",
121
+ opacity: canGoBack ? 1 : 0.4,
122
+ }}
123
+ >
124
+ Back
125
+ </button>
126
+ <button
127
+ type="button"
128
+ onClick={next}
129
+ disabled={isSubmitting}
130
+ style={{
131
+ padding: "12px 24px",
132
+ borderRadius: t.cornerRadius,
133
+ border: "none",
134
+ fontWeight: 600,
135
+ cursor: "pointer",
136
+ opacity: isSubmitting ? 0.5 : 1,
137
+ background: t.primary,
138
+ color: onPrimary,
139
+ }}
140
+ >
141
+ {isSubmitting ? "Submitting…" : isSubmitStep ? settings.submitButtonText || "Submit" : "Next"}
142
+ </button>
143
+ </div>
144
+ </div>
145
+ );
146
+ }