@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,616 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { Play, Pause, Music, Star } from "lucide-react";
3
+ import { ProductCategory } from "../../types/models";
4
+ import type { IPublicProduct, IPublicProductTrack, IPublicProductVariant } from "../../types/models";
5
+ import { useGetProduct } from "../../data/queries/useProducts";
6
+ import { useCart } from "../../contexts/CartContext";
7
+ import { useAudioPlayer } from "../../contexts/AudioPlayerContext";
8
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
9
+ import { readableTextOn } from "../theme/contrast";
10
+ import { useAmountFormatter } from "../format/useFormatCurrency";
11
+ import { PriceDisplay } from "../format/PriceDisplay";
12
+ import { usePricesIncludeTax } from "../../data/queries/useWebsite";
13
+ import { Button } from "./Button";
14
+ import { Loading } from "./Loading";
15
+ import { ReviewsSection } from "./ReviewsSection";
16
+
17
+ export interface ProductDetailProps {
18
+ /** Product slug (resolves the product). */
19
+ slug?: string;
20
+ /** Format a numeric amount for display. */
21
+ formatAmount?: (amount: number) => string;
22
+ /** Where "Buy now" sends the buyer after adding to cart. Default `/i/checkout`. */
23
+ checkoutPath?: string;
24
+ /** Extra class(es) appended to the root element. */
25
+ className?: string;
26
+ /** Inline style merged LAST into the root element (callers can override). */
27
+ style?: React.CSSProperties;
28
+ /** Server-fetched product used as initial data (SSR), avoids client loading spinner. */
29
+ initialProduct?: IPublicProduct;
30
+ }
31
+
32
+ const a = (hex: string, al: number) => hex + Math.round(al * 255).toString(16).padStart(2, "0");
33
+
34
+ /**
35
+ * Drop-in product page. **Music** products (category `Music`) get an album/single
36
+ * preview with per-track playback; everything else — including Digital products
37
+ * (whose downloadable file is stored as a track) — gets the standard product
38
+ * layout. Renders the description as sanitized-by-source HTML with responsive
39
+ * media (no horizontal overflow from embedded video/images).
40
+ */
41
+ export function ProductDetail({ slug, formatAmount, checkoutPath = "/i/checkout", className, style, initialProduct }: ProductDetailProps) {
42
+ const t = useThemeTokens();
43
+ const { data: product, isLoading } = useGetProduct(slug, { initialData: initialProduct });
44
+ const fmt = useAmountFormatter(formatAmount);
45
+
46
+ if (isLoading) return <Loading fullPage />;
47
+ if (!product) return <p style={{ color: t.text }}>Product not found.</p>;
48
+
49
+ // Classify on the product's category, NOT "has tracks" — a Digital product
50
+ // stores its download as a track too, so track-presence would misrender it as
51
+ // music (audio player over a PDF, etc.).
52
+ const isMusic = product.category === ProductCategory.Music;
53
+ return isMusic ? (
54
+ <MusicDetail product={product} fmt={fmt} checkoutPath={checkoutPath} className={className} style={style} />
55
+ ) : (
56
+ <StandardDetail product={product} fmt={fmt} checkoutPath={checkoutPath} className={className} style={style} />
57
+ );
58
+ }
59
+
60
+ // ── Rich description (responsive media) ───────────────────────────────────────
61
+ const RICH_CSS =
62
+ ".forge-rich{overflow-wrap:break-word;line-height:1.6}" +
63
+ ".forge-rich img,.forge-rich video{max-width:100%;height:auto;border-radius:8px}" +
64
+ ".forge-rich iframe{max-width:100%;width:100%;aspect-ratio:16/9;border:0;border-radius:8px}" +
65
+ ".forge-rich table{max-width:100%;display:block;overflow-x:auto}";
66
+
67
+ function RichText({ html }: { html: string }) {
68
+ return (
69
+ <>
70
+ <style>{RICH_CSS}</style>
71
+ <div className="forge-rich" style={{ overflowWrap: "break-word" }} dangerouslySetInnerHTML={{ __html: html }} />
72
+ </>
73
+ );
74
+ }
75
+
76
+ // ── Shared buy actions ────────────────────────────────────────────────────────
77
+ function useBuy(product: IPublicProduct, cover: string | undefined, checkoutPath: string) {
78
+ const { addToCart } = useCart();
79
+ const add = (variant: IPublicProductVariant, opts?: { canIncreaseQuantity?: boolean; quantity?: number; coverImage?: string }) => {
80
+ addToCart({
81
+ productId: product.id,
82
+ productVariantId: variant.id,
83
+ title: product.title,
84
+ price: variant.price,
85
+ coverImage: opts?.coverImage ?? cover,
86
+ isGift: false,
87
+ quantity: opts?.quantity ?? 1,
88
+ canIncreaseQuantity: opts?.canIncreaseQuantity ?? true,
89
+ payWhatYouWant: !!variant.payWhatYouWant,
90
+ color: variant.color,
91
+ size: variant.size,
92
+ deliveryType: variant.deliveryType,
93
+ });
94
+ };
95
+ const buyNow = (variant: IPublicProductVariant, opts?: { canIncreaseQuantity?: boolean }) => {
96
+ add(variant, opts);
97
+ if (typeof window !== "undefined") window.location.href = checkoutPath;
98
+ };
99
+ return { add, buyNow };
100
+ }
101
+
102
+ // ── Standard product ──────────────────────────────────────────────────────────
103
+ function StandardDetail({
104
+ product,
105
+ fmt,
106
+ checkoutPath,
107
+ className,
108
+ style,
109
+ }: {
110
+ product: IPublicProduct;
111
+ fmt: (n: number) => string;
112
+ checkoutPath: string;
113
+ className?: string;
114
+ style?: React.CSSProperties;
115
+ }) {
116
+ const t = useThemeTokens();
117
+ // Inclusive stores caption the price as tax-inclusive (display only).
118
+ const pricesIncludeTax = usePricesIncludeTax();
119
+ const cover = product.media?.find((m) => m.type === "image")?.url ?? product.media?.[0]?.url;
120
+ const { add } = useBuy(product, cover, checkoutPath);
121
+ const { isCartOpen } = useCart();
122
+
123
+ const [selectedColor, setSelectedColor] = useState<string | null>(null);
124
+ const [selectedSize, setSelectedSize] = useState<string | null>(null);
125
+ const [quantity, setQuantity] = useState(1);
126
+ const [imageIndex, setImageIndex] = useState(0);
127
+
128
+ // Colors that exist across variants (each flagged if it has any in-stock size).
129
+ const availableColors = useMemo(() => {
130
+ const colors = Array.from(new Set(product.variants.map((v) => v.color)));
131
+ return colors
132
+ .map((color) => ({
133
+ name: color,
134
+ hasAvailableSize: product.variants.some((v) => v.color === color && v.availabilityStatus === "active"),
135
+ }))
136
+ .filter((c) => c.name);
137
+ }, [product.variants]);
138
+
139
+ // Sizes for the chosen color.
140
+ const availableSizes = useMemo(() => {
141
+ if (!selectedColor) return [];
142
+ return product.variants
143
+ .filter((v) => v.color === selectedColor)
144
+ .map((v) => ({ name: v.size, isAvailable: v.availabilityStatus === "active" }))
145
+ .filter((s) => s.name);
146
+ }, [selectedColor, product.variants]);
147
+
148
+ // Products without color/size options (digital, service, simple physical) just
149
+ // use the default variant; otherwise the buyer must pick color + size.
150
+ const hasOptions = availableColors.length > 0;
151
+ const selectedVariant = useMemo(() => {
152
+ if (!hasOptions) return product.variants?.find((v) => v.isDefault) ?? product.variants?.[0];
153
+ if (!selectedColor || !selectedSize) return undefined;
154
+ return product.variants.find((v) => v.color === selectedColor && v.size === selectedSize);
155
+ }, [hasOptions, selectedColor, selectedSize, product.variants]);
156
+
157
+ // Prefer the selected variant's own images; fall back to the product's.
158
+ const images = useMemo(() => {
159
+ const vImgs = selectedVariant?.media?.filter((m) => m.type === "image") ?? [];
160
+ return vImgs.length > 0 ? vImgs : product.media?.filter((m) => m.type === "image") ?? [];
161
+ }, [selectedVariant, product.media]);
162
+
163
+ // Auto-select the first available color, then its first available size.
164
+ useEffect(() => {
165
+ if (!selectedColor && availableColors.length > 0) {
166
+ const first = availableColors.find((c) => c.hasAvailableSize) ?? availableColors[0];
167
+ setSelectedColor(first.name);
168
+ }
169
+ }, [availableColors, selectedColor]);
170
+ useEffect(() => {
171
+ if (selectedColor && !selectedSize && availableSizes.length > 0) {
172
+ const first = availableSizes.find((s) => s.isAvailable) ?? availableSizes[0];
173
+ setSelectedSize(first.name);
174
+ }
175
+ }, [selectedColor, selectedSize, availableSizes]);
176
+ useEffect(() => setImageIndex(0), [selectedVariant]);
177
+
178
+ // Mobile: once the inline Add-to-cart scrolls out of view, surface a sticky
179
+ // bottom bar so the primary action stays reachable. Hidden while the cart
180
+ // drawer is open so it doesn't overlap the drawer's checkout button.
181
+ const addRef = useRef<HTMLDivElement>(null);
182
+ const [showSticky, setShowSticky] = useState(false);
183
+ useEffect(() => {
184
+ const el = addRef.current;
185
+ if (!el || typeof IntersectionObserver === "undefined") return;
186
+ const obs = new IntersectionObserver(([entry]) => setShowSticky(!entry.isIntersecting), { threshold: 0 });
187
+ obs.observe(el);
188
+ return () => obs.disconnect();
189
+ }, []);
190
+
191
+ const outOfStock = !!selectedVariant && selectedVariant.availabilityStatus !== "active";
192
+ const canAdd = !!selectedVariant && !outOfStock;
193
+ const label = !selectedVariant ? "Select options" : outOfStock ? "Out of stock" : "Add to cart";
194
+ const addToCart = () => {
195
+ if (!canAdd || !selectedVariant) return;
196
+ add(selectedVariant, { quantity, coverImage: images[imageIndex]?.url ?? images[0]?.url });
197
+ };
198
+ const price = selectedVariant?.price ?? product.variants?.[0]?.price ?? 0;
199
+
200
+ const AddButton = ({ innerRef }: { innerRef?: React.Ref<HTMLDivElement> }) => (
201
+ <div ref={innerRef}>
202
+ <Button fullWidth disabled={!canAdd} onClick={addToCart}>
203
+ {label}
204
+ </Button>
205
+ </div>
206
+ );
207
+
208
+ return (
209
+ <>
210
+ <div
211
+ className={`grid grid-cols-1 md:grid-cols-2 gap-8${className ? ` ${className}` : ""}`}
212
+ style={{ color: t.text, fontFamily: t.fontFamily, ...style }}
213
+ >
214
+ {/* Gallery */}
215
+ <div>
216
+ {images[imageIndex]?.url ? (
217
+ <img
218
+ src={images[imageIndex].url}
219
+ alt={product.title}
220
+ style={{ width: "100%", aspectRatio: "1", objectFit: "cover", objectPosition: "top", borderRadius: t.cornerRadius }}
221
+ />
222
+ ) : (
223
+ cover && <img src={cover} alt={product.title} style={{ width: "100%", borderRadius: t.cornerRadius, objectFit: "cover", objectPosition: "top" }} />
224
+ )}
225
+ {images.length > 1 && (
226
+ <div style={{ display: "flex", gap: 8, overflowX: "auto", marginTop: 10 }}>
227
+ {images.map((img, i) => (
228
+ <button
229
+ key={img.id ?? i}
230
+ type="button"
231
+ onClick={() => setImageIndex(i)}
232
+ style={{
233
+ flexShrink: 0,
234
+ width: 64,
235
+ height: 64,
236
+ borderRadius: Math.max(6, t.cornerRadius - 2),
237
+ overflow: "hidden",
238
+ padding: 0,
239
+ cursor: "pointer",
240
+ background: "transparent",
241
+ border: `2px solid ${i === imageIndex ? t.primary : "transparent"}`,
242
+ }}
243
+ >
244
+ <img src={img.url} alt="" style={{ width: "100%", height: "100%", objectFit: "cover", objectPosition: "top" }} />
245
+ </button>
246
+ ))}
247
+ </div>
248
+ )}
249
+ </div>
250
+
251
+ {/* Info */}
252
+ <div style={{ minWidth: 0 }}>
253
+ <h1 style={{ fontSize: 28, fontWeight: 800, fontFamily: t.headingFontFamily }}>{product.title}</h1>
254
+ {product.artist && <p style={{ opacity: 0.7, marginTop: 4 }}>{product.artist}</p>}
255
+ <p style={{ color: t.primary, fontWeight: 700, fontSize: 20, marginTop: 8 }}>
256
+ <PriceDisplay amount={price} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
257
+ {selectedVariant?.payWhatYouWant && <span style={{ fontSize: 13, opacity: 0.7 }}> (Pay what you want)</span>}
258
+ </p>
259
+
260
+ {/* Color */}
261
+ {availableColors.length > 0 && (
262
+ <div style={{ marginTop: 18 }}>
263
+ <p style={{ fontSize: 15, marginBottom: 8 }}>Color</p>
264
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 10, alignItems: "center", minHeight: 40 }}>
265
+ {availableColors.map((c) => {
266
+ const isHex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(c.name);
267
+ const on = selectedColor === c.name;
268
+ return (
269
+ <button
270
+ key={c.name}
271
+ type="button"
272
+ title={c.name}
273
+ disabled={!c.hasAvailableSize}
274
+ onClick={() => {
275
+ setSelectedColor(c.name);
276
+ setSelectedSize(null);
277
+ }}
278
+ style={{
279
+ position: "relative",
280
+ width: on ? 34 : 30,
281
+ height: on ? 34 : 30,
282
+ borderRadius: "50%",
283
+ cursor: c.hasAvailableSize ? "pointer" : "not-allowed",
284
+ opacity: c.hasAvailableSize ? 1 : 0.5,
285
+ background: isHex ? c.name : c.name.toLowerCase(),
286
+ border: `2px solid ${on ? t.primary : a(t.text, 0.25)}`,
287
+ boxShadow: on ? "0 2px 8px -2px rgba(0,0,0,0.3)" : "none",
288
+ transition: "all .15s",
289
+ }}
290
+ />
291
+ );
292
+ })}
293
+ </div>
294
+ </div>
295
+ )}
296
+
297
+ {/* Size */}
298
+ {selectedColor && availableSizes.length > 0 && (
299
+ <div style={{ marginTop: 18 }}>
300
+ <p style={{ fontSize: 15, marginBottom: 8 }}>Size</p>
301
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 10 }}>
302
+ {availableSizes.map((s) => {
303
+ const on = selectedSize === s.name;
304
+ return (
305
+ <button
306
+ key={s.name}
307
+ type="button"
308
+ disabled={!s.isAvailable}
309
+ onClick={() => setSelectedSize(s.name)}
310
+ style={{
311
+ padding: "8px 16px",
312
+ borderRadius: t.cornerRadius,
313
+ cursor: s.isAvailable ? "pointer" : "not-allowed",
314
+ fontWeight: 600,
315
+ color: s.isAvailable ? t.text : a(t.text, 0.4),
316
+ background: on ? a(t.primary, 0.1) : s.isAvailable ? "transparent" : a(t.text, 0.06),
317
+ border: `2px solid ${on ? t.primary : a(t.text, 0.2)}`,
318
+ }}
319
+ >
320
+ {s.name}
321
+ </button>
322
+ );
323
+ })}
324
+ </div>
325
+ </div>
326
+ )}
327
+
328
+ {/* Quantity */}
329
+ {selectedVariant && (
330
+ <div style={{ marginTop: 18 }}>
331
+ <p style={{ fontSize: 15, marginBottom: 8 }}>Quantity</p>
332
+ <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
333
+ <QtyButton onClick={() => setQuantity((q) => Math.max(1, q - 1))} border={a(t.text, 0.25)} color={t.text}>
334
+
335
+ </QtyButton>
336
+ <span style={{ fontSize: 17, fontWeight: 600, width: 40, textAlign: "center" }}>{quantity}</span>
337
+ <QtyButton onClick={() => setQuantity((q) => q + 1)} border={a(t.text, 0.25)} color={t.text}>
338
+ +
339
+ </QtyButton>
340
+ </div>
341
+ </div>
342
+ )}
343
+
344
+ {/* Add to cart — ABOVE the description. */}
345
+ <div style={{ marginTop: 20 }}>
346
+ <AddButton innerRef={addRef} />
347
+ </div>
348
+
349
+ <div style={{ margin: "16px 0", opacity: 0.9 }}>
350
+ <RichText html={product.description || ""} />
351
+ </div>
352
+
353
+ {/* Product details */}
354
+ {selectedVariant && (
355
+ <div style={{ marginTop: 20, paddingTop: 16, borderTop: `1px solid ${a(t.text, 0.15)}` }}>
356
+ <p style={{ fontWeight: 700, fontSize: 13, opacity: 0.7, marginBottom: 6 }}>Product details</p>
357
+ <p style={{ fontSize: 14, opacity: 0.75 }}>Category: {product.category}</p>
358
+ <p style={{ fontSize: 14, opacity: 0.75 }}>SKU: {selectedVariant.upcCode || "N/A"}</p>
359
+ </div>
360
+ )}
361
+
362
+ {product.credits && (
363
+ <div style={{ marginTop: 20 }}>
364
+ <p style={{ fontWeight: 700, fontSize: 13, opacity: 0.7 }}>Credits</p>
365
+ <p style={{ whiteSpace: "pre-line", fontSize: 14, opacity: 0.7 }}>{product.credits}</p>
366
+ </div>
367
+ )}
368
+ </div>
369
+ </div>
370
+
371
+ {/* Verified-purchase reviews */}
372
+ <ReviewsSection entityType="product" entityId={product.id} initialAggregate={product.reviewAggregate} />
373
+
374
+ {/* Mobile sticky Add-to-cart (only when the inline one is scrolled past). */}
375
+ {showSticky && !isCartOpen && (
376
+ <div
377
+ className="md:hidden"
378
+ style={{
379
+ position: "fixed",
380
+ bottom: 0,
381
+ left: 0,
382
+ right: 0,
383
+ zIndex: 50,
384
+ padding: 12,
385
+ background: t.surface,
386
+ borderTop: `1px solid ${t.border}`,
387
+ boxShadow: "0 -8px 24px -16px rgba(0,0,0,0.4)",
388
+ }}
389
+ >
390
+ <AddButton />
391
+ </div>
392
+ )}
393
+ </>
394
+ );
395
+ }
396
+
397
+ function QtyButton({ onClick, border, color, children }: { onClick: () => void; border: string; color: string; children: React.ReactNode }) {
398
+ return (
399
+ <button
400
+ type="button"
401
+ onClick={onClick}
402
+ style={{
403
+ width: 40,
404
+ height: 40,
405
+ borderRadius: 8,
406
+ border: `1px solid ${border}`,
407
+ background: "transparent",
408
+ color,
409
+ fontSize: 18,
410
+ cursor: "pointer",
411
+ display: "inline-flex",
412
+ alignItems: "center",
413
+ justifyContent: "center",
414
+ }}
415
+ >
416
+ {children}
417
+ </button>
418
+ );
419
+ }
420
+
421
+ // ── Music product (album / single preview) ────────────────────────────────────
422
+ function MusicDetail({
423
+ product,
424
+ fmt,
425
+ checkoutPath,
426
+ className,
427
+ style,
428
+ }: {
429
+ product: IPublicProduct;
430
+ fmt: (n: number) => string;
431
+ checkoutPath: string;
432
+ className?: string;
433
+ style?: React.CSSProperties;
434
+ }) {
435
+ const t = useThemeTokens();
436
+ // Inclusive stores caption the price as tax-inclusive (display only).
437
+ const pricesIncludeTax = usePricesIncludeTax();
438
+ const { pause, loadAndPlay, currentTrack, play, isPlaying } = useAudioPlayer();
439
+
440
+ const defaultVariant = product.variants.find((v) => v.isDefault) || product.variants[0];
441
+ const tracks = defaultVariant?.tracks ?? [];
442
+ const isSingle = tracks.length === 1;
443
+ const firstTrack = tracks[0];
444
+ const cover =
445
+ product.media.find((m) => m.type === "image")?.url ?? defaultVariant?.media.find((m) => m.type === "image")?.url;
446
+ const belongsToProduct = currentTrack?.productId === product.id;
447
+ const { add, buyNow } = useBuy(product, cover, checkoutPath);
448
+
449
+ const handlePlayPause = (track: IPublicProductTrack) => {
450
+ if (isPlaying && currentTrack?.id === track.id) return pause();
451
+ if (currentTrack?.id === track.id) return play();
452
+ const audioUrl = track.media.find((m) => m.type === "audio")?.url;
453
+ if (!audioUrl) return;
454
+ const toTrack = (t: IPublicProductTrack) => ({
455
+ id: t.id,
456
+ title: t.title,
457
+ url: t.media.find((m) => m.type === "audio")?.url ?? t.media[0]?.url ?? "",
458
+ coverImage: cover,
459
+ artist: t.artist || product.artist || "Unknown Artist",
460
+ productId: product.id,
461
+ });
462
+ loadAndPlay(toTrack(track), isSingle ? [] : tracks.map(toTrack));
463
+ };
464
+
465
+ if (!defaultVariant || !firstTrack) return <p style={{ color: t.text }}>No preview available.</p>;
466
+
467
+ const border = `1px solid ${a(t.primary, 0.2)}`;
468
+ const onPrimary = readableTextOn(t.primary);
469
+
470
+ return (
471
+ <div
472
+ className={className}
473
+ style={{ maxWidth: 900, margin: "0 auto", color: t.text, fontFamily: t.fontFamily, ...style }}
474
+ >
475
+ <div style={{ border, borderRadius: t.cornerRadius }}>
476
+ {/* Header */}
477
+ <div style={{ padding: 24, borderBottom: border }}>
478
+ <div className="flex flex-col md:flex-row" style={{ gap: 24 }}>
479
+ {/* Cover + play overlay */}
480
+ <div style={{ position: "relative", flexShrink: 0 }}>
481
+ <div className="w-full md:w-64 md:h-64" style={{ aspectRatio: "1 / 1", borderRadius: t.cornerRadius, overflow: "hidden", background: t.primary }}>
482
+ {cover ? (
483
+ <img src={cover} alt={product.title} style={{ width: "100%", height: "100%", objectFit: "cover", objectPosition: "top" }} />
484
+ ) : (
485
+ <div style={{ width: "100%", height: "100%", display: "grid", placeItems: "center" }}>
486
+ <Music size={64} color={onPrimary} />
487
+ </div>
488
+ )}
489
+ </div>
490
+ <button
491
+ aria-label={isPlaying && belongsToProduct ? "Pause" : "Play"}
492
+ onClick={() => {
493
+ if (!currentTrack || !belongsToProduct || currentTrack.id === firstTrack.id) return handlePlayPause(firstTrack);
494
+ return isPlaying ? pause() : play();
495
+ }}
496
+ style={{
497
+ position: "absolute",
498
+ left: 14,
499
+ bottom: 14,
500
+ background: t.primary,
501
+ color: onPrimary,
502
+ border: "none",
503
+ borderRadius: "9999px",
504
+ width: 52,
505
+ height: 52,
506
+ display: "grid",
507
+ placeItems: "center",
508
+ cursor: "pointer",
509
+ boxShadow: "0 4px 14px rgba(0,0,0,0.25)",
510
+ }}
511
+ >
512
+ {isPlaying && belongsToProduct ? <Pause size={26} /> : <Play size={26} />}
513
+ </button>
514
+ </div>
515
+
516
+ {/* Info */}
517
+ <div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 10 }}>
518
+ <div>
519
+ <h1 style={{ fontSize: 28, fontWeight: 800, fontFamily: t.headingFontFamily }}>{product.title}</h1>
520
+ <p style={{ fontSize: 14, fontWeight: 600, marginTop: 4, opacity: 0.85 }}>{product.artist}</p>
521
+ </div>
522
+ <span
523
+ style={{
524
+ alignSelf: "flex-start",
525
+ fontSize: 12,
526
+ fontWeight: 600,
527
+ padding: "3px 10px",
528
+ borderRadius: 999,
529
+ color: t.text,
530
+ border: `1px solid ${a(t.text, 0.3)}`,
531
+ background: a(t.text, 0.08),
532
+ }}
533
+ >
534
+ {isSingle ? "Single" : `Album · ${tracks.length} tracks`}
535
+ </span>
536
+ <div style={{ fontSize: 20, fontWeight: 700, color: t.primary }}>
537
+ <PriceDisplay amount={defaultVariant.price} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
538
+ </div>
539
+ <div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginTop: 4 }}>
540
+ <Button onClick={() => buyNow(defaultVariant, { canIncreaseQuantity: false })}>Buy now</Button>
541
+ <Button variant="outline" onClick={() => add(defaultVariant, { canIncreaseQuantity: false })}>
542
+ Add to cart
543
+ </Button>
544
+ </div>
545
+ </div>
546
+ </div>
547
+ </div>
548
+
549
+ {/* Description */}
550
+ {product.description && (
551
+ <div style={{ padding: 24, opacity: 0.85, fontSize: 14 }}>
552
+ <RichText html={product.description} />
553
+ </div>
554
+ )}
555
+
556
+ {/* Tracks (album only) */}
557
+ {!isSingle && (
558
+ <div style={{ padding: 24, display: "flex", flexDirection: "column", gap: 8 }}>
559
+ {tracks.map((track) => {
560
+ const audioUrl = track.media.find((m) => m.type === "audio")?.url;
561
+ const active = currentTrack?.id === track.id;
562
+ return (
563
+ <div key={track.id} style={{ border, borderRadius: t.cornerRadius, padding: 14, display: "flex", alignItems: "center", gap: 14 }}>
564
+ <button
565
+ onClick={() => handlePlayPause(track)}
566
+ disabled={!audioUrl}
567
+ aria-label={active && isPlaying ? "Pause" : "Play"}
568
+ style={{
569
+ width: 44,
570
+ height: 44,
571
+ flexShrink: 0,
572
+ borderRadius: "9999px",
573
+ border: "none",
574
+ background: t.primary,
575
+ color: onPrimary,
576
+ cursor: audioUrl ? "pointer" : "not-allowed",
577
+ display: "grid",
578
+ placeItems: "center",
579
+ }}
580
+ >
581
+ {active && isPlaying ? <Pause size={18} /> : <Play size={18} />}
582
+ </button>
583
+ <div style={{ flex: 1, minWidth: 0 }}>
584
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
585
+ <span style={{ fontWeight: 600, fontSize: 14, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
586
+ {track.title || product.title}
587
+ </span>
588
+ {track.hasExplicitContent && (
589
+ <span style={{ fontSize: 10, padding: "0 4px", borderRadius: 3, border: `1px solid ${a(t.primary, 0.3)}`, background: a(t.primary, 0.1) }}>
590
+ E
591
+ </span>
592
+ )}
593
+ {track.isFeatured && <Star size={14} color={t.primary} />}
594
+ </div>
595
+ <div style={{ fontSize: 12, opacity: 0.7 }}>{track.artist || product.artist}</div>
596
+ </div>
597
+ </div>
598
+ );
599
+ })}
600
+ </div>
601
+ )}
602
+
603
+ {/* Credits */}
604
+ {product.credits && (
605
+ <div style={{ padding: 24, borderTop: border }}>
606
+ <p style={{ fontWeight: 700, fontSize: 13, opacity: 0.7 }}>Credits</p>
607
+ <p style={{ whiteSpace: "pre-line", fontSize: 14, opacity: 0.7 }}>{product.credits}</p>
608
+ </div>
609
+ )}
610
+ </div>
611
+
612
+ {/* Verified-purchase reviews */}
613
+ <ReviewsSection entityType="product" entityId={product.id} initialAggregate={product.reviewAggregate} />
614
+ </div>
615
+ );
616
+ }