@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.
- package/package.json +42 -0
- package/src/client/createForgeClient.ts +54 -0
- package/src/content/ForgeContentProvider.tsx +118 -0
- package/src/content/ForgeEditBridge.tsx +134 -0
- package/src/content/editFlag.ts +23 -0
- package/src/content/index.ts +28 -0
- package/src/content/resolve.ts +67 -0
- package/src/content/types.ts +97 -0
- package/src/content/useField.ts +64 -0
- package/src/contexts/AppAuthContext.tsx +185 -0
- package/src/contexts/AudioPlayerContext.tsx +389 -0
- package/src/contexts/CartContext.tsx +131 -0
- package/src/contexts/PublicAuthContext.tsx +333 -0
- package/src/contexts/useAppAdminGuard.ts +56 -0
- package/src/css.d.ts +5 -0
- package/src/data/collectionParams.ts +57 -0
- package/src/data/queries/useAccount.ts +71 -0
- package/src/data/queries/useAccountSettings.ts +82 -0
- package/src/data/queries/useAnalytics.ts +86 -0
- package/src/data/queries/useAuthActions.ts +28 -0
- package/src/data/queries/useBlog.ts +103 -0
- package/src/data/queries/useBlogComments.ts +96 -0
- package/src/data/queries/useBroadcasts.ts +31 -0
- package/src/data/queries/useCertificates.ts +58 -0
- package/src/data/queries/useChat.ts +267 -0
- package/src/data/queries/useCoachingAvailability.ts +69 -0
- package/src/data/queries/useCoachingProducts.ts +67 -0
- package/src/data/queries/useCohortPage.ts +17 -0
- package/src/data/queries/useCollections.ts +211 -0
- package/src/data/queries/useCommunity.ts +794 -0
- package/src/data/queries/useCoupons.ts +20 -0
- package/src/data/queries/useCourseAccess.ts +50 -0
- package/src/data/queries/useCourses.ts +91 -0
- package/src/data/queries/useCurrencies.ts +33 -0
- package/src/data/queries/useDocuments.ts +208 -0
- package/src/data/queries/useDonations.ts +29 -0
- package/src/data/queries/useEngagement.ts +115 -0
- package/src/data/queries/useEvents.ts +69 -0
- package/src/data/queries/useFinalize.ts +109 -0
- package/src/data/queries/useForms.ts +31 -0
- package/src/data/queries/useInvoice.ts +65 -0
- package/src/data/queries/useLeadMagnet.ts +96 -0
- package/src/data/queries/useMembership.ts +20 -0
- package/src/data/queries/useNotifications.ts +267 -0
- package/src/data/queries/useOffers.ts +56 -0
- package/src/data/queries/useOrders.ts +71 -0
- package/src/data/queries/usePWA.ts +101 -0
- package/src/data/queries/usePageActions.ts +36 -0
- package/src/data/queries/usePaymentFlow.ts +80 -0
- package/src/data/queries/usePaymentLink.ts +47 -0
- package/src/data/queries/usePodcast.ts +85 -0
- package/src/data/queries/usePostCollections.ts +48 -0
- package/src/data/queries/usePosts.ts +94 -0
- package/src/data/queries/useProducts.ts +108 -0
- package/src/data/queries/useReplay.ts +19 -0
- package/src/data/queries/useReviews.ts +122 -0
- package/src/data/queries/useShipping.ts +109 -0
- package/src/data/queries/useSubscriptions.ts +66 -0
- package/src/data/queries/useWebsite.ts +128 -0
- package/src/data/queries/useWebsiteAgent.ts +100 -0
- package/src/index.ts +98 -0
- package/src/provider/ForgeAppProvider.tsx +149 -0
- package/src/provider/ForgeProvider.tsx +91 -0
- package/src/provider/SiteConfigProvider.tsx +27 -0
- package/src/server/_tests/buildPwaHead.spec.ts +182 -0
- package/src/server/_tests/buildWebManifest.spec.ts +313 -0
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +251 -0
- package/src/server/index.ts +260 -0
- package/src/server/jobs.ts +104 -0
- package/src/server/pwa.ts +332 -0
- package/src/types/index.ts +74 -0
- package/src/types/models.ts +1313 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +208 -0
- package/src/ui/analytics/PageMetaPixel.tsx +92 -0
- package/src/ui/content/Editable.tsx +90 -0
- package/src/ui/format/PriceDisplay.tsx +142 -0
- package/src/ui/format/useFormatCurrency.ts +92 -0
- package/src/ui/headless/agent/useAiAgent.ts +106 -0
- package/src/ui/headless/auth/useLoginFlow.ts +72 -0
- package/src/ui/headless/auth/useSignupForm.ts +85 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +59 -0
- package/src/ui/headless/chat/attachmentKind.ts +27 -0
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +132 -0
- package/src/ui/headless/chat/useChatRoom.ts +223 -0
- package/src/ui/headless/chat/useMessageThread.ts +60 -0
- package/src/ui/headless/checkout/useCheckout.ts +438 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +223 -0
- package/src/ui/headless/community/index.ts +20 -0
- package/src/ui/headless/community/lexical.ts +131 -0
- package/src/ui/headless/community/replyTree.ts +51 -0
- package/src/ui/headless/community/richLexical.ts +314 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +137 -0
- package/src/ui/headless/community/useCommunitySpaceFeed.ts +122 -0
- package/src/ui/headless/community/useCommunityThread.ts +95 -0
- package/src/ui/headless/community/useMentionComposer.ts +105 -0
- package/src/ui/headless/community/useRichMentionComposer.ts +193 -0
- package/src/ui/headless/consent/useCookieConsent.ts +81 -0
- package/src/ui/headless/course/useCourseCheckout.ts +99 -0
- package/src/ui/headless/dialog.tsx +14 -0
- package/src/ui/headless/document/index.ts +7 -0
- package/src/ui/headless/document/useContractSignature.ts +68 -0
- package/src/ui/headless/document/useDocumentSigningFlow.ts +92 -0
- package/src/ui/headless/document/useOtpCodeInput.ts +86 -0
- package/src/ui/headless/document/useSignaturePad.ts +180 -0
- package/src/ui/headless/donation/Donation.tsx +115 -0
- package/src/ui/headless/donation/DonationContext.tsx +223 -0
- package/src/ui/headless/donation/fees.ts +15 -0
- package/src/ui/headless/donation/index.ts +10 -0
- package/src/ui/headless/engagement/usePostEngagement.ts +119 -0
- package/src/ui/headless/event/useEventCheckout.ts +141 -0
- package/src/ui/headless/forms/useContactForm.ts +58 -0
- package/src/ui/headless/forms/useEmailListForm.ts +75 -0
- package/src/ui/headless/forms/useSectionedForm.ts +256 -0
- package/src/ui/headless/index.ts +60 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +48 -0
- package/src/ui/headless/memberHome/index.ts +7 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +82 -0
- package/src/ui/headless/membership/MembershipGate.tsx +39 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +121 -0
- package/src/ui/headless/offer/Offer.tsx +88 -0
- package/src/ui/headless/offer/OfferContext.tsx +162 -0
- package/src/ui/headless/offer/index.ts +8 -0
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +46 -0
- package/src/ui/headless/podcast/usePodcastPlayer.ts +96 -0
- package/src/ui/headless/posts/index.ts +13 -0
- package/src/ui/headless/posts/upsell.ts +41 -0
- package/src/ui/headless/posts/useMemberPostsFeed.ts +110 -0
- package/src/ui/headless/posts/usePostCollectionDetail.ts +56 -0
- package/src/ui/headless/posts/usePostCollectionsList.ts +90 -0
- package/src/ui/headless/posts/usePostItem.ts +81 -0
- package/src/ui/headless/reviews/index.ts +6 -0
- package/src/ui/headless/reviews/useHelpfulVote.ts +45 -0
- package/src/ui/headless/reviews/useReviewForm.ts +108 -0
- package/src/ui/headless/reviews/useReviewsList.ts +79 -0
- package/src/ui/headless/work/index.ts +25 -0
- package/src/ui/headless/work/useWorkPortal.ts +349 -0
- package/src/ui/index.ts +144 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +45 -0
- package/src/ui/payment/ForgeStripePayment.tsx +171 -0
- package/src/ui/shell/TribeNestApp.tsx +121 -0
- package/src/ui/shell/shellGating.spec.ts +28 -0
- package/src/ui/shell/shellGating.ts +11 -0
- package/src/ui/styled/AccountDashboard.tsx +575 -0
- package/src/ui/styled/AiAgentWidget.tsx +152 -0
- package/src/ui/styled/AudioPlayer.tsx +123 -0
- package/src/ui/styled/BlogCategory.tsx +79 -0
- package/src/ui/styled/BlogComments.tsx +236 -0
- package/src/ui/styled/BlogList.tsx +101 -0
- package/src/ui/styled/BlogPost.tsx +160 -0
- package/src/ui/styled/BlogPostFooter.tsx +33 -0
- package/src/ui/styled/BotProtection.tsx +101 -0
- package/src/ui/styled/Button.tsx +88 -0
- package/src/ui/styled/Cart.tsx +276 -0
- package/src/ui/styled/ChatRoom.tsx +252 -0
- package/src/ui/styled/Checkout.tsx +988 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +452 -0
- package/src/ui/styled/CoachingBooking.tsx +605 -0
- package/src/ui/styled/CoachingConfirmation.tsx +151 -0
- package/src/ui/styled/CoachingDetail.tsx +99 -0
- package/src/ui/styled/CohortPage.tsx +82 -0
- package/src/ui/styled/ConfirmSubscription.tsx +134 -0
- package/src/ui/styled/Confirmation.tsx +171 -0
- package/src/ui/styled/ContactForm.tsx +81 -0
- package/src/ui/styled/CookieConsent.tsx +239 -0
- package/src/ui/styled/CourseAccess.tsx +169 -0
- package/src/ui/styled/CourseCheckout.tsx +425 -0
- package/src/ui/styled/CourseConfirmation.tsx +191 -0
- package/src/ui/styled/CourseDetail.tsx +140 -0
- package/src/ui/styled/CoursesGrid.tsx +85 -0
- package/src/ui/styled/CurrencySwitcher.tsx +51 -0
- package/src/ui/styled/DonationButton.tsx +247 -0
- package/src/ui/styled/DonationPage.tsx +29 -0
- package/src/ui/styled/EmailListForm.tsx +88 -0
- package/src/ui/styled/EventConfirmation.tsx +228 -0
- package/src/ui/styled/EventCountdown.tsx +98 -0
- package/src/ui/styled/EventDetail.tsx +141 -0
- package/src/ui/styled/EventTickets.tsx +548 -0
- package/src/ui/styled/EventsList.tsx +83 -0
- package/src/ui/styled/ForgotPasswordForm.tsx +105 -0
- package/src/ui/styled/FormRenderer.tsx +249 -0
- package/src/ui/styled/InstallBanner.tsx +114 -0
- package/src/ui/styled/InvoiceConfirmation.tsx +50 -0
- package/src/ui/styled/InvoicePayment.tsx +141 -0
- package/src/ui/styled/LeadMagnet.tsx +93 -0
- package/src/ui/styled/Loading.tsx +56 -0
- package/src/ui/styled/LoginForm.tsx +134 -0
- package/src/ui/styled/Markdown.tsx +103 -0
- package/src/ui/styled/MembershipCheckout.tsx +197 -0
- package/src/ui/styled/MembershipTiers.tsx +115 -0
- package/src/ui/styled/OfferButton.tsx +181 -0
- package/src/ui/styled/PageActions.tsx +124 -0
- package/src/ui/styled/PaymentLinkConfirmation.tsx +50 -0
- package/src/ui/styled/PaymentLinkPayment.tsx +85 -0
- package/src/ui/styled/Paywall.tsx +54 -0
- package/src/ui/styled/PlayButton.tsx +48 -0
- package/src/ui/styled/PodcastEpisode.tsx +137 -0
- package/src/ui/styled/PodcastList.tsx +58 -0
- package/src/ui/styled/PodcastShow.tsx +164 -0
- package/src/ui/styled/PostsFeed.tsx +63 -0
- package/src/ui/styled/ProductDetail.tsx +616 -0
- package/src/ui/styled/ProductGrid.tsx +82 -0
- package/src/ui/styled/PushOptIn.tsx +65 -0
- package/src/ui/styled/PwaRegistration.tsx +58 -0
- package/src/ui/styled/ReactionBar.tsx +93 -0
- package/src/ui/styled/ReplayList.tsx +134 -0
- package/src/ui/styled/ResetPasswordForm.tsx +127 -0
- package/src/ui/styled/ReviewForm.tsx +292 -0
- package/src/ui/styled/ReviewsSection.tsx +260 -0
- package/src/ui/styled/SectionedFormRenderer.tsx +146 -0
- package/src/ui/styled/SignupForm.tsx +109 -0
- package/src/ui/styled/UserMenu.tsx +166 -0
- package/src/ui/styled/chat/ChatAttachments.tsx +172 -0
- package/src/ui/styled/chat/ChatComposer.tsx +153 -0
- package/src/ui/styled/chat/ChatThreadPanel.tsx +94 -0
- package/src/ui/styled/chat/MessageBubble.tsx +66 -0
- package/src/ui/styled/chat/NewDmDialog.tsx +78 -0
- package/src/ui/styled/community/CommunityComposer.tsx +358 -0
- package/src/ui/styled/community/CommunityFeed.tsx +483 -0
- package/src/ui/styled/community/CommunityLeaderboard.tsx +109 -0
- package/src/ui/styled/community/CommunityMediaGallery.tsx +116 -0
- package/src/ui/styled/community/CommunityMemberProfile.tsx +285 -0
- package/src/ui/styled/community/CommunityNotifications.tsx +222 -0
- package/src/ui/styled/community/CommunityPostCard.tsx +165 -0
- package/src/ui/styled/community/CommunityPostDetail.tsx +337 -0
- package/src/ui/styled/community/CommunityReactionBar.tsx +107 -0
- package/src/ui/styled/community/CommunityRichText.tsx +157 -0
- package/src/ui/styled/community/CommunitySpaces.tsx +174 -0
- package/src/ui/styled/community/MemberAvatar.tsx +74 -0
- package/src/ui/styled/community/index.ts +23 -0
- package/src/ui/styled/community/util.ts +15 -0
- package/src/ui/styled/members/MemberHomeLayout.tsx +98 -0
- package/src/ui/styled/members/MemberPostCard.tsx +392 -0
- package/src/ui/styled/members/MemberPostsFeed.tsx +219 -0
- package/src/ui/styled/members/PostCollectionDetail.tsx +227 -0
- package/src/ui/styled/members/PostCollectionsGrid.tsx +287 -0
- package/src/ui/styled/members/index.ts +7 -0
- package/src/ui/styled/rich-text.css +150 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +222 -0
- package/src/ui/styled/work/WorkPortalProjects.tsx +163 -0
- package/src/ui/styled/work/WorkProjectInvoices.tsx +113 -0
- package/src/ui/styled/work/WorkProjectReport.tsx +66 -0
- package/src/ui/styled/work/WorkReportBody.tsx +327 -0
- package/src/ui/styled/work/WorkTaskAttachments.tsx +67 -0
- package/src/ui/styled/work/WorkTaskComments.tsx +151 -0
- package/src/ui/styled/work/WorkTaskDetail.tsx +92 -0
- package/src/ui/styled/work/WorkTokenReport.tsx +49 -0
- package/src/ui/styled/work/index.ts +10 -0
- package/src/ui/theme/ForgeThemeProvider.tsx +121 -0
- package/src/ui/theme/contrast.ts +22 -0
- package/src/utils/analyticsBus.ts +31 -0
- package/src/utils/attribution.ts +68 -0
- package/src/utils/cookieConsent.ts +83 -0
- package/src/utils/landing.ts +153 -0
- package/src/utils/metaPixel.ts +122 -0
- 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
|
+
}
|