@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,988 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { useForm, Controller } from "react-hook-form";
3
+ import { CreditCard, MapPin, Package, User } from "lucide-react";
4
+ import { useCheckout, CHECKOUT_STAGE_ORDER, type CheckoutStage } from "../headless/checkout/useCheckout";
5
+ import type { SelectedShippingRate } from "../headless/checkout/useCheckout";
6
+ import type { ShippingRate, ShippingGroup } from "../../data/queries/useShipping";
7
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
8
+ import { readableTextOn } from "../theme/contrast";
9
+ import { useFormatCurrency } from "../format/useFormatCurrency";
10
+ import { summarizeTaxQuote } from "../format/PriceDisplay";
11
+ import { usePricesIncludeTax } from "../../data/queries/useWebsite";
12
+ import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
13
+ import { Loading } from "./Loading";
14
+
15
+ export interface CheckoutProps {
16
+ /** Path used to build the post-payment return URL. Defaults to `/checkout/finalise`. */
17
+ finalisePath?: string;
18
+ /** Where "Continue shopping" (empty cart) goes. Defaults to `/store`. */
19
+ storePath?: string;
20
+ /** Where the guest "Login" button goes (a `?redirect=` back to this page is appended). Defaults to `/login`. */
21
+ loginPath?: string;
22
+ /** SPA navigation. Defaults to a full-page `window.location` redirect. */
23
+ onNavigate?: (path: string) => void;
24
+ className?: string;
25
+ style?: React.CSSProperties;
26
+ }
27
+
28
+ function alpha(value: number) {
29
+ return Math.round(value * 255)
30
+ .toString(16)
31
+ .padStart(2, "0");
32
+ }
33
+
34
+ type GuestFormData = { firstName: string; lastName: string; email: string };
35
+ type ShippingFormData = {
36
+ address1: string;
37
+ city: string;
38
+ stateCode: string;
39
+ countryCode: string;
40
+ zip: string;
41
+ phone: string;
42
+ };
43
+
44
+ const stageIdx = (s: CheckoutStage) => CHECKOUT_STAGE_ORDER.indexOf(s);
45
+
46
+ /**
47
+ * Themed cart checkout — the full staged flow the client app ships, built on
48
+ * `useCheckout`: guest details → (physical only) shipping address + per-product
49
+ * rate selection → create order + start payment → coupon → redirect payment (or
50
+ * a free-order shortcut). Inline-themed from `ForgeThemeProvider`; the payment
51
+ * UI (Stripe) is rendered by Forge itself, and every amount formats through
52
+ * `useFormatCurrency`, whose source currency is the tenant's runtime settlement
53
+ * currency — so nothing here needs a baked currency.
54
+ */
55
+ export function Checkout({
56
+ finalisePath = "/checkout/finalise",
57
+ storePath = "/store",
58
+ loginPath = "/login",
59
+ onNavigate,
60
+ className,
61
+ style,
62
+ }: CheckoutProps) {
63
+ const theme = useForgeTheme();
64
+ const { formatCurrency } = useFormatCurrency();
65
+ // Store display mode (site config) — drives the pre-quote "incl. tax" caption.
66
+ // Once the payment starts, the authoritative taxQuote from start-payment wins.
67
+ const pricesIncludeTax = usePricesIncludeTax();
68
+
69
+ const c = useCheckout({ finalisePath });
70
+ const taxSummary = summarizeTaxQuote(c.taxQuote, formatCurrency);
71
+
72
+ const go = (path: string) => {
73
+ if (onNavigate) onNavigate(path);
74
+ else if (typeof window !== "undefined") window.location.href = path;
75
+ };
76
+
77
+ // Guest + shipping forms (react-hook-form). Declared unconditionally; only the
78
+ // relevant one is rendered per stage.
79
+ const guestForm = useForm<GuestFormData>({ defaultValues: { firstName: "", lastName: "", email: "" } });
80
+ const shippingForm = useForm<ShippingFormData>({
81
+ defaultValues: { address1: "", city: "", stateCode: "", countryCode: "", zip: "", phone: "" },
82
+ });
83
+
84
+ // Reset the chosen state whenever the country changes (form-level).
85
+ const selectedCountryCode = shippingForm.watch("countryCode");
86
+ const selectedCountry = c.countries.find((country) => country.code === selectedCountryCode);
87
+ useEffect(() => {
88
+ if (selectedCountryCode) shippingForm.setValue("stateCode", "");
89
+ }, [selectedCountryCode, shippingForm]);
90
+
91
+ const handleLogin = () => {
92
+ const here = typeof window !== "undefined" ? window.location.pathname : "/checkout";
93
+ go(`${loginPath}?redirect=${encodeURIComponent(here)}`);
94
+ };
95
+
96
+ const handleFreeCheckout = async () => {
97
+ const id = await c.freeCheckout();
98
+ if (id) go(`${finalisePath}?orderId=${id}`);
99
+ };
100
+
101
+ // ── Shared style helpers ────────────────────────────────────────────────────
102
+ const inputStyle: React.CSSProperties = {
103
+ width: "100%",
104
+ padding: "8px 12px",
105
+ border: `1px solid ${theme.colors.primary}${alpha(0.3)}`,
106
+ backgroundColor: theme.colors.background,
107
+ color: theme.colors.text,
108
+ borderRadius: `${theme.cornerRadius}px`,
109
+ };
110
+
111
+ const PrimaryButton = ({
112
+ text,
113
+ onClick,
114
+ type = "button",
115
+ fullWidth,
116
+ disabled,
117
+ }: {
118
+ text: string;
119
+ onClick?: () => void;
120
+ type?: "button" | "submit";
121
+ fullWidth?: boolean;
122
+ disabled?: boolean;
123
+ }) => (
124
+ <button
125
+ type={type}
126
+ onClick={onClick}
127
+ disabled={disabled}
128
+ className={fullWidth ? "w-full" : ""}
129
+ style={{
130
+ padding: "12px 18px",
131
+ backgroundColor: theme.colors.primary,
132
+ color: readableTextOn(theme.colors.primary),
133
+ border: "none",
134
+ borderRadius: `${theme.cornerRadius}px`,
135
+ fontWeight: 600,
136
+ cursor: disabled ? "not-allowed" : "pointer",
137
+ opacity: disabled ? 0.6 : 1,
138
+ }}
139
+ >
140
+ {text}
141
+ </button>
142
+ );
143
+
144
+ const SecondaryButton = ({ text, onClick, fullWidth }: { text: string; onClick?: () => void; fullWidth?: boolean }) => (
145
+ <button
146
+ type="button"
147
+ onClick={onClick}
148
+ className={fullWidth ? "w-full" : ""}
149
+ style={{
150
+ padding: "12px 18px",
151
+ backgroundColor: "transparent",
152
+ color: theme.colors.primary,
153
+ border: `1px solid ${theme.colors.primary}`,
154
+ borderRadius: `${theme.cornerRadius}px`,
155
+ fontWeight: 600,
156
+ cursor: "pointer",
157
+ }}
158
+ >
159
+ {text}
160
+ </button>
161
+ );
162
+
163
+ // The cart loads from storage after mount; don't flash the empty state until we
164
+ // actually know it's empty.
165
+ if (!c.isCartReady) {
166
+ return <Loading fullPage />;
167
+ }
168
+
169
+ if (c.cartItems.length === 0) {
170
+ return (
171
+ <div
172
+ className={["w-full max-w-4xl mx-auto p-6", className].filter(Boolean).join(" ")}
173
+ style={{ backgroundColor: theme.colors.background, color: theme.colors.text, fontFamily: theme.fontFamily, ...style }}
174
+ >
175
+ <div className="text-center py-12">
176
+ <h1 className="text-2xl font-bold mb-4" style={{ color: theme.colors.text }}>
177
+ Your cart is empty
178
+ </h1>
179
+ <p className="mb-6" style={{ color: `${theme.colors.text}${alpha(0.7)}` }}>
180
+ Add some items to your cart before checking out.
181
+ </p>
182
+ <PrimaryButton text="Continue Shopping" onClick={() => go(storePath)} />
183
+ </div>
184
+ </div>
185
+ );
186
+ }
187
+
188
+ const stages = c.hasPhysicalProduct
189
+ ? [
190
+ { key: "userDetails" as const, label: "User", icon: User },
191
+ { key: "shippingAddress" as const, label: "Address", icon: MapPin },
192
+ { key: "shippingRates" as const, label: "Shipping", icon: Package },
193
+ { key: "payment" as const, label: "Payment", icon: CreditCard },
194
+ ]
195
+ : [
196
+ { key: "userDetails" as const, label: "User", icon: User },
197
+ { key: "payment" as const, label: "Payment", icon: CreditCard },
198
+ ];
199
+
200
+ const currentIdx = stageIdx(c.currentStage);
201
+
202
+ return (
203
+ <div
204
+ className={["w-full max-w-6xl mx-auto p-6", className].filter(Boolean).join(" ")}
205
+ style={{ backgroundColor: theme.colors.background, color: theme.colors.text, fontFamily: theme.fontFamily, ...style }}
206
+ >
207
+ {/* Progress Indicator */}
208
+ <div className="mb-8">
209
+ <div className="flex items-center justify-center space-x-4">
210
+ {stages.map((stage, idx) => {
211
+ const reached = currentIdx >= stageIdx(stage.key);
212
+ return (
213
+ <React.Fragment key={stage.key}>
214
+ {idx > 0 && <div className="w-4 h-0.5" style={{ backgroundColor: `${theme.colors.text}${alpha(0.3)}` }} />}
215
+ <div
216
+ className="flex items-center"
217
+ style={{ color: reached ? theme.colors.primary : `${theme.colors.text}${alpha(0.4)}` }}
218
+ >
219
+ <div
220
+ className="w-8 h-8 rounded-full flex items-center justify-center border-2"
221
+ style={{
222
+ borderColor: reached ? theme.colors.primary : `${theme.colors.text}${alpha(0.3)}`,
223
+ backgroundColor: reached ? theme.colors.primary : "transparent",
224
+ color: reached ? "#fff" : theme.colors.text,
225
+ borderRadius: `${theme.cornerRadius}px`,
226
+ }}
227
+ >
228
+ <stage.icon size={16} />
229
+ </div>
230
+ <span className="ml-2">{stage.label}</span>
231
+ </div>
232
+ </React.Fragment>
233
+ );
234
+ })}
235
+ </div>
236
+ </div>
237
+
238
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
239
+ {/* Main Content */}
240
+ <div className="lg:col-span-2">
241
+ {c.pageError && (
242
+ <div
243
+ className="mb-4 p-3 text-sm"
244
+ style={{ color: "#ef4444", border: "1px solid #ef444440", borderRadius: `${theme.cornerRadius}px` }}
245
+ >
246
+ {c.pageError}
247
+ </div>
248
+ )}
249
+
250
+ {/* Stage: User Details */}
251
+ {c.currentStage === "userDetails" && !c.user && (
252
+ <div
253
+ className="rounded-lg shadow-md p-6"
254
+ style={{
255
+ backgroundColor: theme.colors.background,
256
+ border: `1px solid ${theme.colors.primary}${alpha(0.2)}`,
257
+ borderRadius: `${theme.cornerRadius}px`,
258
+ }}
259
+ >
260
+ <div className="pb-6" style={{ borderTop: `1px solid ${theme.colors.primary}${alpha(0.2)}` }}>
261
+ <h3 className="text-lg font-semibold mb-4" style={{ color: theme.colors.text }}>
262
+ Already have an account?
263
+ </h3>
264
+ <PrimaryButton text="Login" onClick={handleLogin} fullWidth />
265
+ </div>
266
+ <h2 className="text-2xl font-bold mb-6" style={{ color: theme.colors.text }}>
267
+ Or
268
+ </h2>
269
+
270
+ <div className="mb-6">
271
+ <h3 className="text-lg font-semibold mb-4" style={{ color: theme.colors.text }}>
272
+ Continue as Guest
273
+ </h3>
274
+ <form onSubmit={guestForm.handleSubmit(c.submitGuest)} className="space-y-4">
275
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
276
+ <div>
277
+ <label className="block text-sm font-medium mb-2" style={{ color: theme.colors.text }}>
278
+ First Name
279
+ </label>
280
+ <Controller
281
+ control={guestForm.control}
282
+ name="firstName"
283
+ rules={{ required: "First name is required" }}
284
+ render={({ field }) => <input {...field} placeholder="First Name" style={inputStyle} />}
285
+ />
286
+ {guestForm.formState.errors.firstName && (
287
+ <p className="text-sm text-red-500 mt-1">{guestForm.formState.errors.firstName.message}</p>
288
+ )}
289
+ </div>
290
+ <div>
291
+ <label className="block text-sm font-medium mb-2" style={{ color: theme.colors.text }}>
292
+ Last Name
293
+ </label>
294
+ <Controller
295
+ control={guestForm.control}
296
+ name="lastName"
297
+ rules={{ required: "Last name is required" }}
298
+ render={({ field }) => <input {...field} placeholder="Last Name" style={inputStyle} />}
299
+ />
300
+ {guestForm.formState.errors.lastName && (
301
+ <p className="text-sm text-red-500 mt-1">{guestForm.formState.errors.lastName.message}</p>
302
+ )}
303
+ </div>
304
+ </div>
305
+ <div>
306
+ <label className="block text-sm font-medium mb-2" style={{ color: theme.colors.text }}>
307
+ Email
308
+ </label>
309
+ <Controller
310
+ control={guestForm.control}
311
+ name="email"
312
+ rules={{
313
+ required: "Email is required",
314
+ pattern: { value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, message: "Invalid email address" },
315
+ }}
316
+ render={({ field }) => <input {...field} type="email" placeholder="Email" style={inputStyle} />}
317
+ />
318
+ {guestForm.formState.errors.email && (
319
+ <p className="text-sm text-red-500 mt-1">{guestForm.formState.errors.email.message}</p>
320
+ )}
321
+ </div>
322
+ <div className="flex items-start gap-2">
323
+ <input
324
+ type="checkbox"
325
+ id="joinEmailList"
326
+ checked={c.joinEmailList}
327
+ onChange={(e) => c.setJoinEmailList(e.target.checked)}
328
+ className="mt-1 h-4 w-4 rounded"
329
+ style={{ accentColor: theme.colors.primary }}
330
+ />
331
+ <label htmlFor="joinEmailList" className="text-sm" style={{ color: theme.colors.text }}>
332
+ Sign up to receive updates
333
+ </label>
334
+ </div>
335
+ <PrimaryButton text="Continue as Guest" type="submit" fullWidth />
336
+ </form>
337
+ </div>
338
+ </div>
339
+ )}
340
+
341
+ {/* Stage: Shipping Address */}
342
+ {c.currentStage === "shippingAddress" && c.hasPhysicalProduct && (
343
+ <div
344
+ className="rounded-lg shadow-md p-6"
345
+ style={{
346
+ backgroundColor: theme.colors.background,
347
+ border: `1px solid ${theme.colors.primary}${alpha(0.2)}`,
348
+ borderRadius: `${theme.cornerRadius}px`,
349
+ }}
350
+ >
351
+ <h2 className="text-2xl font-bold mb-6" style={{ color: theme.colors.text }}>
352
+ Shipping Address
353
+ </h2>
354
+ <form onSubmit={shippingForm.handleSubmit(c.submitAddress)} className="space-y-4">
355
+ <div>
356
+ <label className="block text-sm font-medium mb-2" style={{ color: theme.colors.text }}>
357
+ Address Line 1
358
+ </label>
359
+ <Controller
360
+ control={shippingForm.control}
361
+ name="address1"
362
+ rules={{ required: "Address is required" }}
363
+ render={({ field }) => <input {...field} placeholder="12 address avenue, Bankstown" style={inputStyle} />}
364
+ />
365
+ {shippingForm.formState.errors.address1 && (
366
+ <p className="text-sm text-red-500 mt-1">{shippingForm.formState.errors.address1.message}</p>
367
+ )}
368
+ </div>
369
+
370
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
371
+ <div>
372
+ <label className="block text-sm font-medium mb-2" style={{ color: theme.colors.text }}>
373
+ City
374
+ </label>
375
+ <Controller
376
+ control={shippingForm.control}
377
+ name="city"
378
+ rules={{ required: "City is required" }}
379
+ render={({ field }) => <input {...field} placeholder="Sydney" style={inputStyle} />}
380
+ />
381
+ {shippingForm.formState.errors.city && (
382
+ <p className="text-sm text-red-500 mt-1">{shippingForm.formState.errors.city.message}</p>
383
+ )}
384
+ </div>
385
+
386
+ <div>
387
+ <label className="block text-sm font-medium mb-2" style={{ color: theme.colors.text }}>
388
+ Postal Code
389
+ </label>
390
+ <Controller
391
+ control={shippingForm.control}
392
+ name="zip"
393
+ rules={{ required: "Postal code is required" }}
394
+ render={({ field }) => <input {...field} placeholder="2200" style={inputStyle} />}
395
+ />
396
+ {shippingForm.formState.errors.zip && (
397
+ <p className="text-sm text-red-500 mt-1">{shippingForm.formState.errors.zip.message}</p>
398
+ )}
399
+ </div>
400
+ </div>
401
+
402
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
403
+ <div>
404
+ <label className="block text-sm font-medium mb-2" style={{ color: theme.colors.text }}>
405
+ Country
406
+ </label>
407
+ <Controller
408
+ control={shippingForm.control}
409
+ name="countryCode"
410
+ rules={{ required: "Country is required" }}
411
+ render={({ field }) => (
412
+ <select {...field} style={inputStyle}>
413
+ <option value="">Select Country</option>
414
+ {c.countries.map((country) => (
415
+ <option key={country.code} value={country.code}>
416
+ {country.name}
417
+ </option>
418
+ ))}
419
+ </select>
420
+ )}
421
+ />
422
+ {shippingForm.formState.errors.countryCode && (
423
+ <p className="text-sm text-red-500 mt-1">{shippingForm.formState.errors.countryCode.message}</p>
424
+ )}
425
+ </div>
426
+
427
+ {selectedCountry?.states && selectedCountry.states.length > 0 && (
428
+ <div>
429
+ <label className="block text-sm font-medium mb-2" style={{ color: theme.colors.text }}>
430
+ State/Province
431
+ </label>
432
+ <Controller
433
+ control={shippingForm.control}
434
+ name="stateCode"
435
+ rules={{ required: "State/Province is required" }}
436
+ render={({ field }) => (
437
+ <select {...field} style={inputStyle}>
438
+ <option value="">Select State/Province</option>
439
+ {selectedCountry.states!.map((state) => (
440
+ <option key={state.code} value={state.code}>
441
+ {state.name}
442
+ </option>
443
+ ))}
444
+ </select>
445
+ )}
446
+ />
447
+ {shippingForm.formState.errors.stateCode && (
448
+ <p className="text-sm text-red-500 mt-1">{shippingForm.formState.errors.stateCode.message}</p>
449
+ )}
450
+ </div>
451
+ )}
452
+ </div>
453
+
454
+ <div>
455
+ <label className="block text-sm font-medium mb-2" style={{ color: theme.colors.text }}>
456
+ Phone Number
457
+ </label>
458
+ <Controller
459
+ control={shippingForm.control}
460
+ name="phone"
461
+ rules={{ required: "Phone number is required" }}
462
+ render={({ field }) => <input {...field} placeholder="Phone number" style={inputStyle} />}
463
+ />
464
+ {shippingForm.formState.errors.phone && (
465
+ <p className="text-sm text-red-500 mt-1">{shippingForm.formState.errors.phone.message}</p>
466
+ )}
467
+ </div>
468
+
469
+ <div className="flex gap-4 pt-4">
470
+ {!c.user && <SecondaryButton text="Back" onClick={() => c.setCurrentStage("userDetails")} />}
471
+ <PrimaryButton
472
+ text={c.isFetchingRates ? "Fetching shipping rates..." : "Continue to Shipping"}
473
+ type="submit"
474
+ fullWidth
475
+ disabled={c.isFetchingRates}
476
+ />
477
+ </div>
478
+ </form>
479
+ </div>
480
+ )}
481
+
482
+ {/* Stage: Shipping Rates */}
483
+ {c.currentStage === "shippingRates" && c.hasPhysicalProduct && c.shippingData && (
484
+ <div
485
+ className="rounded-lg shadow-md p-6"
486
+ style={{
487
+ backgroundColor: theme.colors.background,
488
+ border: `1px solid ${theme.colors.primary}${alpha(0.2)}`,
489
+ borderRadius: `${theme.cornerRadius}px`,
490
+ }}
491
+ >
492
+ <h2 className="text-2xl font-bold mb-6" style={{ color: theme.colors.text }}>
493
+ Shipping Options
494
+ </h2>
495
+
496
+ <ShippingRateSelector
497
+ groups={c.shippingGroups}
498
+ selections={c.selections}
499
+ onSelect={c.selectShippingRate}
500
+ formatCurrency={formatCurrency}
501
+ />
502
+
503
+ <div className="flex gap-4 pt-6">
504
+ <SecondaryButton text="Back" onClick={() => c.setCurrentStage("shippingAddress")} />
505
+ <PrimaryButton
506
+ text="Continue to Payment"
507
+ onClick={c.confirmRates}
508
+ fullWidth
509
+ disabled={c.shippingGroups.some((g) => g.selectable && !c.selections[g.id])}
510
+ />
511
+ </div>
512
+ </div>
513
+ )}
514
+
515
+ {/* Stage: Payment */}
516
+ {c.currentStage === "payment" && c.isPaidCheckout && (
517
+ <PaymentSection
518
+ checkout={c}
519
+ onBack={
520
+ c.hasPhysicalProduct
521
+ ? () => c.setCurrentStage("shippingRates")
522
+ : !c.user
523
+ ? () => c.setCurrentStage("userDetails")
524
+ : undefined
525
+ }
526
+ />
527
+ )}
528
+
529
+ {/* Stage: Payment — Free */}
530
+ {c.currentStage === "payment" && !c.isPaidCheckout && (
531
+ <div
532
+ className="rounded-lg shadow-md p-6"
533
+ style={{
534
+ backgroundColor: theme.colors.background,
535
+ border: `1px solid ${theme.colors.primary}${alpha(0.2)}`,
536
+ borderRadius: `${theme.cornerRadius}px`,
537
+ }}
538
+ >
539
+ <h2 className="text-2xl font-bold mb-6" style={{ color: theme.colors.text }}>
540
+ Payment Information
541
+ </h2>
542
+ <p className="text-sm mb-8" style={{ color: `${theme.colors.text}${alpha(0.7)}` }}>
543
+ This is a free checkout. You will not be charged anything.
544
+ </p>
545
+ <PrimaryButton text="Complete Checkout" onClick={handleFreeCheckout} fullWidth disabled={c.isFreeCheckoutLoading} />
546
+ </div>
547
+ )}
548
+ </div>
549
+
550
+ {/* Order Summary */}
551
+ <div className="lg:col-span-1">
552
+ <div
553
+ className="rounded-lg shadow-md p-6 sticky top-6"
554
+ style={{
555
+ backgroundColor: theme.colors.background,
556
+ border: `1px solid ${theme.colors.primary}${alpha(0.2)}`,
557
+ borderRadius: `${theme.cornerRadius}px`,
558
+ }}
559
+ >
560
+ <h3 className="text-xl font-bold mb-4" style={{ color: theme.colors.text }}>
561
+ Order Summary
562
+ </h3>
563
+
564
+ {/* Customer */}
565
+ <div className="mb-4 pb-4" style={{ borderBottom: `1px solid ${theme.colors.primary}${alpha(0.2)}` }}>
566
+ <h4 className="font-semibold mb-2" style={{ color: theme.colors.text }}>
567
+ Customer
568
+ </h4>
569
+ {c.user ? (
570
+ <div className="text-sm">
571
+ <p style={{ color: theme.colors.text }}>
572
+ {c.user?.firstName} {c.user?.lastName}
573
+ </p>
574
+ <p style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>{c.user?.email}</p>
575
+ </div>
576
+ ) : c.guestUserData ? (
577
+ <div className="text-sm">
578
+ <p style={{ color: theme.colors.text }}>
579
+ {c.guestUserData.firstName} {c.guestUserData.lastName}
580
+ </p>
581
+ <p style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>{c.guestUserData.email}</p>
582
+ </div>
583
+ ) : (
584
+ <p className="text-sm" style={{ color: `${theme.colors.text}${alpha(0.5)}` }}>
585
+ Guest checkout
586
+ </p>
587
+ )}
588
+ </div>
589
+
590
+ {/* Shipping Info */}
591
+ {c.hasPhysicalProduct && c.shippingData && (
592
+ <div className="mb-4 pb-4" style={{ borderBottom: `1px solid ${theme.colors.primary}${alpha(0.2)}` }}>
593
+ <h4 className="font-semibold mb-2" style={{ color: theme.colors.text }}>
594
+ Shipping Address
595
+ </h4>
596
+ <div className="text-sm">
597
+ <p style={{ color: theme.colors.text }}>{c.shippingData.address1}</p>
598
+ <p style={{ color: theme.colors.text }}>
599
+ {c.shippingData.city}, {c.shippingData.stateCode} {c.shippingData.zip}
600
+ </p>
601
+ <p style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
602
+ {c.countries.find((cc) => cc.code === c.shippingData!.countryCode)?.name}
603
+ </p>
604
+ </div>
605
+ </div>
606
+ )}
607
+
608
+ {/* Cart Items */}
609
+ <div className="mb-4 pb-4" style={{ borderBottom: `1px solid ${theme.colors.primary}${alpha(0.2)}` }}>
610
+ <h4 className="font-semibold mb-2" style={{ color: theme.colors.text }}>
611
+ Items
612
+ </h4>
613
+ <div className="space-y-2">
614
+ {c.cartItems.map((item) => (
615
+ <div
616
+ key={`${item.productId}-${item.productVariantId}-${item.isGift}-${item.recipientEmail}`}
617
+ className="flex justify-between text-sm"
618
+ >
619
+ <div className="flex-1">
620
+ <p className="font-medium" style={{ color: theme.colors.text }}>
621
+ {item.title}
622
+ </p>
623
+ {item.isGift && (
624
+ <p className="text-xs" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
625
+ Gift for {item.recipientName}
626
+ </p>
627
+ )}
628
+ <p className="text-xs" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
629
+ Qty: {item.quantity}
630
+ </p>
631
+ </div>
632
+ <p className="font-medium" style={{ color: theme.colors.text }}>
633
+ {formatCurrency(item.price * item.quantity)}
634
+ </p>
635
+ </div>
636
+ ))}
637
+ </div>
638
+ </div>
639
+
640
+ {/* Coupon */}
641
+ {c.currentStage === "payment" && c.orderId && (
642
+ <div className="mb-4 pb-4" style={{ borderBottom: `1px solid ${theme.colors.primary}${alpha(0.2)}` }}>
643
+ {!c.showCouponInput && !c.appliedCoupon && (
644
+ <button
645
+ type="button"
646
+ className="text-sm underline"
647
+ style={{ color: theme.colors.primary }}
648
+ onClick={() => c.setShowCouponInput(true)}
649
+ >
650
+ Have a coupon code?
651
+ </button>
652
+ )}
653
+ {c.showCouponInput && !c.appliedCoupon && (
654
+ <div className="flex gap-2 items-end">
655
+ <div className="flex-1">
656
+ <p className="text-sm mb-1" style={{ color: theme.colors.text }}>
657
+ Coupon Code
658
+ </p>
659
+ <input
660
+ placeholder="Enter coupon code"
661
+ style={inputStyle}
662
+ value={c.couponCode}
663
+ onChange={(e) => c.setCouponCode(e.target.value.toUpperCase())}
664
+ />
665
+ </div>
666
+ <PrimaryButton
667
+ text={c.isApplyingCoupon ? "..." : "Apply"}
668
+ onClick={c.applyCoupon}
669
+ disabled={c.isApplyingCoupon || !c.couponCode.trim()}
670
+ />
671
+ </div>
672
+ )}
673
+ {c.couponError && <p className="text-sm text-red-500 mt-1">{c.couponError}</p>}
674
+ {c.appliedCoupon && (
675
+ <div
676
+ className="p-3 rounded-lg flex justify-between items-center"
677
+ style={{ backgroundColor: `${theme.colors.primary}${alpha(0.1)}` }}
678
+ >
679
+ <div>
680
+ <p className="text-sm font-semibold" style={{ color: theme.colors.primary }}>
681
+ Coupon applied: {c.appliedCoupon.code}
682
+ </p>
683
+ <p className="text-xs" style={{ color: theme.colors.text }}>
684
+ {c.appliedCoupon.discountType === "percentage"
685
+ ? `${c.appliedCoupon.discountValue}% off`
686
+ : `${formatCurrency(c.appliedCoupon.discountValue ?? 0)} off`}
687
+ </p>
688
+ </div>
689
+ <button
690
+ type="button"
691
+ className="text-sm underline"
692
+ style={{ color: theme.colors.text }}
693
+ onClick={c.removeCoupon}
694
+ disabled={c.isApplyingCoupon}
695
+ >
696
+ Remove
697
+ </button>
698
+ </div>
699
+ )}
700
+ </div>
701
+ )}
702
+
703
+ {/* Totals */}
704
+ <div className="space-y-2">
705
+ <div className="flex justify-between">
706
+ <span style={{ color: theme.colors.text }}>Subtotal</span>
707
+ <span style={{ color: theme.colors.text }}>{formatCurrency(c.subTotal)}</span>
708
+ </div>
709
+ {c.discountAmount > 0 && (
710
+ <div className="flex justify-between">
711
+ <span style={{ color: theme.colors.text }}>Discount</span>
712
+ <span style={{ color: theme.colors.primary }}>-{formatCurrency(c.discountAmount)}</span>
713
+ </div>
714
+ )}
715
+ {c.shippingCost > 0 && (
716
+ <div className="flex justify-between">
717
+ <span style={{ color: theme.colors.text }}>Shipping</span>
718
+ <span style={{ color: theme.colors.text }}>{formatCurrency(c.shippingCost)}</span>
719
+ </div>
720
+ )}
721
+ {/* Exclusive mode: the authoritative tax line, once the checkout
722
+ quote is known (added on top of the total). */}
723
+ {taxSummary?.mode === "exclusive" && (
724
+ <div className="flex justify-between">
725
+ <span style={{ color: theme.colors.text }}>Tax</span>
726
+ <span style={{ color: theme.colors.text }}>{taxSummary.formattedTax}</span>
727
+ </div>
728
+ )}
729
+ <div
730
+ className="flex justify-between font-bold text-lg pt-2"
731
+ style={{ borderTop: `1px solid ${theme.colors.primary}${alpha(0.2)}`, color: theme.colors.primary }}
732
+ >
733
+ <span>Total</span>
734
+ <span>
735
+ {c.chargedTotal
736
+ ? formatCurrency(c.chargedTotal.amount, c.chargedTotal.currency)
737
+ : formatCurrency(c.subTotal + c.shippingCost)}
738
+ </span>
739
+ </div>
740
+ {/* Inclusive mode: the total already contains tax — break out the
741
+ contained amount once known, else a generic muted caption. */}
742
+ {taxSummary?.mode === "inclusive" ? (
743
+ <p className="text-xs text-right" style={{ color: `${theme.colors.text}${alpha(0.6)}`, margin: 0 }}>
744
+ {taxSummary.label}
745
+ </p>
746
+ ) : (
747
+ !taxSummary &&
748
+ pricesIncludeTax && (
749
+ <p className="text-xs text-right" style={{ color: `${theme.colors.text}${alpha(0.6)}`, margin: 0 }}>
750
+ incl. tax
751
+ </p>
752
+ )
753
+ )}
754
+ </div>
755
+ </div>
756
+ </div>
757
+ </div>
758
+ </div>
759
+ );
760
+ }
761
+
762
+ // ── Payment section ───────────────────────────────────────────────────────────
763
+ /**
764
+ * The payment stage. The order + payment-intent are created by `useCheckout`
765
+ * (on entering this stage); here we only render the resolved provider UI —
766
+ * Stripe via the registered `<ForgePaymentProvider>` renderer, Paystack via a
767
+ * redirect button (the flow also auto-redirects). Re-keyed on the client secret
768
+ * so a coupon that re-issues the intent remounts the form against the latest one.
769
+ */
770
+ function PaymentSection({
771
+ checkout,
772
+ onBack,
773
+ }: {
774
+ checkout: ReturnType<typeof useCheckout>;
775
+ onBack?: () => void;
776
+ }) {
777
+ const theme = useForgeTheme();
778
+ const { formatCurrency } = useFormatCurrency();
779
+ const renderPay = usePaymentRenderer();
780
+ const { paymentSecret, chargedAmount, chargedCurrency, returnUrl, isPaystack, isCreatingOrder, startError } = checkout;
781
+
782
+ // Paystack has no in-app form — send the buyer to the hosted checkout as soon
783
+ // as the redirect URL (paymentSecret) is ready. The button below is a manual
784
+ // fallback in case the automatic redirect is blocked (e.g. a pop-up blocker).
785
+ useEffect(() => {
786
+ if (isPaystack && paymentSecret && typeof window !== "undefined") {
787
+ window.location.href = paymentSecret;
788
+ }
789
+ }, [isPaystack, paymentSecret]);
790
+
791
+ const backButton = onBack ? (
792
+ <button
793
+ type="button"
794
+ onClick={onBack}
795
+ className="px-4 py-3 font-semibold"
796
+ style={{
797
+ border: `1px solid ${theme.colors.primary}`,
798
+ color: theme.colors.primary,
799
+ borderRadius: `${theme.cornerRadius}px`,
800
+ }}
801
+ >
802
+ Back
803
+ </button>
804
+ ) : null;
805
+
806
+ // Stripe (the default renderer) needs a registered provider.
807
+ if (!isPaystack && !renderPay) {
808
+ return (
809
+ <div
810
+ className="rounded-lg shadow-md p-6"
811
+ style={{
812
+ backgroundColor: theme.colors.background,
813
+ border: `1px solid ${theme.colors.primary}${alpha(0.2)}`,
814
+ borderRadius: `${theme.cornerRadius}px`,
815
+ }}
816
+ >
817
+ <h2 className="text-2xl font-bold mb-2" style={{ color: theme.colors.text }}>
818
+ Payment
819
+ </h2>
820
+ <p className="text-lg mb-4" style={{ color: theme.colors.text }}>
821
+ Payment is not configured
822
+ </p>
823
+ {backButton}
824
+ </div>
825
+ );
826
+ }
827
+
828
+ return (
829
+ <div
830
+ className="rounded-lg shadow-md p-6"
831
+ style={{
832
+ backgroundColor: theme.colors.background,
833
+ border: `1px solid ${theme.colors.primary}${alpha(0.2)}`,
834
+ borderRadius: `${theme.cornerRadius}px`,
835
+ }}
836
+ >
837
+ <h2 className="text-2xl font-bold mb-6" style={{ color: theme.colors.text }}>
838
+ Payment Information
839
+ </h2>
840
+
841
+ {startError && (
842
+ <p className="text-sm mb-4" style={{ color: "#ef4444" }}>
843
+ {startError}
844
+ </p>
845
+ )}
846
+
847
+ {isCreatingOrder && !paymentSecret && <Loading label="Preparing payment…" size={22} />}
848
+
849
+ {isPaystack
850
+ ? paymentSecret &&
851
+ !!chargedAmount && (
852
+ <button
853
+ type="button"
854
+ onClick={() => {
855
+ window.location.href = paymentSecret;
856
+ }}
857
+ className="w-full px-4 py-3 font-semibold"
858
+ style={{
859
+ backgroundColor: theme.colors.primary,
860
+ color: readableTextOn(theme.colors.primary),
861
+ borderRadius: `${theme.cornerRadius}px`,
862
+ }}
863
+ >
864
+ {`Pay ${formatCurrency(chargedAmount, chargedCurrency || undefined)} Now`}
865
+ </button>
866
+ )
867
+ : paymentSecret &&
868
+ renderPay && (
869
+ <div key={paymentSecret}>
870
+ {renderPay({
871
+ clientSecret: paymentSecret,
872
+ returnUrl,
873
+ amount: chargedAmount,
874
+ currency: chargedCurrency || undefined,
875
+ mode: "redirect",
876
+ })}
877
+ </div>
878
+ )}
879
+
880
+ {backButton && <div className="mt-4">{backButton}</div>}
881
+ </div>
882
+ );
883
+ }
884
+
885
+ // ── Shipping rate selector ──────────────────────────────────────────────────────
886
+ /**
887
+ * Renders the shipping groups: fixed lines for auto-selected flat/external
888
+ * groups, and a radio list for the selectable (dynamic/carrier) group. Selection
889
+ * state is owned by `useCheckout`; this component just renders + calls `onSelect`.
890
+ */
891
+ function ShippingRateSelector({
892
+ groups,
893
+ selections,
894
+ onSelect,
895
+ formatCurrency,
896
+ }: {
897
+ groups: ShippingGroup[];
898
+ selections: Record<string, SelectedShippingRate>;
899
+ onSelect: (group: ShippingGroup, rate: ShippingRate) => void;
900
+ formatCurrency: (amount: number, currencyCode?: string) => string;
901
+ }) {
902
+ const theme = useForgeTheme();
903
+ if (groups.length === 0) return null;
904
+
905
+ return (
906
+ <div className="space-y-6">
907
+ {groups.map((group) => (
908
+ <div
909
+ key={group.id}
910
+ className="p-4 rounded-lg"
911
+ style={{ border: `1px solid ${theme.colors.primary}${alpha(0.15)}`, borderRadius: `${theme.cornerRadius}px` }}
912
+ >
913
+ <p className="font-medium mb-3" style={{ color: theme.colors.text }}>
914
+ {group.title}
915
+ </p>
916
+
917
+ {!group.selectable && group.rates.length > 0 ? (
918
+ // Flat / external — a single, auto-selected rate.
919
+ <div
920
+ className="flex items-center justify-between p-3 rounded-md"
921
+ style={{ backgroundColor: `${theme.colors.primary}${alpha(0.05)}`, borderRadius: `${theme.cornerRadius}px` }}
922
+ >
923
+ <div>
924
+ <span className="text-sm font-medium" style={{ color: theme.colors.text }}>
925
+ {group.rates[0].carrier} — {group.rates[0].service}
926
+ </span>
927
+ {group.rates[0].estimatedDays != null && (
928
+ <span className="text-xs ml-2" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
929
+ ({group.rates[0].estimatedDays} days)
930
+ </span>
931
+ )}
932
+ </div>
933
+ <span className="text-sm font-semibold" style={{ color: theme.colors.primary }}>
934
+ {formatCurrency(group.rates[0].amount, group.rates[0].currency)}
935
+ </span>
936
+ </div>
937
+ ) : (
938
+ // Dynamic — buyer picks a carrier option.
939
+ <div className="space-y-2">
940
+ {group.rates.length === 0 && (
941
+ <p className="text-sm" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
942
+ No shipping options available for this address.
943
+ </p>
944
+ )}
945
+ {group.rates.map((rate) => {
946
+ const isSelected = selections[group.id]?.rateId === rate.rateId;
947
+ return (
948
+ <label
949
+ key={rate.rateId}
950
+ className="flex items-center justify-between p-3 rounded-md cursor-pointer transition-colors"
951
+ style={{
952
+ backgroundColor: isSelected ? `${theme.colors.primary}${alpha(0.1)}` : "transparent",
953
+ border: `1px solid ${isSelected ? theme.colors.primary : `${theme.colors.primary}${alpha(0.2)}`}`,
954
+ borderRadius: `${theme.cornerRadius}px`,
955
+ }}
956
+ >
957
+ <div className="flex items-center gap-3">
958
+ <input
959
+ type="radio"
960
+ name={`shipping-rate-${group.id}`}
961
+ checked={isSelected}
962
+ onChange={() => onSelect(group, rate)}
963
+ style={{ accentColor: theme.colors.primary }}
964
+ />
965
+ <div>
966
+ <span className="text-sm font-medium" style={{ color: theme.colors.text }}>
967
+ {rate.carrier} - {rate.service}
968
+ </span>
969
+ {rate.estimatedDays && (
970
+ <p className="text-xs" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
971
+ Estimated {rate.estimatedDays} {rate.estimatedDays === 1 ? "day" : "days"}
972
+ </p>
973
+ )}
974
+ </div>
975
+ </div>
976
+ <span className="text-sm font-semibold" style={{ color: theme.colors.primary }}>
977
+ {formatCurrency(rate.amount, rate.currency)}
978
+ </span>
979
+ </label>
980
+ );
981
+ })}
982
+ </div>
983
+ )}
984
+ </div>
985
+ ))}
986
+ </div>
987
+ );
988
+ }