@tribe-nest/forge 3.26.0 → 3.29.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 +1 -1
- package/src/client/_tests/localeHeader.spec.tsx +98 -0
- package/src/client/activeLocale.ts +34 -0
- package/src/client/createForgeClient.ts +13 -0
- package/src/contexts/CartContext.tsx +76 -0
- package/src/contexts/_tests/CartContext.spec.tsx +210 -0
- package/src/data/queries/useCheckouts.ts +83 -0
- package/src/data/queries/useWebsite.ts +7 -0
- package/src/i18n/CONVENTION.md +184 -0
- package/src/i18n/_tests/translationKeys.spec.ts +153 -0
- package/src/i18n/de.json +960 -0
- package/src/i18n/en.json +960 -0
- package/src/i18n/index.ts +125 -0
- package/src/index.ts +21 -0
- package/src/provider/ForgeAppProvider.tsx +2 -1
- package/src/provider/SiteConfigProvider.tsx +11 -0
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
- package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
- package/src/ui/headless/checkout/useCartResume.ts +340 -0
- package/src/ui/headless/checkout/useCheckout.ts +9 -2
- package/src/ui/headless/index.ts +18 -0
- package/src/ui/index.ts +2 -0
- package/src/ui/shell/TribeNestApp.tsx +36 -27
- package/src/ui/styled/AccountDashboard.tsx +197 -93
- package/src/ui/styled/AiAgentWidget.tsx +10 -6
- package/src/ui/styled/AudioPlayer.tsx +14 -6
- package/src/ui/styled/BlogCategory.tsx +4 -2
- package/src/ui/styled/BlogComments.tsx +27 -20
- package/src/ui/styled/BlogList.tsx +5 -3
- package/src/ui/styled/BlogPost.tsx +14 -11
- package/src/ui/styled/BundleConfirmation.tsx +32 -23
- package/src/ui/styled/CancellationTerms.tsx +3 -1
- package/src/ui/styled/Cart.tsx +32 -14
- package/src/ui/styled/ChatRoom.tsx +65 -20
- package/src/ui/styled/Checkout.tsx +151 -89
- package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
- package/src/ui/styled/CoachingBooking.tsx +71 -44
- package/src/ui/styled/CoachingConfirmation.tsx +56 -19
- package/src/ui/styled/CoachingDetail.tsx +15 -11
- package/src/ui/styled/CohortPage.tsx +10 -8
- package/src/ui/styled/ConfirmSubscription.tsx +14 -8
- package/src/ui/styled/ContactForm.tsx +31 -12
- package/src/ui/styled/CookieConsent.tsx +19 -15
- package/src/ui/styled/CourseAccess.tsx +54 -47
- package/src/ui/styled/CourseCheckout.tsx +56 -38
- package/src/ui/styled/CourseConfirmation.tsx +39 -17
- package/src/ui/styled/CourseDetail.tsx +21 -15
- package/src/ui/styled/CoursesGrid.tsx +4 -2
- package/src/ui/styled/CurrencySwitcher.tsx +4 -1
- package/src/ui/styled/DiscountCode.tsx +22 -18
- package/src/ui/styled/DonationButton.tsx +52 -17
- package/src/ui/styled/DonationPage.tsx +4 -4
- package/src/ui/styled/EmailListForm.tsx +22 -9
- package/src/ui/styled/EventConfirmation.tsx +37 -14
- package/src/ui/styled/EventCountdown.tsx +10 -6
- package/src/ui/styled/EventDetail.tsx +8 -6
- package/src/ui/styled/EventSeriesDetail.tsx +18 -13
- package/src/ui/styled/EventTickets.tsx +232 -108
- package/src/ui/styled/EventWaitlist.tsx +42 -33
- package/src/ui/styled/EventsList.tsx +15 -13
- package/src/ui/styled/ForgotPasswordForm.tsx +12 -12
- package/src/ui/styled/FormRenderer.tsx +12 -7
- package/src/ui/styled/InstallBanner.tsx +21 -18
- package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
- package/src/ui/styled/InvoicePayment.tsx +37 -16
- package/src/ui/styled/LeadMagnet.tsx +9 -7
- package/src/ui/styled/Loading.tsx +7 -1
- package/src/ui/styled/LoginForm.tsx +22 -14
- package/src/ui/styled/MembershipCheckout.tsx +32 -13
- package/src/ui/styled/MembershipTiers.tsx +27 -18
- package/src/ui/styled/OfferButton.tsx +33 -13
- package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
- package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
- package/src/ui/styled/PodcastEpisode.tsx +20 -9
- package/src/ui/styled/PodcastList.tsx +9 -3
- package/src/ui/styled/PodcastShow.tsx +29 -7
- package/src/ui/styled/PostsFeed.tsx +10 -8
- package/src/ui/styled/PresaleCode.tsx +8 -11
- package/src/ui/styled/ProductBrowseNav.tsx +21 -11
- package/src/ui/styled/ProductDetail.tsx +58 -28
- package/src/ui/styled/ReactionBar.tsx +4 -2
- package/src/ui/styled/ReplayList.tsx +24 -13
- package/src/ui/styled/ResetPasswordForm.tsx +27 -15
- package/src/ui/styled/ResumeCart.tsx +289 -0
- package/src/ui/styled/ReviewForm.tsx +52 -58
- package/src/ui/styled/SignupForm.tsx +29 -16
- package/src/ui/styled/TicketTransfer.tsx +55 -54
- package/src/ui/styled/UserMenu.tsx +16 -14
- package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
|
@@ -21,6 +21,7 @@ import { useCart } from "../../contexts/CartContext";
|
|
|
21
21
|
import { usePublicAuth } from "../../contexts/PublicAuthContext";
|
|
22
22
|
import { OrderStatus, ProductDeliveryType, type IPublicOrder } from "../../types/models";
|
|
23
23
|
import { CartLineOptions } from "./CartLineOptions";
|
|
24
|
+
import { useForgeT } from "../../i18n";
|
|
24
25
|
|
|
25
26
|
export interface CheckoutConfirmationProps {
|
|
26
27
|
orderId?: string;
|
|
@@ -60,10 +61,10 @@ const PAID_STATES = new Set<OrderStatus>([
|
|
|
60
61
|
const FAILED_STATES = new Set<OrderStatus>([OrderStatus.PaymentFailed, OrderStatus.Failed, OrderStatus.Cancelled]);
|
|
61
62
|
const PENDING_STATES = new Set<OrderStatus>([OrderStatus.InitiatedPayment, OrderStatus.Processing]);
|
|
62
63
|
|
|
63
|
-
const
|
|
64
|
-
[ProductDeliveryType.Physical]: "
|
|
65
|
-
[ProductDeliveryType.Service]: "
|
|
66
|
-
[ProductDeliveryType.Digital]: "
|
|
64
|
+
const DELIVERY_LABEL_KEY: Record<ProductDeliveryType, string> = {
|
|
65
|
+
[ProductDeliveryType.Physical]: "forge.checkout_confirmation.delivery_physical",
|
|
66
|
+
[ProductDeliveryType.Service]: "forge.checkout_confirmation.delivery_service",
|
|
67
|
+
[ProductDeliveryType.Digital]: "forge.checkout_confirmation.delivery_digital",
|
|
67
68
|
};
|
|
68
69
|
|
|
69
70
|
/** Short date only, e.g. "Jun 30, 2026". */
|
|
@@ -130,6 +131,7 @@ function OrderConfirmation({
|
|
|
130
131
|
accountPath = "/i/account",
|
|
131
132
|
checkoutPath = "/i/checkout",
|
|
132
133
|
}: CheckoutConfirmationProps) {
|
|
134
|
+
const t = useForgeT();
|
|
133
135
|
const theme = useForgeTheme();
|
|
134
136
|
const formatAmt = useAmountFormatter(formatAmount);
|
|
135
137
|
const text = theme.colors.text;
|
|
@@ -165,7 +167,7 @@ function OrderConfirmation({
|
|
|
165
167
|
<div style={{ textAlign: "center", color: alpha(text, 0.75) }}>
|
|
166
168
|
<Loader2 size={40} color={primary} style={{ animation: "cc-spin 1s linear infinite" }} />
|
|
167
169
|
<style>{"@keyframes cc-spin{to{transform:rotate(360deg)}}"}</style>
|
|
168
|
-
<p style={{ marginTop: 16, fontSize: 15 }}>
|
|
170
|
+
<p style={{ marginTop: 16, fontSize: 15 }}>{t("forge.checkout_confirmation.confirming")}</p>
|
|
169
171
|
</div>
|
|
170
172
|
</ConfirmationStage>
|
|
171
173
|
);
|
|
@@ -178,14 +180,14 @@ function OrderConfirmation({
|
|
|
178
180
|
<ConfirmationCard width={440}>
|
|
179
181
|
<div style={{ padding: "40px 30px", textAlign: "center" }}>
|
|
180
182
|
<WarnSeal icon={<AlertTriangle size={34} color="#f59e0b" />} />
|
|
181
|
-
<h1 style={{ fontSize: 24, fontWeight: 800, margin: "22px 0 8px" }}>
|
|
183
|
+
<h1 style={{ fontSize: 24, fontWeight: 800, margin: "22px 0 8px" }}>{t("forge.checkout_confirmation.not_found_title")}</h1>
|
|
182
184
|
<p style={{ fontSize: 15, color: alpha(text, 0.7), margin: "0 0 24px" }}>
|
|
183
185
|
{isError
|
|
184
|
-
? "
|
|
185
|
-
: "
|
|
186
|
+
? t("forge.checkout_confirmation.not_found_error_body")
|
|
187
|
+
: t("forge.checkout_confirmation.not_found_body")}
|
|
186
188
|
</p>
|
|
187
189
|
<Action href={explorePath} primary={primary}>
|
|
188
|
-
|
|
190
|
+
{t("forge.checkout_confirmation.continue_shopping")} <ArrowRight size={16} />
|
|
189
191
|
</Action>
|
|
190
192
|
</div>
|
|
191
193
|
</ConfirmationCard>
|
|
@@ -207,18 +209,28 @@ function OrderConfirmation({
|
|
|
207
209
|
const discount = order?.discountAmount || 0;
|
|
208
210
|
const email = order?.customerEmail;
|
|
209
211
|
|
|
210
|
-
const eyebrow = isPaid
|
|
212
|
+
const eyebrow = isPaid
|
|
213
|
+
? t("forge.checkout_confirmation.eyebrow_paid")
|
|
214
|
+
: isFailed
|
|
215
|
+
? t("forge.checkout_confirmation.eyebrow_failed")
|
|
216
|
+
: t("forge.checkout_confirmation.eyebrow_pending");
|
|
211
217
|
const eyebrowColor = isPaid ? primary : isFailed ? "#f59e0b" : primary;
|
|
212
|
-
const heading = isPaid
|
|
218
|
+
const heading = isPaid
|
|
219
|
+
? t("forge.checkout_confirmation.heading_paid")
|
|
220
|
+
: isFailed
|
|
221
|
+
? t("forge.checkout_confirmation.heading_failed")
|
|
222
|
+
: gaveUp
|
|
223
|
+
? t("forge.checkout_confirmation.heading_still_processing")
|
|
224
|
+
: t("forge.checkout_confirmation.confirming");
|
|
213
225
|
const sub = isPaid
|
|
214
226
|
? email
|
|
215
|
-
?
|
|
216
|
-
: "
|
|
227
|
+
? t("forge.checkout_confirmation.receipt_emailed", { email })
|
|
228
|
+
: t("forge.checkout_confirmation.receipt_on_way")
|
|
217
229
|
: isFailed
|
|
218
|
-
? "
|
|
230
|
+
? t("forge.checkout_confirmation.failed_body")
|
|
219
231
|
: gaveUp
|
|
220
|
-
? "
|
|
221
|
-
: "
|
|
232
|
+
? t("forge.checkout_confirmation.slow_body")
|
|
233
|
+
: t("forge.checkout_confirmation.pending_body");
|
|
222
234
|
|
|
223
235
|
return (
|
|
224
236
|
<ConfirmationStage>
|
|
@@ -284,17 +296,21 @@ function OrderConfirmation({
|
|
|
284
296
|
{isFailed ? (
|
|
285
297
|
<div className="cf-rise" style={{ padding: "6px 30px 34px", animationDelay: "0.4s" }}>
|
|
286
298
|
<Action href={checkoutPath} primary={primary}>
|
|
287
|
-
|
|
299
|
+
{t("forge.checkout_confirmation.return_to_checkout")} <ArrowRight size={16} />
|
|
288
300
|
</Action>
|
|
289
301
|
</div>
|
|
290
302
|
) : (
|
|
291
303
|
<>
|
|
292
304
|
{/* ── Order items (grouped by delivery) ── */}
|
|
293
305
|
<div className="cf-rise" style={{ padding: "0 30px 8px", animationDelay: "0.42s" }}>
|
|
294
|
-
<SectionLabel
|
|
306
|
+
<SectionLabel
|
|
307
|
+
icon={<ShoppingBag size={16} color={primary} />}
|
|
308
|
+
text={t("forge.checkout_confirmation.order_section")}
|
|
309
|
+
muted={alpha(text, 0.55)}
|
|
310
|
+
/>
|
|
295
311
|
{groups.length === 0 ? (
|
|
296
312
|
<p style={{ textAlign: "center", padding: "12px 0", color: alpha(text, 0.6), fontSize: 14 }}>
|
|
297
|
-
|
|
313
|
+
{t("forge.checkout_confirmation.no_items")}
|
|
298
314
|
</p>
|
|
299
315
|
) : (
|
|
300
316
|
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
|
@@ -308,24 +324,32 @@ function OrderConfirmation({
|
|
|
308
324
|
{/* ── Totals ── */}
|
|
309
325
|
<div className="cf-rise" style={{ padding: "12px 30px 0", animationDelay: "0.48s" }}>
|
|
310
326
|
<div style={{ paddingTop: 14, borderTop: `1px solid ${alpha(text, 0.1)}`, display: "flex", flexDirection: "column", gap: 6 }}>
|
|
311
|
-
<SummaryLine label="
|
|
312
|
-
{shipping > 0 &&
|
|
313
|
-
|
|
327
|
+
<SummaryLine label={t("forge.checkout_confirmation.subtotal")} value={fmt(subtotal)} muted={alpha(text, 0.6)} />
|
|
328
|
+
{shipping > 0 && (
|
|
329
|
+
<SummaryLine label={t("forge.checkout_confirmation.shipping")} value={fmt(shipping)} muted={alpha(text, 0.6)} />
|
|
330
|
+
)}
|
|
331
|
+
{discount > 0 && (
|
|
332
|
+
<SummaryLine label={t("forge.checkout_confirmation.discount")} value={`−${fmt(discount)}`} muted={alpha(text, 0.6)} />
|
|
333
|
+
)}
|
|
314
334
|
{Number(order?.taxAmount || 0) > 0 && !order?.taxInclusive && (
|
|
315
335
|
<SummaryLine
|
|
316
|
-
label="
|
|
336
|
+
label={t("forge.checkout_confirmation.tax")}
|
|
317
337
|
value={formatAmt(Number(order?.taxAmount), order?.chargedCurrency || currency)}
|
|
318
338
|
muted={alpha(text, 0.6)}
|
|
319
339
|
/>
|
|
320
340
|
)}
|
|
321
341
|
</div>
|
|
322
342
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 12 }}>
|
|
323
|
-
<span style={{ fontWeight: 700 }}>
|
|
343
|
+
<span style={{ fontWeight: 700 }}>
|
|
344
|
+
{isPaid ? t("forge.checkout_confirmation.total_paid") : t("forge.checkout_confirmation.total")}
|
|
345
|
+
</span>
|
|
324
346
|
<span style={{ fontSize: 20, fontWeight: 800, color: primary }}>{totalDisplay}</span>
|
|
325
347
|
</div>
|
|
326
348
|
{Number(order?.taxAmount || 0) > 0 && order?.taxInclusive && (
|
|
327
349
|
<div style={{ textAlign: "right", fontSize: 12, color: alpha(text, 0.6), marginTop: 4 }}>
|
|
328
|
-
|
|
350
|
+
{t("forge.checkout_confirmation.tax_inclusive_note", {
|
|
351
|
+
amount: formatAmt(Number(order?.taxAmount), order?.chargedCurrency || currency),
|
|
352
|
+
})}
|
|
329
353
|
</div>
|
|
330
354
|
)}
|
|
331
355
|
</div>
|
|
@@ -337,22 +361,28 @@ function OrderConfirmation({
|
|
|
337
361
|
{/* ── Details ── */}
|
|
338
362
|
<div className="cf-rise" style={{ padding: "20px 30px 8px", animationDelay: "0.54s" }}>
|
|
339
363
|
<dl style={{ display: "grid", gridTemplateColumns: "1fr auto", rowGap: 10, columnGap: 16, margin: 0 }}>
|
|
340
|
-
<ConfirmationRow
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
364
|
+
<ConfirmationRow
|
|
365
|
+
label={t("forge.checkout_confirmation.row_order")}
|
|
366
|
+
value={`#${order!.id.slice(0, 8).toUpperCase()}`}
|
|
367
|
+
mono
|
|
368
|
+
/>
|
|
369
|
+
<ConfirmationRow label={t("forge.checkout_confirmation.row_date")} value={formatDate(order!.createdAt)} />
|
|
370
|
+
{order!.customerName && (
|
|
371
|
+
<ConfirmationRow label={t("forge.checkout_confirmation.row_name")} value={order!.customerName} />
|
|
372
|
+
)}
|
|
373
|
+
{email && <ConfirmationRow label={t("forge.checkout_confirmation.row_email")} value={email} />}
|
|
344
374
|
</dl>
|
|
345
375
|
</div>
|
|
346
376
|
|
|
347
377
|
{/* ── Actions ── */}
|
|
348
378
|
<div className="cf-rise" style={{ padding: "22px 30px 30px", display: "flex", flexDirection: "column", gap: 10, animationDelay: "0.6s" }}>
|
|
349
379
|
<Action href={explorePath} primary={primary}>
|
|
350
|
-
|
|
380
|
+
{t("forge.checkout_confirmation.continue_shopping")} <ArrowRight size={16} />
|
|
351
381
|
</Action>
|
|
352
382
|
{/* Orders live behind auth — only offer the link to signed-in buyers. */}
|
|
353
383
|
{user && (
|
|
354
384
|
<Action href={accountPath} primary={primary} ghost>
|
|
355
|
-
|
|
385
|
+
{t("forge.checkout_confirmation.view_orders")}
|
|
356
386
|
</Action>
|
|
357
387
|
)}
|
|
358
388
|
</div>
|
|
@@ -381,15 +411,16 @@ function GroupBlock({
|
|
|
381
411
|
fmt: (n: number) => string;
|
|
382
412
|
theme: ReturnType<typeof useForgeTheme>;
|
|
383
413
|
}) {
|
|
414
|
+
const t = useForgeT();
|
|
384
415
|
const text = theme.colors.text;
|
|
385
416
|
const primary = theme.colors.primary;
|
|
386
417
|
return (
|
|
387
418
|
<div style={{ borderRadius: theme.cornerRadius, border: `1px solid ${alpha(primary, 0.16)}`, background: alpha(primary, 0.04), overflow: "hidden" }}>
|
|
388
419
|
{/* group header */}
|
|
389
420
|
<div style={{ padding: "10px 14px", borderBottom: `1px solid ${alpha(primary, 0.12)}` }}>
|
|
390
|
-
<div style={{ fontSize: 13, fontWeight: 700, color: text }}>{
|
|
421
|
+
<div style={{ fontSize: 13, fontWeight: 700, color: text }}>{t(DELIVERY_LABEL_KEY[group.deliveryType])}</div>
|
|
391
422
|
<div style={{ fontSize: 12, color: alpha(text, 0.65), marginTop: 2 }}>
|
|
392
|
-
|
|
423
|
+
{t("forge.checkout_confirmation.recipient", { name: group.recipientName, email: group.recipientEmail })}
|
|
393
424
|
</div>
|
|
394
425
|
{group.recipientMessage && (
|
|
395
426
|
<div style={{ fontSize: 12, color: alpha(text, 0.6), marginTop: 2, fontStyle: "italic" }}>“{group.recipientMessage}”</div>
|
|
@@ -418,14 +449,14 @@ function GroupBlock({
|
|
|
418
449
|
{item.title}
|
|
419
450
|
</div>
|
|
420
451
|
<div style={{ fontSize: 12, color: alpha(text, 0.6), marginTop: 2 }}>
|
|
421
|
-
{fmt(item.price)
|
|
452
|
+
{t("forge.checkout_confirmation.item_price_qty", { price: fmt(item.price), quantity: item.quantity })}
|
|
422
453
|
</div>
|
|
423
454
|
{/* Which version — the receipt has to say, or a buyer cannot check
|
|
424
455
|
they were charged for the one they picked. */}
|
|
425
456
|
<CartLineOptions item={item} accent={primary} size="small" />
|
|
426
457
|
{item.isGift && (
|
|
427
458
|
<div style={{ fontSize: 12, marginTop: 4, color: primary }}>
|
|
428
|
-
|
|
459
|
+
{t("forge.checkout_confirmation.gift_for", { name: item.recipientName ?? "", email: item.recipientEmail ?? "" })}
|
|
429
460
|
</div>
|
|
430
461
|
)}
|
|
431
462
|
{item.recipientMessage && (
|
|
@@ -2,6 +2,7 @@ import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useStat
|
|
|
2
2
|
import { Calendar, ChevronLeft, ChevronRight, Clock, X } from "lucide-react";
|
|
3
3
|
import type { BookingSlot, CoachingProduct } from "../../types/models";
|
|
4
4
|
import { useCoachingBooking } from "../headless/coaching/useCoachingBooking";
|
|
5
|
+
import { useForgeT } from "../../i18n";
|
|
5
6
|
import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
|
|
6
7
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
7
8
|
import { summarizeTaxQuote } from "../format/PriceDisplay";
|
|
@@ -56,12 +57,13 @@ const a = (hex: string, alpha: number) => hex + Math.round(alpha * 255).toString
|
|
|
56
57
|
*/
|
|
57
58
|
export function CoachingBooking({
|
|
58
59
|
slug,
|
|
59
|
-
triggerText
|
|
60
|
+
triggerText,
|
|
60
61
|
formatAmount,
|
|
61
62
|
renderPayment,
|
|
62
63
|
finalisePath,
|
|
63
64
|
onComplete,
|
|
64
65
|
}: CoachingBookingProps) {
|
|
66
|
+
const t = useForgeT();
|
|
65
67
|
const theme = useForgeTheme();
|
|
66
68
|
const registered = usePaymentRenderer();
|
|
67
69
|
const fmt = useAmountFormatter(formatAmount);
|
|
@@ -72,7 +74,7 @@ export function CoachingBooking({
|
|
|
72
74
|
|
|
73
75
|
const tryClose = (next: boolean) => {
|
|
74
76
|
if (!next && c.step !== "slot") {
|
|
75
|
-
if (typeof window !== "undefined" && !window.confirm("
|
|
77
|
+
if (typeof window !== "undefined" && !window.confirm(t("forge.coaching_booking.confirm_leave"))) return;
|
|
76
78
|
// Release the held slot so it's not locked until the timer runs out.
|
|
77
79
|
void c.releaseReservation();
|
|
78
80
|
}
|
|
@@ -82,7 +84,7 @@ export function CoachingBooking({
|
|
|
82
84
|
return (
|
|
83
85
|
<DialogRoot open={open} onOpenChange={tryClose}>
|
|
84
86
|
<DialogTrigger asChild>
|
|
85
|
-
<button style={triggerButtonStyle(theme)}>{triggerText}</button>
|
|
87
|
+
<button style={triggerButtonStyle(theme)}>{triggerText ?? t("forge.coaching_booking.trigger")}</button>
|
|
86
88
|
</DialogTrigger>
|
|
87
89
|
<DialogPortal>
|
|
88
90
|
<DialogOverlay style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 50 }} />
|
|
@@ -106,17 +108,17 @@ export function CoachingBooking({
|
|
|
106
108
|
>
|
|
107
109
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
|
|
108
110
|
<DialogTitle style={{ fontSize: 18, fontWeight: 700, margin: 0 }}>
|
|
109
|
-
{c.product?.title ?? "
|
|
111
|
+
{c.product?.title ?? t("forge.coaching_booking.title")}
|
|
110
112
|
</DialogTitle>
|
|
111
113
|
<DialogClose asChild>
|
|
112
|
-
<button aria-label="
|
|
114
|
+
<button aria-label={t("forge.coaching_booking.aria_close")} style={closeButtonStyle(theme)}>
|
|
113
115
|
<X size={18} />
|
|
114
116
|
</button>
|
|
115
117
|
</DialogClose>
|
|
116
118
|
</div>
|
|
117
119
|
|
|
118
120
|
{c.isLoading && <Loading />}
|
|
119
|
-
{!c.isLoading && !c.product && <p style={{ marginTop: 8 }}>
|
|
121
|
+
{!c.isLoading && !c.product && <p style={{ marginTop: 8 }}>{t("forge.coaching_booking.not_found")}</p>}
|
|
120
122
|
{c.product && <CoachingBookingBody c={c} product={c.product} fmt={fmt} renderPayment={renderPay} />}
|
|
121
123
|
</DialogContent>
|
|
122
124
|
</DialogPortal>
|
|
@@ -156,6 +158,7 @@ function CoachingBookingBody({
|
|
|
156
158
|
// (keeps the Stripe form in the payment step from re-mounting). Fires `onExpire`
|
|
157
159
|
// exactly once when the hold runs out.
|
|
158
160
|
function ReservationTimer({ expiresAt, onExpire }: { expiresAt: number | null; onExpire: () => void }) {
|
|
161
|
+
const t = useForgeT();
|
|
159
162
|
const theme = useForgeTheme();
|
|
160
163
|
const [now, setNow] = useState(() => Date.now());
|
|
161
164
|
const firedRef = useRef(false);
|
|
@@ -202,7 +205,9 @@ function ReservationTimer({ expiresAt, onExpire }: { expiresAt: number | null; o
|
|
|
202
205
|
}}
|
|
203
206
|
>
|
|
204
207
|
<Clock size={16} style={{ color }} />
|
|
205
|
-
<span style={{ fontWeight: 600, fontSize: 14, color }}>
|
|
208
|
+
<span style={{ fontWeight: 600, fontSize: 14, color }}>
|
|
209
|
+
{t("forge.coaching_booking.time_remaining", { time: mmss })}
|
|
210
|
+
</span>
|
|
206
211
|
</div>
|
|
207
212
|
);
|
|
208
213
|
}
|
|
@@ -250,6 +255,7 @@ function Progress({ current }: { current: number }) {
|
|
|
250
255
|
|
|
251
256
|
// ── Step 1: week navigation + slot selection (reserve happens on Continue) ─────
|
|
252
257
|
function SlotStep({ c, product, fmt }: { c: Booking; product: CoachingProduct; fmt: (n: number) => string }) {
|
|
258
|
+
const t = useForgeT();
|
|
253
259
|
const theme = useForgeTheme();
|
|
254
260
|
const slots: BookingSlot[] = c.availability.data ?? [];
|
|
255
261
|
|
|
@@ -284,7 +290,10 @@ function SlotStep({ c, product, fmt }: { c: Booking; product: CoachingProduct; f
|
|
|
284
290
|
}
|
|
285
291
|
})();
|
|
286
292
|
|
|
287
|
-
const weekLabel =
|
|
293
|
+
const weekLabel = t("forge.coaching_booking.week_range", {
|
|
294
|
+
start: c.weekStart.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
|
|
295
|
+
end: c.weekEnd.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }),
|
|
296
|
+
});
|
|
288
297
|
const navButton = (disabled: boolean): CSSProperties => ({
|
|
289
298
|
padding: 8,
|
|
290
299
|
borderRadius: theme.cornerRadius,
|
|
@@ -299,28 +308,31 @@ function SlotStep({ c, product, fmt }: { c: Booking; product: CoachingProduct; f
|
|
|
299
308
|
|
|
300
309
|
return (
|
|
301
310
|
<div>
|
|
302
|
-
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 6 }}>
|
|
311
|
+
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 6 }}>{t("forge.coaching_booking.slot_title")}</h2>
|
|
303
312
|
<p style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13, color: a(theme.colors.text, 0.6), margin: "0 0 16px" }}>
|
|
304
|
-
<Clock size={14} />
|
|
313
|
+
<Clock size={14} />{" "}
|
|
314
|
+
{tzAbbr
|
|
315
|
+
? t("forge.coaching_booking.timezone_note_named", { timezone: tzAbbr })
|
|
316
|
+
: t("forge.coaching_booking.timezone_note")}
|
|
305
317
|
</p>
|
|
306
318
|
|
|
307
319
|
{/* Week navigation */}
|
|
308
320
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
|
|
309
321
|
<span style={{ fontSize: 14, color: a(theme.colors.text, 0.6) }}>{weekLabel}</span>
|
|
310
322
|
<div style={{ display: "flex", gap: 8 }}>
|
|
311
|
-
<button aria-label="
|
|
323
|
+
<button aria-label={t("forge.coaching_booking.aria_prev_week")} onClick={c.goPrevWeek} disabled={!c.canGoPrevWeek} style={navButton(!c.canGoPrevWeek)}>
|
|
312
324
|
<ChevronLeft size={16} />
|
|
313
325
|
</button>
|
|
314
|
-
<button aria-label="
|
|
326
|
+
<button aria-label={t("forge.coaching_booking.aria_next_week")} onClick={c.goNextWeek} style={navButton(false)}>
|
|
315
327
|
<ChevronRight size={16} />
|
|
316
328
|
</button>
|
|
317
329
|
</div>
|
|
318
330
|
</div>
|
|
319
331
|
|
|
320
332
|
{c.availability.isLoading ? (
|
|
321
|
-
<Loading label="
|
|
333
|
+
<Loading label={t("forge.coaching_booking.loading_slots")} />
|
|
322
334
|
) : grouped.length === 0 ? (
|
|
323
|
-
<p style={{ opacity: 0.7 }}>
|
|
335
|
+
<p style={{ opacity: 0.7 }}>{t("forge.coaching_booking.no_slots")}</p>
|
|
324
336
|
) : (
|
|
325
337
|
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
|
326
338
|
{grouped.map(([dateKey, daySlots]) => {
|
|
@@ -376,7 +388,7 @@ function SlotStep({ c, product, fmt }: { c: Booking; product: CoachingProduct; f
|
|
|
376
388
|
product={product}
|
|
377
389
|
fmt={fmt}
|
|
378
390
|
onNext={c.reserveSelected}
|
|
379
|
-
nextLabel={c.isProcessing ? "
|
|
391
|
+
nextLabel={c.isProcessing ? t("forge.coaching_booking.reserving") : t("forge.coaching_booking.continue")}
|
|
380
392
|
nextDisabled={!c.selectedSlotId || c.isProcessing}
|
|
381
393
|
/>
|
|
382
394
|
</div>
|
|
@@ -385,6 +397,7 @@ function SlotStep({ c, product, fmt }: { c: Booking; product: CoachingProduct; f
|
|
|
385
397
|
|
|
386
398
|
// ── Step 2: buyer details + questionnaire ─────────────────────────────────────
|
|
387
399
|
function DetailsStep({ c, product, fmt }: { c: Booking; product: CoachingProduct; fmt: (n: number) => string }) {
|
|
400
|
+
const t = useForgeT();
|
|
388
401
|
const theme = useForgeTheme();
|
|
389
402
|
const [confirmEmail, setConfirmEmail] = useState("");
|
|
390
403
|
const [answers, setAnswers] = useState<Record<string, string>>({});
|
|
@@ -400,11 +413,13 @@ function DetailsStep({ c, product, fmt }: { c: Booking; product: CoachingProduct
|
|
|
400
413
|
|
|
401
414
|
const onContinue = () => {
|
|
402
415
|
setLocalError(null);
|
|
403
|
-
if (!c.firstName.trim() || !c.lastName.trim()) return setLocalError("
|
|
404
|
-
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email))
|
|
405
|
-
|
|
416
|
+
if (!c.firstName.trim() || !c.lastName.trim()) return setLocalError(t("forge.coaching_booking.error_name_required"));
|
|
417
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email))
|
|
418
|
+
return setLocalError(t("forge.coaching_booking.error_email_invalid"));
|
|
419
|
+
if (c.email !== confirmEmail) return setLocalError(t("forge.coaching_booking.error_email_mismatch"));
|
|
406
420
|
for (const q of questions) {
|
|
407
|
-
if (!q.optional && !(answers[q.id] ?? "").trim())
|
|
421
|
+
if (!q.optional && !(answers[q.id] ?? "").trim())
|
|
422
|
+
return setLocalError(t("forge.coaching_booking.error_answer_required", { question: q.question }));
|
|
408
423
|
}
|
|
409
424
|
c.continueToPayment();
|
|
410
425
|
};
|
|
@@ -413,22 +428,24 @@ function DetailsStep({ c, product, fmt }: { c: Booking; product: CoachingProduct
|
|
|
413
428
|
|
|
414
429
|
return (
|
|
415
430
|
<div>
|
|
416
|
-
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 4 }}>
|
|
417
|
-
<p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>
|
|
418
|
-
Please provide your contact information for the booking.
|
|
419
|
-
</p>
|
|
431
|
+
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 4 }}>{t("forge.coaching_booking.details_title")}</h2>
|
|
432
|
+
<p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>{t("forge.coaching_booking.details_subtitle")}</p>
|
|
420
433
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
421
434
|
<div style={{ display: "flex", gap: 8 }}>
|
|
422
|
-
<input placeholder="
|
|
423
|
-
<input placeholder="
|
|
435
|
+
<input placeholder={t("forge.coaching_booking.first_name_placeholder")} value={c.firstName} onChange={(e) => c.setFirstName(e.target.value)} style={inputStyle(theme)} />
|
|
436
|
+
<input placeholder={t("forge.coaching_booking.last_name_placeholder")} value={c.lastName} onChange={(e) => c.setLastName(e.target.value)} style={inputStyle(theme)} />
|
|
424
437
|
</div>
|
|
425
438
|
<div>
|
|
426
|
-
<input placeholder="
|
|
427
|
-
{c.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email) &&
|
|
439
|
+
<input placeholder={t("forge.coaching_booking.email_placeholder")} type="email" value={c.email} onChange={(e) => c.setEmail(e.target.value)} style={inputStyle(theme)} />
|
|
440
|
+
{c.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email) && (
|
|
441
|
+
<FieldError>{t("forge.coaching_booking.email_invalid_hint")}</FieldError>
|
|
442
|
+
)}
|
|
428
443
|
</div>
|
|
429
444
|
<div>
|
|
430
|
-
<input placeholder="
|
|
431
|
-
{confirmEmail && c.email !== confirmEmail &&
|
|
445
|
+
<input placeholder={t("forge.coaching_booking.confirm_email_placeholder")} type="email" value={confirmEmail} onChange={(e) => setConfirmEmail(e.target.value)} style={inputStyle(theme)} />
|
|
446
|
+
{confirmEmail && c.email !== confirmEmail && (
|
|
447
|
+
<FieldError>{t("forge.coaching_booking.email_mismatch_hint")}</FieldError>
|
|
448
|
+
)}
|
|
432
449
|
</div>
|
|
433
450
|
|
|
434
451
|
{questions.map((q) => {
|
|
@@ -441,7 +458,7 @@ function DetailsStep({ c, product, fmt }: { c: Booking; product: CoachingProduct
|
|
|
441
458
|
</span>
|
|
442
459
|
{q.type === "select" && q.options && q.options.length > 0 ? (
|
|
443
460
|
<select value={answers[q.id] ?? ""} onChange={(e) => onAnswer(q.id, e.target.value)} style={inputStyle(theme)}>
|
|
444
|
-
<option value="">
|
|
461
|
+
<option value="">{t("forge.coaching_booking.select_placeholder")}</option>
|
|
445
462
|
{q.options.map((opt) => (
|
|
446
463
|
<option key={opt} value={opt}>
|
|
447
464
|
{opt}
|
|
@@ -453,7 +470,7 @@ function DetailsStep({ c, product, fmt }: { c: Booking; product: CoachingProduct
|
|
|
453
470
|
) : (
|
|
454
471
|
<input value={answers[q.id] ?? ""} onChange={(e) => onAnswer(q.id, e.target.value)} style={inputStyle(theme)} />
|
|
455
472
|
)}
|
|
456
|
-
{missing && <FieldError>
|
|
473
|
+
{missing && <FieldError>{t("forge.coaching_booking.field_required")}</FieldError>}
|
|
457
474
|
</label>
|
|
458
475
|
);
|
|
459
476
|
})}
|
|
@@ -475,7 +492,7 @@ function DetailsStep({ c, product, fmt }: { c: Booking; product: CoachingProduct
|
|
|
475
492
|
<CancellationTerms terms={c.cancellation} />
|
|
476
493
|
|
|
477
494
|
<p style={{ fontSize: 12, color: a(theme.colors.text, 0.6), marginTop: 4 }}>
|
|
478
|
-
|
|
495
|
+
{t("forge.coaching_booking.consent")}
|
|
479
496
|
</p>
|
|
480
497
|
</div>
|
|
481
498
|
<ActionBar
|
|
@@ -484,7 +501,13 @@ function DetailsStep({ c, product, fmt }: { c: Booking; product: CoachingProduct
|
|
|
484
501
|
fmt={fmt}
|
|
485
502
|
onBack={() => void c.releaseReservation()}
|
|
486
503
|
onNext={onContinue}
|
|
487
|
-
nextLabel={
|
|
504
|
+
nextLabel={
|
|
505
|
+
c.isProcessing
|
|
506
|
+
? t("forge.coaching_booking.processing")
|
|
507
|
+
: isFree
|
|
508
|
+
? t("forge.coaching_booking.confirm_booking")
|
|
509
|
+
: t("forge.coaching_booking.continue_to_payment")
|
|
510
|
+
}
|
|
488
511
|
nextDisabled={c.isProcessing}
|
|
489
512
|
/>
|
|
490
513
|
</div>
|
|
@@ -507,6 +530,7 @@ function PaymentStep({
|
|
|
507
530
|
fmt: (n: number) => string;
|
|
508
531
|
renderPayment?: (props: PaymentRenderProps) => ReactNode;
|
|
509
532
|
}) {
|
|
533
|
+
const t = useForgeT();
|
|
510
534
|
const theme = useForgeTheme();
|
|
511
535
|
const total = c.totalAmount ?? Number(product.price);
|
|
512
536
|
// Authoritative tax quote from start-payment — the charged total already
|
|
@@ -514,8 +538,8 @@ function PaymentStep({
|
|
|
514
538
|
const taxSummary = summarizeTaxQuote(c.taxQuote);
|
|
515
539
|
return (
|
|
516
540
|
<div>
|
|
517
|
-
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>
|
|
518
|
-
<p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>
|
|
541
|
+
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>{t("forge.coaching_booking.payment_title")}</h2>
|
|
542
|
+
<p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>{t("forge.coaching_booking.payment_subtitle")}</p>
|
|
519
543
|
<div
|
|
520
544
|
style={{
|
|
521
545
|
padding: 20,
|
|
@@ -531,12 +555,14 @@ function PaymentStep({
|
|
|
531
555
|
fmt={fmt}
|
|
532
556
|
/>
|
|
533
557
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
534
|
-
<span style={{ fontSize: 16 }}>
|
|
558
|
+
<span style={{ fontSize: 16 }}>{t("forge.coaching_booking.total_amount")}</span>
|
|
535
559
|
<span style={{ fontSize: 22, fontWeight: 700, color: theme.colors.primary, textAlign: "right" }}>
|
|
536
560
|
{fmt(total)}
|
|
537
561
|
{taxSummary && (
|
|
538
562
|
<span style={{ display: "block", fontSize: 12, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}>
|
|
539
|
-
{taxSummary.mode === "inclusive"
|
|
563
|
+
{taxSummary.mode === "inclusive"
|
|
564
|
+
? taxSummary.label
|
|
565
|
+
: t("forge.coaching_booking.tax_included", { amount: taxSummary.formattedTax })}
|
|
540
566
|
</span>
|
|
541
567
|
)}
|
|
542
568
|
</span>
|
|
@@ -550,19 +576,19 @@ function PaymentStep({
|
|
|
550
576
|
so it is checked BEFORE the "not configured" branch. */}
|
|
551
577
|
{c.isPaystack ? (
|
|
552
578
|
c.canOpenPaystack ? (
|
|
553
|
-
<PaystackPayButton onPay={c.openPaystackCheckout} label={
|
|
579
|
+
<PaystackPayButton onPay={c.openPaystackCheckout} label={t("forge.coaching_booking.pay", { amount: fmt(total) })} />
|
|
554
580
|
) : (
|
|
555
|
-
<p style={{ opacity: 0.7 }}>
|
|
581
|
+
<p style={{ opacity: 0.7 }}>{t("forge.coaching_booking.preparing_payment")}</p>
|
|
556
582
|
)
|
|
557
583
|
) : !renderPayment ? (
|
|
558
|
-
<p style={{ color: "#ef4444", fontSize: 14 }}>
|
|
584
|
+
<p style={{ color: "#ef4444", fontSize: 14 }}>{t("forge.coaching_booking.payment_not_configured")}</p>
|
|
559
585
|
) : c.clientSecret ? (
|
|
560
586
|
renderPayment({ clientSecret: c.clientSecret, returnUrl: c.returnUrl, amount: total, mode: "redirect" })
|
|
561
587
|
) : (
|
|
562
|
-
<p style={{ opacity: 0.7 }}>
|
|
588
|
+
<p style={{ opacity: 0.7 }}>{t("forge.coaching_booking.preparing_payment")}</p>
|
|
563
589
|
)}
|
|
564
590
|
<button onClick={() => c.setStep("details")} style={{ ...secondaryButtonStyle(theme), marginTop: 16 }}>
|
|
565
|
-
|
|
591
|
+
{t("forge.coaching_booking.back")}
|
|
566
592
|
</button>
|
|
567
593
|
</div>
|
|
568
594
|
);
|
|
@@ -586,6 +612,7 @@ function ActionBar({
|
|
|
586
612
|
nextLabel?: string;
|
|
587
613
|
nextDisabled?: boolean;
|
|
588
614
|
}) {
|
|
615
|
+
const t = useForgeT();
|
|
589
616
|
const theme = useForgeTheme();
|
|
590
617
|
const total = c.totalAmount ?? Number(product.price);
|
|
591
618
|
return (
|
|
@@ -605,14 +632,14 @@ function ActionBar({
|
|
|
605
632
|
fmt={fmt}
|
|
606
633
|
/>
|
|
607
634
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
608
|
-
<span style={{ fontWeight: 600 }}>
|
|
635
|
+
<span style={{ fontWeight: 600 }}>{t("forge.coaching_booking.total")}</span>
|
|
609
636
|
<span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>{fmt(total)}</span>
|
|
610
637
|
</div>
|
|
611
638
|
</div>
|
|
612
639
|
{(onBack || onNext) && (
|
|
613
640
|
<div style={{ display: "flex", gap: 8, justifyContent: "space-between" }}>
|
|
614
641
|
<button onClick={onBack} disabled={!onBack} style={{ ...secondaryButtonStyle(theme), opacity: onBack ? 1 : 0.4 }}>
|
|
615
|
-
|
|
642
|
+
{t("forge.coaching_booking.back")}
|
|
616
643
|
</button>
|
|
617
644
|
{onNext && (
|
|
618
645
|
<button onClick={onNext} disabled={nextDisabled} style={{ ...primaryButtonStyle(theme), opacity: nextDisabled ? 0.6 : 1 }}>
|