@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,605 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Calendar, ChevronLeft, ChevronRight, Clock, X } from "lucide-react";
|
|
3
|
+
import type { BookingSlot, CoachingProduct } from "../../types/models";
|
|
4
|
+
import { useCoachingBooking } from "../headless/coaching/useCoachingBooking";
|
|
5
|
+
import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
|
|
6
|
+
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
7
|
+
import { summarizeTaxQuote } from "../format/PriceDisplay";
|
|
8
|
+
import { Loading } from "./Loading";
|
|
9
|
+
import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
|
|
10
|
+
import { buttonStyle } from "./Button";
|
|
11
|
+
import {
|
|
12
|
+
DialogRoot,
|
|
13
|
+
DialogTrigger,
|
|
14
|
+
DialogPortal,
|
|
15
|
+
DialogOverlay,
|
|
16
|
+
DialogContent,
|
|
17
|
+
DialogTitle,
|
|
18
|
+
DialogClose,
|
|
19
|
+
} from "../headless/dialog";
|
|
20
|
+
|
|
21
|
+
export interface CoachingBookingProps {
|
|
22
|
+
/** Coaching product slug (resolves the product + drives the booking flow). */
|
|
23
|
+
slug?: string;
|
|
24
|
+
/** Trigger button label. */
|
|
25
|
+
triggerText?: string;
|
|
26
|
+
/** Format a numeric amount for display. */
|
|
27
|
+
formatAmount?: (amount: number) => string;
|
|
28
|
+
/** Optional payment renderer — falls back to a registered <ForgePaymentProvider>. */
|
|
29
|
+
renderPayment?: (props: PaymentRenderProps) => ReactNode;
|
|
30
|
+
/** Stripe return URL for a PAID booking. Default `/i/coaching/:slug/finalise`. */
|
|
31
|
+
finalisePath?: (slug: string, bookingId: string) => string;
|
|
32
|
+
/** Host-owned navigation on FREE completion (e.g. router navigate). */
|
|
33
|
+
onComplete?: (info: { slug: string; bookingId: string }) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
37
|
+
|
|
38
|
+
/** Append an alpha to a 6-digit hex color (mirrors frontend-shared addAlphaToHexCode). */
|
|
39
|
+
const a = (hex: string, alpha: number) => hex + Math.round(alpha * 255).toString(16).padStart(2, "0");
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Themed coaching session booking — a "Book a session" trigger + modal running
|
|
43
|
+
* the three-step flow (slot → details → payment) on `useCoachingBooking`. Port
|
|
44
|
+
* of the client app's CoachingBookingModal, styled from `ForgeThemeProvider`.
|
|
45
|
+
*/
|
|
46
|
+
export function CoachingBooking({
|
|
47
|
+
slug,
|
|
48
|
+
triggerText = "Select Availability",
|
|
49
|
+
formatAmount,
|
|
50
|
+
renderPayment,
|
|
51
|
+
finalisePath,
|
|
52
|
+
onComplete,
|
|
53
|
+
}: CoachingBookingProps) {
|
|
54
|
+
const theme = useForgeTheme();
|
|
55
|
+
const registered = usePaymentRenderer();
|
|
56
|
+
const fmt = useAmountFormatter(formatAmount);
|
|
57
|
+
const c = useCoachingBooking(slug, { finalisePath, onComplete });
|
|
58
|
+
const renderPay = renderPayment ?? registered ?? undefined;
|
|
59
|
+
|
|
60
|
+
const [open, setOpen] = useState(false);
|
|
61
|
+
|
|
62
|
+
const tryClose = (next: boolean) => {
|
|
63
|
+
if (!next && c.step !== "slot") {
|
|
64
|
+
if (typeof window !== "undefined" && !window.confirm("Are you sure you want to leave checkout?")) return;
|
|
65
|
+
// Release the held slot so it's not locked until the timer runs out.
|
|
66
|
+
void c.releaseReservation();
|
|
67
|
+
}
|
|
68
|
+
setOpen(next);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<DialogRoot open={open} onOpenChange={tryClose}>
|
|
73
|
+
<DialogTrigger asChild>
|
|
74
|
+
<button style={triggerButtonStyle(theme)}>{triggerText}</button>
|
|
75
|
+
</DialogTrigger>
|
|
76
|
+
<DialogPortal>
|
|
77
|
+
<DialogOverlay style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 50 }} />
|
|
78
|
+
<DialogContent
|
|
79
|
+
style={{
|
|
80
|
+
position: "fixed",
|
|
81
|
+
top: "50%",
|
|
82
|
+
left: "50%",
|
|
83
|
+
transform: "translate(-50%, -50%)",
|
|
84
|
+
width: "min(640px, calc(100vw - 24px))",
|
|
85
|
+
maxHeight: "88vh",
|
|
86
|
+
overflowY: "auto",
|
|
87
|
+
background: theme.colors.background,
|
|
88
|
+
color: theme.colors.text,
|
|
89
|
+
borderRadius: theme.cornerRadius,
|
|
90
|
+
padding: 24,
|
|
91
|
+
zIndex: 51,
|
|
92
|
+
fontFamily: theme.fontFamily,
|
|
93
|
+
}}
|
|
94
|
+
aria-describedby={undefined}
|
|
95
|
+
>
|
|
96
|
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
|
|
97
|
+
<DialogTitle style={{ fontSize: 18, fontWeight: 700, margin: 0 }}>
|
|
98
|
+
{c.product?.title ?? "Book a session"}
|
|
99
|
+
</DialogTitle>
|
|
100
|
+
<DialogClose asChild>
|
|
101
|
+
<button aria-label="Close" style={closeButtonStyle(theme)}>
|
|
102
|
+
<X size={18} />
|
|
103
|
+
</button>
|
|
104
|
+
</DialogClose>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{c.isLoading && <Loading />}
|
|
108
|
+
{!c.isLoading && !c.product && <p style={{ marginTop: 8 }}>Coaching session not found.</p>}
|
|
109
|
+
{c.product && <CoachingBookingBody c={c} product={c.product} fmt={fmt} renderPayment={renderPay} />}
|
|
110
|
+
</DialogContent>
|
|
111
|
+
</DialogPortal>
|
|
112
|
+
</DialogRoot>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type Booking = ReturnType<typeof useCoachingBooking>;
|
|
117
|
+
|
|
118
|
+
function CoachingBookingBody({
|
|
119
|
+
c,
|
|
120
|
+
product,
|
|
121
|
+
fmt,
|
|
122
|
+
renderPayment,
|
|
123
|
+
}: {
|
|
124
|
+
c: Booking;
|
|
125
|
+
product: CoachingProduct;
|
|
126
|
+
fmt: (n: number) => string;
|
|
127
|
+
renderPayment?: (props: PaymentRenderProps) => ReactNode;
|
|
128
|
+
}) {
|
|
129
|
+
const stepIndex = c.step === "slot" ? 0 : c.step === "details" ? 1 : 2;
|
|
130
|
+
return (
|
|
131
|
+
<div style={{ width: "100%" }}>
|
|
132
|
+
{/* Reservation hold countdown — isolated so its per-second tick re-renders
|
|
133
|
+
only itself, not the modal body (esp. the Stripe payment form). */}
|
|
134
|
+
<ReservationTimer expiresAt={c.reservationExpiresAt} onExpire={c.expireReservation} />
|
|
135
|
+
<Progress current={stepIndex} />
|
|
136
|
+
{c.step === "slot" && <SlotStep c={c} product={product} fmt={fmt} />}
|
|
137
|
+
{c.step === "details" && <DetailsStep c={c} product={product} fmt={fmt} />}
|
|
138
|
+
{c.step === "payment" && <PaymentStep c={c} product={product} fmt={fmt} renderPayment={renderPayment} />}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Reservation hold countdown ────────────────────────────────────────────────
|
|
144
|
+
// Owns its own per-second tick so only this component re-renders each second
|
|
145
|
+
// (keeps the Stripe form in the payment step from re-mounting). Fires `onExpire`
|
|
146
|
+
// exactly once when the hold runs out.
|
|
147
|
+
function ReservationTimer({ expiresAt, onExpire }: { expiresAt: number | null; onExpire: () => void }) {
|
|
148
|
+
const theme = useForgeTheme();
|
|
149
|
+
const [now, setNow] = useState(() => Date.now());
|
|
150
|
+
const firedRef = useRef(false);
|
|
151
|
+
const onExpireRef = useRef(onExpire);
|
|
152
|
+
onExpireRef.current = onExpire;
|
|
153
|
+
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (expiresAt == null) return;
|
|
156
|
+
firedRef.current = false;
|
|
157
|
+
setNow(Date.now());
|
|
158
|
+
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
159
|
+
return () => clearInterval(id);
|
|
160
|
+
}, [expiresAt]);
|
|
161
|
+
|
|
162
|
+
const remaining = expiresAt == null ? 0 : Math.max(0, Math.floor((expiresAt - now) / 1000));
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (expiresAt != null && remaining === 0 && !firedRef.current) {
|
|
166
|
+
firedRef.current = true;
|
|
167
|
+
onExpireRef.current();
|
|
168
|
+
}
|
|
169
|
+
}, [remaining, expiresAt]);
|
|
170
|
+
|
|
171
|
+
if (expiresAt == null) return null;
|
|
172
|
+
|
|
173
|
+
const danger = remaining <= 60;
|
|
174
|
+
const color = danger ? "#ef4444" : theme.colors.primary;
|
|
175
|
+
const mmss = `${Math.floor(remaining / 60)
|
|
176
|
+
.toString()
|
|
177
|
+
.padStart(2, "0")}:${(remaining % 60).toString().padStart(2, "0")}`;
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div
|
|
181
|
+
style={{
|
|
182
|
+
marginBottom: 16,
|
|
183
|
+
padding: 12,
|
|
184
|
+
borderRadius: theme.cornerRadius,
|
|
185
|
+
display: "flex",
|
|
186
|
+
alignItems: "center",
|
|
187
|
+
justifyContent: "center",
|
|
188
|
+
gap: 8,
|
|
189
|
+
background: a(color, danger ? 0.12 : 0.06),
|
|
190
|
+
border: `1px solid ${a(color, 0.4)}`,
|
|
191
|
+
}}
|
|
192
|
+
>
|
|
193
|
+
<Clock size={16} style={{ color }} />
|
|
194
|
+
<span style={{ fontWeight: 600, fontSize: 14, color }}>Time remaining: {mmss}</span>
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Progress indicator (3 numbered steps) ────────────────────────────────────
|
|
200
|
+
function Progress({ current }: { current: number }) {
|
|
201
|
+
const theme = useForgeTheme();
|
|
202
|
+
const steps = [0, 1, 2];
|
|
203
|
+
return (
|
|
204
|
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 24 }}>
|
|
205
|
+
{steps.map((s, index) => (
|
|
206
|
+
<div key={s} style={{ display: "flex", alignItems: "center", flex: index < steps.length - 1 ? 1 : "0 0 auto" }}>
|
|
207
|
+
<div
|
|
208
|
+
style={{
|
|
209
|
+
width: 32,
|
|
210
|
+
height: 32,
|
|
211
|
+
borderRadius: "9999px",
|
|
212
|
+
display: "flex",
|
|
213
|
+
alignItems: "center",
|
|
214
|
+
justifyContent: "center",
|
|
215
|
+
fontSize: 14,
|
|
216
|
+
fontWeight: 600,
|
|
217
|
+
border: `1px solid ${index <= current ? theme.colors.primary : a(theme.colors.primary, 0.25)}`,
|
|
218
|
+
background: index <= current ? theme.colors.primary : "transparent",
|
|
219
|
+
color: index <= current ? "#fff" : theme.colors.primary,
|
|
220
|
+
}}
|
|
221
|
+
>
|
|
222
|
+
{index + 1}
|
|
223
|
+
</div>
|
|
224
|
+
{index < steps.length - 1 && (
|
|
225
|
+
<div
|
|
226
|
+
style={{
|
|
227
|
+
flex: 1,
|
|
228
|
+
height: 2,
|
|
229
|
+
margin: "0 16px",
|
|
230
|
+
background: index < current ? theme.colors.primary : a(theme.colors.primary, 0.13),
|
|
231
|
+
}}
|
|
232
|
+
/>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
))}
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Step 1: week navigation + slot selection (reserve happens on Continue) ─────
|
|
241
|
+
function SlotStep({ c, product, fmt }: { c: Booking; product: CoachingProduct; fmt: (n: number) => string }) {
|
|
242
|
+
const theme = useForgeTheme();
|
|
243
|
+
const slots: BookingSlot[] = c.availability.data ?? [];
|
|
244
|
+
|
|
245
|
+
const grouped = useMemo(() => {
|
|
246
|
+
const byDay: Record<string, { slotId: string; time: string; ts: number }[]> = {};
|
|
247
|
+
for (const slot of slots) {
|
|
248
|
+
const date = new Date(slot.startTime);
|
|
249
|
+
const dateKey = date.toISOString().split("T")[0];
|
|
250
|
+
if (!dateKey) continue;
|
|
251
|
+
// Shown in the visitor's own timezone (like the client app + the confirmation).
|
|
252
|
+
const time = date.toLocaleTimeString("en-US", {
|
|
253
|
+
hour: "numeric",
|
|
254
|
+
minute: "2-digit",
|
|
255
|
+
hour12: true,
|
|
256
|
+
});
|
|
257
|
+
(byDay[dateKey] ??= []).push({ slotId: slot.id, time, ts: date.getTime() });
|
|
258
|
+
}
|
|
259
|
+
for (const key of Object.keys(byDay)) byDay[key]!.sort((x, y) => x.ts - y.ts);
|
|
260
|
+
return Object.entries(byDay).sort(([x], [y]) => x.localeCompare(y));
|
|
261
|
+
}, [slots]);
|
|
262
|
+
|
|
263
|
+
// The visitor's local timezone abbreviation (e.g. "EDT") — slots are shown in it.
|
|
264
|
+
const tzAbbr = (() => {
|
|
265
|
+
try {
|
|
266
|
+
return (
|
|
267
|
+
new Intl.DateTimeFormat("en-US", { timeZoneName: "short" })
|
|
268
|
+
.formatToParts(new Date())
|
|
269
|
+
.find((p) => p.type === "timeZoneName")?.value ?? ""
|
|
270
|
+
);
|
|
271
|
+
} catch {
|
|
272
|
+
return "";
|
|
273
|
+
}
|
|
274
|
+
})();
|
|
275
|
+
|
|
276
|
+
const weekLabel = `${c.weekStart.toLocaleDateString("en-US", { month: "short", day: "numeric" })} – ${c.weekEnd.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
|
|
277
|
+
const navButton = (disabled: boolean): CSSProperties => ({
|
|
278
|
+
padding: 8,
|
|
279
|
+
borderRadius: theme.cornerRadius,
|
|
280
|
+
border: `1px solid ${a(theme.colors.primary, 0.25)}`,
|
|
281
|
+
background: "transparent",
|
|
282
|
+
color: disabled ? a(theme.colors.text, 0.3) : theme.colors.text,
|
|
283
|
+
cursor: disabled ? "not-allowed" : "pointer",
|
|
284
|
+
display: "flex",
|
|
285
|
+
alignItems: "center",
|
|
286
|
+
justifyContent: "center",
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<div>
|
|
291
|
+
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 6 }}>Select Availability</h2>
|
|
292
|
+
<p style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13, color: a(theme.colors.text, 0.6), margin: "0 0 16px" }}>
|
|
293
|
+
<Clock size={14} /> Times shown in your timezone{tzAbbr ? ` (${tzAbbr})` : ""}
|
|
294
|
+
</p>
|
|
295
|
+
|
|
296
|
+
{/* Week navigation */}
|
|
297
|
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
|
|
298
|
+
<span style={{ fontSize: 14, color: a(theme.colors.text, 0.6) }}>{weekLabel}</span>
|
|
299
|
+
<div style={{ display: "flex", gap: 8 }}>
|
|
300
|
+
<button aria-label="Previous week" onClick={c.goPrevWeek} disabled={!c.canGoPrevWeek} style={navButton(!c.canGoPrevWeek)}>
|
|
301
|
+
<ChevronLeft size={16} />
|
|
302
|
+
</button>
|
|
303
|
+
<button aria-label="Next week" onClick={c.goNextWeek} style={navButton(false)}>
|
|
304
|
+
<ChevronRight size={16} />
|
|
305
|
+
</button>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
{c.availability.isLoading ? (
|
|
310
|
+
<Loading label="Loading available slots…" />
|
|
311
|
+
) : grouped.length === 0 ? (
|
|
312
|
+
<p style={{ opacity: 0.7 }}>No available slots for this week.</p>
|
|
313
|
+
) : (
|
|
314
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
|
315
|
+
{grouped.map(([dateKey, daySlots]) => {
|
|
316
|
+
const date = new Date(dateKey);
|
|
317
|
+
const dayName = DAY_NAMES[date.getUTCDay()];
|
|
318
|
+
const dateString = date.toLocaleDateString("en-US", {
|
|
319
|
+
month: "short",
|
|
320
|
+
day: "numeric",
|
|
321
|
+
year: "numeric",
|
|
322
|
+
timeZone: "UTC",
|
|
323
|
+
});
|
|
324
|
+
return (
|
|
325
|
+
<div key={dateKey}>
|
|
326
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
|
|
327
|
+
<Calendar size={18} style={{ color: theme.colors.primary }} />
|
|
328
|
+
<h3 style={{ fontSize: 17, fontWeight: 600, margin: 0 }}>
|
|
329
|
+
{dayName}, {dateString}
|
|
330
|
+
</h3>
|
|
331
|
+
</div>
|
|
332
|
+
<div className="grid grid-cols-2 md:grid-cols-4" style={{ gap: 10 }}>
|
|
333
|
+
{daySlots.map((slot) => {
|
|
334
|
+
const selected = slot.slotId === c.selectedSlotId;
|
|
335
|
+
return (
|
|
336
|
+
<button
|
|
337
|
+
key={slot.slotId}
|
|
338
|
+
onClick={() => c.selectSlot(slot.slotId)}
|
|
339
|
+
disabled={c.isProcessing}
|
|
340
|
+
style={{
|
|
341
|
+
padding: "12px",
|
|
342
|
+
borderRadius: theme.cornerRadius,
|
|
343
|
+
border: `2px solid ${selected ? theme.colors.primary : a(theme.colors.primary, 0.25)}`,
|
|
344
|
+
background: selected ? a(theme.colors.primary, 0.06) : "transparent",
|
|
345
|
+
color: selected ? theme.colors.primary : theme.colors.text,
|
|
346
|
+
cursor: c.isProcessing ? "not-allowed" : "pointer",
|
|
347
|
+
fontWeight: 600,
|
|
348
|
+
fontFamily: theme.fontFamily,
|
|
349
|
+
}}
|
|
350
|
+
>
|
|
351
|
+
{slot.time}
|
|
352
|
+
</button>
|
|
353
|
+
);
|
|
354
|
+
})}
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
);
|
|
358
|
+
})}
|
|
359
|
+
</div>
|
|
360
|
+
)}
|
|
361
|
+
|
|
362
|
+
{c.error && <p style={{ color: "#ef4444", fontSize: 14, marginTop: 12 }}>{c.error}</p>}
|
|
363
|
+
<ActionBar
|
|
364
|
+
c={c}
|
|
365
|
+
product={product}
|
|
366
|
+
fmt={fmt}
|
|
367
|
+
onNext={c.reserveSelected}
|
|
368
|
+
nextLabel={c.isProcessing ? "Reserving…" : "Continue"}
|
|
369
|
+
nextDisabled={!c.selectedSlotId || c.isProcessing}
|
|
370
|
+
/>
|
|
371
|
+
</div>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ── Step 2: buyer details + questionnaire ─────────────────────────────────────
|
|
376
|
+
function DetailsStep({ c, product, fmt }: { c: Booking; product: CoachingProduct; fmt: (n: number) => string }) {
|
|
377
|
+
const theme = useForgeTheme();
|
|
378
|
+
const [confirmEmail, setConfirmEmail] = useState("");
|
|
379
|
+
const [answers, setAnswers] = useState<Record<string, string>>({});
|
|
380
|
+
const [localError, setLocalError] = useState<string | null>(null);
|
|
381
|
+
|
|
382
|
+
const questions = useMemo(() => product.questionnaire ?? [], [product.questionnaire]);
|
|
383
|
+
|
|
384
|
+
const onAnswer = (qid: string, value: string) => {
|
|
385
|
+
const next = { ...answers, [qid]: value };
|
|
386
|
+
setAnswers(next);
|
|
387
|
+
c.setQuestionnaire(questions.map((q) => ({ ...q, answer: next[q.id] ?? "" })));
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const onContinue = () => {
|
|
391
|
+
setLocalError(null);
|
|
392
|
+
if (!c.firstName.trim() || !c.lastName.trim()) return setLocalError("Please enter your name.");
|
|
393
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email)) return setLocalError("Please enter a valid email.");
|
|
394
|
+
if (c.email !== confirmEmail) return setLocalError("Emails do not match.");
|
|
395
|
+
for (const q of questions) {
|
|
396
|
+
if (!q.optional && !(answers[q.id] ?? "").trim()) return setLocalError(`Please answer: ${q.question}`);
|
|
397
|
+
}
|
|
398
|
+
c.continueToPayment();
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const isFree = Number(product.price) <= 0;
|
|
402
|
+
|
|
403
|
+
return (
|
|
404
|
+
<div>
|
|
405
|
+
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 4 }}>Contact Information</h2>
|
|
406
|
+
<p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>
|
|
407
|
+
Please provide your contact information for the booking.
|
|
408
|
+
</p>
|
|
409
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
410
|
+
<div style={{ display: "flex", gap: 8 }}>
|
|
411
|
+
<input placeholder="First name" value={c.firstName} onChange={(e) => c.setFirstName(e.target.value)} style={inputStyle(theme)} />
|
|
412
|
+
<input placeholder="Last name" value={c.lastName} onChange={(e) => c.setLastName(e.target.value)} style={inputStyle(theme)} />
|
|
413
|
+
</div>
|
|
414
|
+
<div>
|
|
415
|
+
<input placeholder="Email" type="email" value={c.email} onChange={(e) => c.setEmail(e.target.value)} style={inputStyle(theme)} />
|
|
416
|
+
{c.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email) && <FieldError>Please enter a valid email address</FieldError>}
|
|
417
|
+
</div>
|
|
418
|
+
<div>
|
|
419
|
+
<input placeholder="Confirm email" type="email" value={confirmEmail} onChange={(e) => setConfirmEmail(e.target.value)} style={inputStyle(theme)} />
|
|
420
|
+
{confirmEmail && c.email !== confirmEmail && <FieldError>Email addresses do not match</FieldError>}
|
|
421
|
+
</div>
|
|
422
|
+
|
|
423
|
+
{questions.map((q) => {
|
|
424
|
+
const missing = !q.optional && !(answers[q.id] ?? "").trim();
|
|
425
|
+
return (
|
|
426
|
+
<label key={q.id} style={{ display: "flex", flexDirection: "column", gap: 6, fontSize: 14 }}>
|
|
427
|
+
<span>
|
|
428
|
+
{q.question}
|
|
429
|
+
{!q.optional && <span style={{ color: "#ef4444" }}> *</span>}
|
|
430
|
+
</span>
|
|
431
|
+
{q.type === "select" && q.options && q.options.length > 0 ? (
|
|
432
|
+
<select value={answers[q.id] ?? ""} onChange={(e) => onAnswer(q.id, e.target.value)} style={inputStyle(theme)}>
|
|
433
|
+
<option value="">Select…</option>
|
|
434
|
+
{q.options.map((opt) => (
|
|
435
|
+
<option key={opt} value={opt}>
|
|
436
|
+
{opt}
|
|
437
|
+
</option>
|
|
438
|
+
))}
|
|
439
|
+
</select>
|
|
440
|
+
) : q.type === "textarea" ? (
|
|
441
|
+
<textarea value={answers[q.id] ?? ""} onChange={(e) => onAnswer(q.id, e.target.value)} rows={3} style={inputStyle(theme)} />
|
|
442
|
+
) : (
|
|
443
|
+
<input value={answers[q.id] ?? ""} onChange={(e) => onAnswer(q.id, e.target.value)} style={inputStyle(theme)} />
|
|
444
|
+
)}
|
|
445
|
+
{missing && <FieldError>This field is required</FieldError>}
|
|
446
|
+
</label>
|
|
447
|
+
);
|
|
448
|
+
})}
|
|
449
|
+
|
|
450
|
+
{(localError || c.error) && <p style={{ color: "#ef4444", fontSize: 14 }}>{localError ?? c.error}</p>}
|
|
451
|
+
|
|
452
|
+
<p style={{ fontSize: 12, color: a(theme.colors.text, 0.6), marginTop: 4 }}>
|
|
453
|
+
By continuing, you agree to receive booking confirmations and important updates about your session via email.
|
|
454
|
+
</p>
|
|
455
|
+
</div>
|
|
456
|
+
<ActionBar
|
|
457
|
+
c={c}
|
|
458
|
+
product={product}
|
|
459
|
+
fmt={fmt}
|
|
460
|
+
onBack={() => void c.releaseReservation()}
|
|
461
|
+
onNext={onContinue}
|
|
462
|
+
nextLabel={c.isProcessing ? "Processing…" : isFree ? "Confirm Booking" : "Continue to payment"}
|
|
463
|
+
nextDisabled={c.isProcessing}
|
|
464
|
+
/>
|
|
465
|
+
</div>
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function FieldError({ children }: { children: ReactNode }) {
|
|
470
|
+
return <p style={{ color: "#ef4444", fontSize: 13, marginTop: 4 }}>{children}</p>;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ── Step 3: payment ───────────────────────────────────────────────────────────
|
|
474
|
+
function PaymentStep({
|
|
475
|
+
c,
|
|
476
|
+
product,
|
|
477
|
+
fmt,
|
|
478
|
+
renderPayment,
|
|
479
|
+
}: {
|
|
480
|
+
c: Booking;
|
|
481
|
+
product: CoachingProduct;
|
|
482
|
+
fmt: (n: number) => string;
|
|
483
|
+
renderPayment?: (props: PaymentRenderProps) => ReactNode;
|
|
484
|
+
}) {
|
|
485
|
+
const theme = useForgeTheme();
|
|
486
|
+
const total = c.totalAmount ?? Number(product.price);
|
|
487
|
+
// Authoritative tax quote from start-payment — the charged total already
|
|
488
|
+
// reflects it; we only caption the breakdown (display only).
|
|
489
|
+
const taxSummary = summarizeTaxQuote(c.taxQuote);
|
|
490
|
+
return (
|
|
491
|
+
<div>
|
|
492
|
+
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>Payment</h2>
|
|
493
|
+
<p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>Complete your purchase to secure your booking.</p>
|
|
494
|
+
<div
|
|
495
|
+
style={{
|
|
496
|
+
padding: 20,
|
|
497
|
+
border: `2px solid ${a(theme.colors.primary, 0.25)}`,
|
|
498
|
+
borderRadius: theme.cornerRadius,
|
|
499
|
+
marginBottom: 16,
|
|
500
|
+
display: "flex",
|
|
501
|
+
justifyContent: "space-between",
|
|
502
|
+
alignItems: "center",
|
|
503
|
+
}}
|
|
504
|
+
>
|
|
505
|
+
<span style={{ fontSize: 16 }}>Total Amount</span>
|
|
506
|
+
<span style={{ fontSize: 22, fontWeight: 700, color: theme.colors.primary, textAlign: "right" }}>
|
|
507
|
+
{fmt(total)}
|
|
508
|
+
{taxSummary && (
|
|
509
|
+
<span style={{ display: "block", fontSize: 12, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}>
|
|
510
|
+
{taxSummary.mode === "inclusive" ? taxSummary.label : `incl. ${taxSummary.formattedTax} tax`}
|
|
511
|
+
</span>
|
|
512
|
+
)}
|
|
513
|
+
</span>
|
|
514
|
+
</div>
|
|
515
|
+
{!renderPayment ? (
|
|
516
|
+
<p style={{ color: "#ef4444", fontSize: 14 }}>Payment is not configured.</p>
|
|
517
|
+
) : c.clientSecret ? (
|
|
518
|
+
renderPayment({ clientSecret: c.clientSecret, returnUrl: c.returnUrl, amount: total, mode: "redirect" })
|
|
519
|
+
) : (
|
|
520
|
+
<p style={{ opacity: 0.7 }}>Preparing payment…</p>
|
|
521
|
+
)}
|
|
522
|
+
<button onClick={() => c.setStep("details")} style={{ ...secondaryButtonStyle(theme), marginTop: 16 }}>
|
|
523
|
+
Back
|
|
524
|
+
</button>
|
|
525
|
+
</div>
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ── Shared bits ───────────────────────────────────────────────────────────────
|
|
530
|
+
function ActionBar({
|
|
531
|
+
c,
|
|
532
|
+
product,
|
|
533
|
+
fmt,
|
|
534
|
+
onBack,
|
|
535
|
+
onNext,
|
|
536
|
+
nextLabel,
|
|
537
|
+
nextDisabled,
|
|
538
|
+
}: {
|
|
539
|
+
c: Booking;
|
|
540
|
+
product: CoachingProduct;
|
|
541
|
+
fmt: (n: number) => string;
|
|
542
|
+
onBack?: () => void;
|
|
543
|
+
onNext?: () => void;
|
|
544
|
+
nextLabel?: string;
|
|
545
|
+
nextDisabled?: boolean;
|
|
546
|
+
}) {
|
|
547
|
+
const theme = useForgeTheme();
|
|
548
|
+
const total = c.totalAmount ?? Number(product.price);
|
|
549
|
+
return (
|
|
550
|
+
<div style={{ marginTop: 24 }}>
|
|
551
|
+
<div
|
|
552
|
+
style={{
|
|
553
|
+
padding: 16,
|
|
554
|
+
border: `1px solid ${a(theme.colors.primary, 0.25)}`,
|
|
555
|
+
borderRadius: theme.cornerRadius,
|
|
556
|
+
marginBottom: 16,
|
|
557
|
+
display: "flex",
|
|
558
|
+
justifyContent: "space-between",
|
|
559
|
+
alignItems: "center",
|
|
560
|
+
}}
|
|
561
|
+
>
|
|
562
|
+
<span style={{ fontWeight: 600 }}>Total</span>
|
|
563
|
+
<span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>{fmt(total)}</span>
|
|
564
|
+
</div>
|
|
565
|
+
{(onBack || onNext) && (
|
|
566
|
+
<div style={{ display: "flex", gap: 8, justifyContent: "space-between" }}>
|
|
567
|
+
<button onClick={onBack} disabled={!onBack} style={{ ...secondaryButtonStyle(theme), opacity: onBack ? 1 : 0.4 }}>
|
|
568
|
+
Back
|
|
569
|
+
</button>
|
|
570
|
+
{onNext && (
|
|
571
|
+
<button onClick={onNext} disabled={nextDisabled} style={{ ...primaryButtonStyle(theme), opacity: nextDisabled ? 0.6 : 1 }}>
|
|
572
|
+
{nextLabel}
|
|
573
|
+
</button>
|
|
574
|
+
)}
|
|
575
|
+
</div>
|
|
576
|
+
)}
|
|
577
|
+
</div>
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const triggerButtonStyle = (t: ForgeTheme): CSSProperties => buttonStyle(t, { variant: "primary", fullWidth: true });
|
|
582
|
+
|
|
583
|
+
const primaryButtonStyle = (t: ForgeTheme): CSSProperties => buttonStyle(t, { variant: "primary" });
|
|
584
|
+
|
|
585
|
+
const secondaryButtonStyle = (t: ForgeTheme): CSSProperties => buttonStyle(t, { variant: "outline" });
|
|
586
|
+
|
|
587
|
+
const closeButtonStyle = (t: ForgeTheme): CSSProperties => ({
|
|
588
|
+
background: "transparent",
|
|
589
|
+
border: "none",
|
|
590
|
+
color: t.colors.text,
|
|
591
|
+
cursor: "pointer",
|
|
592
|
+
fontSize: 16,
|
|
593
|
+
lineHeight: 1,
|
|
594
|
+
opacity: 0.7,
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
const inputStyle = (t: ForgeTheme): CSSProperties => ({
|
|
598
|
+
width: "100%",
|
|
599
|
+
padding: 10,
|
|
600
|
+
border: `1px solid ${a(t.colors.primary, 0.25)}`,
|
|
601
|
+
borderRadius: t.cornerRadius,
|
|
602
|
+
background: t.colors.background,
|
|
603
|
+
color: t.colors.text,
|
|
604
|
+
fontFamily: t.fontFamily,
|
|
605
|
+
});
|