@tribe-nest/forge 3.2.0 → 3.4.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/data/queries/useCheckouts.ts +84 -1
- package/src/data/queries/useCoachingAvailability.ts +18 -3
- package/src/data/queries/useCourses.ts +42 -1
- package/src/data/queries/useEvents.ts +15 -1
- package/src/data/queries/usePaymentFlow.ts +12 -0
- package/src/data/queries/useWebsite.ts +6 -0
- package/src/index.ts +9 -0
- package/src/types/models.ts +66 -0
- package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
- package/src/ui/headless/checkout/useCheckout.ts +156 -8
- package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
- package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
- package/src/ui/headless/coupon/useCouponField.ts +164 -0
- package/src/ui/headless/course/useCourseCheckout.ts +113 -18
- package/src/ui/headless/event/useEventCheckout.ts +53 -2
- package/src/ui/headless/index.ts +15 -0
- package/src/ui/index.ts +7 -0
- package/src/ui/shell/PoweredBy.tsx +60 -0
- package/src/ui/shell/TribeNestApp.tsx +15 -1
- package/src/ui/shell/shellGating.spec.ts +21 -1
- package/src/ui/shell/shellGating.ts +14 -0
- package/src/ui/styled/AddToCalendar.tsx +104 -0
- package/src/ui/styled/Checkout.tsx +45 -14
- package/src/ui/styled/CoachingBooking.tsx +28 -8
- package/src/ui/styled/CoachingConfirmation.tsx +12 -0
- package/src/ui/styled/CourseCheckout.tsx +49 -18
- package/src/ui/styled/DiscountCode.tsx +206 -0
- package/src/ui/styled/EventConfirmation.tsx +68 -22
- package/src/ui/styled/EventDetail.tsx +18 -5
- package/src/ui/styled/EventTickets.tsx +49 -5
- package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
- package/src/utils/ticketOrderOutcome.ts +125 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { shellPwaEnabled } from "./shellGating";
|
|
2
|
+
import { shellPoweredByEnabled, shellPwaEnabled } from "./shellGating";
|
|
3
3
|
|
|
4
4
|
// The PWA (SW register + install prompt) must be ON only for the live published
|
|
5
5
|
// site, and OFF everywhere else (editor, preview/draft), so review Workers never
|
|
@@ -26,3 +26,23 @@ describe("shellPwaEnabled", () => {
|
|
|
26
26
|
expect(shellPwaEnabled({ editable: false, state: "published" })).toBe(true);
|
|
27
27
|
});
|
|
28
28
|
});
|
|
29
|
+
|
|
30
|
+
// "Powered by TribeNest" ships on released builds only, and the ONLY way to
|
|
31
|
+
// remove it is the server-supplied flag — there is no client-side opt-out,
|
|
32
|
+
// because the tenant owns __root.tsx.
|
|
33
|
+
describe("shellPoweredByEnabled", () => {
|
|
34
|
+
it("is true on a live published site", () => {
|
|
35
|
+
expect(shellPoweredByEnabled({ editable: false, state: "published" })).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("is false in the editor and on draft/preview deploys", () => {
|
|
39
|
+
expect(shellPoweredByEnabled({ editable: true, state: "published" })).toBe(false);
|
|
40
|
+
expect(shellPoweredByEnabled({ editable: false, state: "draft" })).toBe(false);
|
|
41
|
+
expect(shellPoweredByEnabled({ editable: false })).toBe(false); // state undefined
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("is false only when the SERVER says to hide it", () => {
|
|
45
|
+
expect(shellPoweredByEnabled({ editable: false, state: "published", hideBadge: true })).toBe(false);
|
|
46
|
+
expect(shellPoweredByEnabled({ editable: false, state: "published", hideBadge: false })).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -9,3 +9,17 @@ export function shellPwaEnabled(opts: {
|
|
|
9
9
|
}): boolean {
|
|
10
10
|
return (opts.pwa ?? true) && !opts.editable && opts.state === "published";
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
// "Powered by TribeNest" shows on RELEASED builds only — the live published
|
|
14
|
+
// site, never the HMR editor or a preview-<versionId>.* review Worker. Same
|
|
15
|
+
// released-build test as the PWA, with one extra lever: `hideBadge` comes from
|
|
16
|
+
// the server (`siteConfig.hideTribeNestBadge`), which is where a white-label
|
|
17
|
+
// plan entitlement turns it off. It is deliberately not a `TribeNestApp` prop —
|
|
18
|
+
// the tenant owns __root.tsx, so a prop would make the badge opt-out.
|
|
19
|
+
export function shellPoweredByEnabled(opts: {
|
|
20
|
+
editable?: boolean;
|
|
21
|
+
state?: "draft" | "published";
|
|
22
|
+
hideBadge?: boolean;
|
|
23
|
+
}): boolean {
|
|
24
|
+
return !opts.hideBadge && !opts.editable && opts.state === "published";
|
|
25
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { CalendarPlus } from "lucide-react";
|
|
2
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
3
|
+
import { useAddToCalendar, type AddToCalendarInput } from "../headless/calendar/useAddToCalendar";
|
|
4
|
+
|
|
5
|
+
export interface AddToCalendarProps extends AddToCalendarInput {
|
|
6
|
+
/** Heading above the provider links. Pass `null` to drop it entirely. */
|
|
7
|
+
label?: string | null;
|
|
8
|
+
/**
|
|
9
|
+
* `compact` renders a single row of small chips with no heading — for tight
|
|
10
|
+
* spots like a confirmation card. `default` renders the label + chips.
|
|
11
|
+
*/
|
|
12
|
+
variant?: "default" | "compact";
|
|
13
|
+
/** Extra class(es) appended to the root element. */
|
|
14
|
+
className?: string;
|
|
15
|
+
/** Inline style merged LAST into the root element (callers can override). */
|
|
16
|
+
style?: React.CSSProperties;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* "Add to calendar" — deep links that prefill Google / Outlook / Microsoft 365
|
|
21
|
+
* with the event, plus an `.ics` download when the host supplies one (that is
|
|
22
|
+
* the Apple Calendar route; there is no Apple web endpoint to link to).
|
|
23
|
+
*
|
|
24
|
+
* Purely presentational: every URL comes from `useAddToCalendar`. Renders
|
|
25
|
+
* nothing at all when the start time is missing or unparseable.
|
|
26
|
+
*/
|
|
27
|
+
export function AddToCalendar({
|
|
28
|
+
label = "Add to calendar",
|
|
29
|
+
variant = "default",
|
|
30
|
+
className,
|
|
31
|
+
style,
|
|
32
|
+
...input
|
|
33
|
+
}: AddToCalendarProps) {
|
|
34
|
+
const t = useThemeTokens();
|
|
35
|
+
const links = useAddToCalendar(input);
|
|
36
|
+
|
|
37
|
+
if (!links) return null;
|
|
38
|
+
|
|
39
|
+
const compact = variant === "compact";
|
|
40
|
+
|
|
41
|
+
const options: { key: string; text: string; href: string; download?: boolean }[] = [
|
|
42
|
+
{ key: "google", text: "Google", href: links.google },
|
|
43
|
+
{ key: "outlook", text: "Outlook", href: links.outlook },
|
|
44
|
+
{ key: "office365", text: "Microsoft 365", href: links.office365 },
|
|
45
|
+
];
|
|
46
|
+
if (links.ics) options.push({ key: "ics", text: "Apple / .ics", href: links.ics, download: true });
|
|
47
|
+
|
|
48
|
+
const chip: React.CSSProperties = {
|
|
49
|
+
display: "inline-flex",
|
|
50
|
+
alignItems: "center",
|
|
51
|
+
gap: 6,
|
|
52
|
+
padding: compact ? "5px 10px" : "7px 12px",
|
|
53
|
+
fontSize: compact ? 12 : 13,
|
|
54
|
+
fontWeight: 600,
|
|
55
|
+
lineHeight: 1.2,
|
|
56
|
+
color: t.text,
|
|
57
|
+
textDecoration: "none",
|
|
58
|
+
border: `1px solid ${t.border}`,
|
|
59
|
+
borderRadius: t.cornerRadius,
|
|
60
|
+
background: t.surface,
|
|
61
|
+
fontFamily: t.fontFamily,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div
|
|
66
|
+
data-testid="add-to-calendar"
|
|
67
|
+
className={className}
|
|
68
|
+
style={{ display: "flex", flexDirection: "column", gap: compact ? 6 : 8, ...style }}
|
|
69
|
+
>
|
|
70
|
+
{label && !compact && (
|
|
71
|
+
<span
|
|
72
|
+
style={{
|
|
73
|
+
display: "inline-flex",
|
|
74
|
+
alignItems: "center",
|
|
75
|
+
gap: 6,
|
|
76
|
+
fontSize: 13,
|
|
77
|
+
fontWeight: 700,
|
|
78
|
+
color: t.muted,
|
|
79
|
+
fontFamily: t.fontFamily,
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
<CalendarPlus size={15} color={t.primary} /> {label}
|
|
83
|
+
</span>
|
|
84
|
+
)}
|
|
85
|
+
|
|
86
|
+
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
|
87
|
+
{compact && <CalendarPlus size={15} color={t.primary} style={{ alignSelf: "center" }} />}
|
|
88
|
+
{options.map((o) => (
|
|
89
|
+
<a
|
|
90
|
+
key={o.key}
|
|
91
|
+
href={o.href}
|
|
92
|
+
target="_blank"
|
|
93
|
+
rel="noopener noreferrer"
|
|
94
|
+
data-testid={`add-to-calendar-${o.key}`}
|
|
95
|
+
{...(o.download ? { download: "" } : {})}
|
|
96
|
+
style={chip}
|
|
97
|
+
>
|
|
98
|
+
{o.text}
|
|
99
|
+
</a>
|
|
100
|
+
))}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -98,6 +98,14 @@ export function Checkout({
|
|
|
98
98
|
if (id) go(`${finalisePath}?orderId=${id}`);
|
|
99
99
|
};
|
|
100
100
|
|
|
101
|
+
// A discount that took a BUNDLE to zero settles it server-side — there is no
|
|
102
|
+
// intent to confirm, so waiting on the payment element would strand the buyer
|
|
103
|
+
// on a checkout they have already completed.
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (c.settledCheckoutId) go(`${finalisePath}?checkoutId=${c.settledCheckoutId}`);
|
|
106
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
107
|
+
}, [c.settledCheckoutId]);
|
|
108
|
+
|
|
101
109
|
// ── Shared style helpers ────────────────────────────────────────────────────
|
|
102
110
|
const inputStyle: React.CSSProperties = {
|
|
103
111
|
width: "100%",
|
|
@@ -637,10 +645,16 @@ export function Checkout({
|
|
|
637
645
|
</div>
|
|
638
646
|
</div>
|
|
639
647
|
|
|
640
|
-
{/* Coupon
|
|
641
|
-
|
|
648
|
+
{/* Coupon.
|
|
649
|
+
A single-surface order is re-priced in place by
|
|
650
|
+
`/public/orders/apply-coupon`, so its field only appears once the
|
|
651
|
+
order exists. A BUNDLE is offered the field from the first stage:
|
|
652
|
+
a code typed before the checkout exists travels with the call
|
|
653
|
+
that creates it, and one typed afterwards goes through
|
|
654
|
+
`/public/checkouts/apply-coupon`. Neither freezes the field. */}
|
|
655
|
+
{((c.currentStage === "payment" && c.orderId) || c.isBundle) && (
|
|
642
656
|
<div className="mb-4 pb-4" style={{ borderBottom: `1px solid ${theme.colors.primary}${alpha(0.2)}` }}>
|
|
643
|
-
{!c.showCouponInput && !c.appliedCoupon && (
|
|
657
|
+
{!c.showCouponInput && !c.appliedCoupon && c.isCouponEditable && (
|
|
644
658
|
<button
|
|
645
659
|
type="button"
|
|
646
660
|
className="text-sm underline"
|
|
@@ -650,13 +664,14 @@ export function Checkout({
|
|
|
650
664
|
Have a coupon code?
|
|
651
665
|
</button>
|
|
652
666
|
)}
|
|
653
|
-
{c.showCouponInput && !c.appliedCoupon && (
|
|
667
|
+
{c.showCouponInput && !c.appliedCoupon && c.isCouponEditable && (
|
|
654
668
|
<div className="flex gap-2 items-end">
|
|
655
669
|
<div className="flex-1">
|
|
656
670
|
<p className="text-sm mb-1" style={{ color: theme.colors.text }}>
|
|
657
671
|
Coupon Code
|
|
658
672
|
</p>
|
|
659
673
|
<input
|
|
674
|
+
aria-label="Coupon code"
|
|
660
675
|
placeholder="Enter coupon code"
|
|
661
676
|
style={inputStyle}
|
|
662
677
|
value={c.couponCode}
|
|
@@ -670,6 +685,13 @@ export function Checkout({
|
|
|
670
685
|
/>
|
|
671
686
|
</div>
|
|
672
687
|
)}
|
|
688
|
+
{/* Only while the bundle does not exist yet: from then on the
|
|
689
|
+
code is priced live, so promising "at payment" would be wrong. */}
|
|
690
|
+
{c.isBundle && !c.checkoutId && c.showCouponInput && !c.appliedCoupon && c.isCouponEditable && (
|
|
691
|
+
<p className="text-xs mt-1" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
|
|
692
|
+
Your code is applied when you reach payment.
|
|
693
|
+
</p>
|
|
694
|
+
)}
|
|
673
695
|
{c.couponError && <p className="text-sm text-red-500 mt-1">{c.couponError}</p>}
|
|
674
696
|
{c.appliedCoupon && (
|
|
675
697
|
<div
|
|
@@ -680,21 +702,30 @@ export function Checkout({
|
|
|
680
702
|
<p className="text-sm font-semibold" style={{ color: theme.colors.primary }}>
|
|
681
703
|
Coupon applied: {c.appliedCoupon.code}
|
|
682
704
|
</p>
|
|
705
|
+
{/* A bundle reports the cash that came off and the code it
|
|
706
|
+
came off under, but not the rate — so say the cash,
|
|
707
|
+
never a made-up "0% off". */}
|
|
683
708
|
<p className="text-xs" style={{ color: theme.colors.text }}>
|
|
684
709
|
{c.appliedCoupon.discountType === "percentage"
|
|
685
710
|
? `${c.appliedCoupon.discountValue}% off`
|
|
686
|
-
:
|
|
711
|
+
: c.appliedCoupon.discountValue != null
|
|
712
|
+
? `${formatCurrency(c.appliedCoupon.discountValue)} off`
|
|
713
|
+
: c.appliedCoupon.discountAmount != null
|
|
714
|
+
? `${formatCurrency(c.appliedCoupon.discountAmount)} off`
|
|
715
|
+
: "Applied at payment"}
|
|
687
716
|
</p>
|
|
688
717
|
</div>
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
718
|
+
{c.isCouponEditable && (
|
|
719
|
+
<button
|
|
720
|
+
type="button"
|
|
721
|
+
className="text-sm underline"
|
|
722
|
+
style={{ color: theme.colors.text }}
|
|
723
|
+
onClick={c.removeCoupon}
|
|
724
|
+
disabled={c.isApplyingCoupon}
|
|
725
|
+
>
|
|
726
|
+
Remove
|
|
727
|
+
</button>
|
|
728
|
+
)}
|
|
698
729
|
</div>
|
|
699
730
|
)}
|
|
700
731
|
</div>
|
|
@@ -8,6 +8,7 @@ import { summarizeTaxQuote } from "../format/PriceDisplay";
|
|
|
8
8
|
import { Loading } from "./Loading";
|
|
9
9
|
import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
|
|
10
10
|
import { buttonStyle } from "./Button";
|
|
11
|
+
import { DiscountCodeField, DiscountSummaryLines } from "./DiscountCode";
|
|
11
12
|
import {
|
|
12
13
|
DialogRoot,
|
|
13
14
|
DialogTrigger,
|
|
@@ -447,6 +448,15 @@ function DetailsStep({ c, product, fmt }: { c: Booking; product: CoachingProduct
|
|
|
447
448
|
);
|
|
448
449
|
})}
|
|
449
450
|
|
|
451
|
+
{/* `booking/update` re-derives the gross price from the PRODUCT on every
|
|
452
|
+
call, so Apply and Remove are both real round trips and Remove puts
|
|
453
|
+
the original total back exactly. */}
|
|
454
|
+
{!isFree && (
|
|
455
|
+
<div style={{ marginTop: 8, paddingTop: 16, borderTop: `1px solid ${a(theme.colors.primary, 0.13)}` }}>
|
|
456
|
+
<DiscountCodeField coupon={c.coupon} fmt={fmt} />
|
|
457
|
+
</div>
|
|
458
|
+
)}
|
|
459
|
+
|
|
450
460
|
{(localError || c.error) && <p style={{ color: "#ef4444", fontSize: 14 }}>{localError ?? c.error}</p>}
|
|
451
461
|
|
|
452
462
|
<p style={{ fontSize: 12, color: a(theme.colors.text, 0.6), marginTop: 4 }}>
|
|
@@ -497,11 +507,15 @@ function PaymentStep({
|
|
|
497
507
|
border: `2px solid ${a(theme.colors.primary, 0.25)}`,
|
|
498
508
|
borderRadius: theme.cornerRadius,
|
|
499
509
|
marginBottom: 16,
|
|
500
|
-
display: "flex",
|
|
501
|
-
justifyContent: "space-between",
|
|
502
|
-
alignItems: "center",
|
|
503
510
|
}}
|
|
504
511
|
>
|
|
512
|
+
<DiscountSummaryLines
|
|
513
|
+
subTotal={c.subTotal ?? Number(product.price)}
|
|
514
|
+
discountAmount={c.coupon.discountAmount}
|
|
515
|
+
appliedCoupons={c.coupon.appliedCoupons}
|
|
516
|
+
fmt={fmt}
|
|
517
|
+
/>
|
|
518
|
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
505
519
|
<span style={{ fontSize: 16 }}>Total Amount</span>
|
|
506
520
|
<span style={{ fontSize: 22, fontWeight: 700, color: theme.colors.primary, textAlign: "right" }}>
|
|
507
521
|
{fmt(total)}
|
|
@@ -511,6 +525,7 @@ function PaymentStep({
|
|
|
511
525
|
</span>
|
|
512
526
|
)}
|
|
513
527
|
</span>
|
|
528
|
+
</div>
|
|
514
529
|
</div>
|
|
515
530
|
{!renderPayment ? (
|
|
516
531
|
<p style={{ color: "#ef4444", fontSize: 14 }}>Payment is not configured.</p>
|
|
@@ -554,13 +569,18 @@ function ActionBar({
|
|
|
554
569
|
border: `1px solid ${a(theme.colors.primary, 0.25)}`,
|
|
555
570
|
borderRadius: theme.cornerRadius,
|
|
556
571
|
marginBottom: 16,
|
|
557
|
-
display: "flex",
|
|
558
|
-
justifyContent: "space-between",
|
|
559
|
-
alignItems: "center",
|
|
560
572
|
}}
|
|
561
573
|
>
|
|
562
|
-
<
|
|
563
|
-
|
|
574
|
+
<DiscountSummaryLines
|
|
575
|
+
subTotal={c.subTotal ?? Number(product.price)}
|
|
576
|
+
discountAmount={c.coupon.discountAmount}
|
|
577
|
+
appliedCoupons={c.coupon.appliedCoupons}
|
|
578
|
+
fmt={fmt}
|
|
579
|
+
/>
|
|
580
|
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
581
|
+
<span style={{ fontWeight: 600 }}>Total</span>
|
|
582
|
+
<span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>{fmt(total)}</span>
|
|
583
|
+
</div>
|
|
564
584
|
</div>
|
|
565
585
|
{(onBack || onNext) && (
|
|
566
586
|
<div style={{ display: "flex", gap: 8, justifyContent: "space-between" }}>
|
|
@@ -5,6 +5,7 @@ import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
|
5
5
|
import { Loading } from "./Loading";
|
|
6
6
|
import { ConfirmationStage, ConfirmationCard, CheckSeal, WarnSeal, Perforation, ConfirmationRow, alpha } from "./Confirmation";
|
|
7
7
|
import { useCoachingBookingFinalize } from "../../data/queries/useFinalize";
|
|
8
|
+
import { AddToCalendar } from "./AddToCalendar";
|
|
8
9
|
|
|
9
10
|
export interface CoachingConfirmationProps {
|
|
10
11
|
slug: string;
|
|
@@ -123,6 +124,17 @@ export function CoachingConfirmation({
|
|
|
123
124
|
Includes {fmt(Number(data.taxAmount), data.chargedCurrency || undefined)} tax
|
|
124
125
|
</div>
|
|
125
126
|
)}
|
|
127
|
+
|
|
128
|
+
{confirmed && data?.sessionStartTime && (
|
|
129
|
+
<AddToCalendar
|
|
130
|
+
variant="compact"
|
|
131
|
+
title={data.productTitle || "Coaching session"}
|
|
132
|
+
start={data.sessionStartTime}
|
|
133
|
+
end={data.sessionEndTime}
|
|
134
|
+
defaultDurationMinutes={data.durationMinutes ?? undefined}
|
|
135
|
+
style={{ marginTop: 18 }}
|
|
136
|
+
/>
|
|
137
|
+
)}
|
|
126
138
|
</div>
|
|
127
139
|
|
|
128
140
|
{/* ── Actions ── */}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type CSSProperties, type ReactNode, useMemo, useState } from "react";
|
|
2
2
|
import { X } from "lucide-react";
|
|
3
|
-
import type { PublicCourse, QuestionnaireQuestion } from "../../types/models";
|
|
3
|
+
import type { AppliedDiscountCoupon, PublicCourse, QuestionnaireQuestion } from "../../types/models";
|
|
4
4
|
import { useCourseCheckout } from "../headless/course/useCourseCheckout";
|
|
5
5
|
import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
|
|
6
6
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
@@ -8,6 +8,7 @@ import { summarizeTaxQuote } from "../format/PriceDisplay";
|
|
|
8
8
|
import { Loading } from "./Loading";
|
|
9
9
|
import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
|
|
10
10
|
import { buttonStyle } from "./Button";
|
|
11
|
+
import { DiscountCodeField, DiscountSummaryLines } from "./DiscountCode";
|
|
11
12
|
import {
|
|
12
13
|
DialogRoot,
|
|
13
14
|
DialogTrigger,
|
|
@@ -260,6 +261,15 @@ function DetailsStep({ c, course, fmt }: { c: Checkout; course: PublicCourse; fm
|
|
|
260
261
|
</div>
|
|
261
262
|
)}
|
|
262
263
|
|
|
264
|
+
{/* `booking/update` re-derives the gross price from the COURSE on every
|
|
265
|
+
call, so Apply and Remove are both real round trips and Remove puts
|
|
266
|
+
the original total back exactly. */}
|
|
267
|
+
{!isFree && (
|
|
268
|
+
<div style={{ marginTop: 8, paddingTop: 16, borderTop: `1px solid ${a(theme.colors.primary, 0.13)}` }}>
|
|
269
|
+
<DiscountCodeField coupon={c.coupon} fmt={fmt} />
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
|
|
263
273
|
<p style={{ fontSize: 12, opacity: 0.6 }}>
|
|
264
274
|
By continuing, you agree to receive course access and important updates via email.
|
|
265
275
|
</p>
|
|
@@ -268,8 +278,11 @@ function DetailsStep({ c, course, fmt }: { c: Checkout; course: PublicCourse; fm
|
|
|
268
278
|
</div>
|
|
269
279
|
<ActionBar
|
|
270
280
|
fmt={fmt}
|
|
271
|
-
amount={price}
|
|
281
|
+
amount={c.totalAmount ?? price}
|
|
272
282
|
compareAt={compareAt}
|
|
283
|
+
subTotal={c.subTotal ?? price}
|
|
284
|
+
discountAmount={c.coupon.discountAmount}
|
|
285
|
+
appliedCoupons={c.coupon.appliedCoupons}
|
|
273
286
|
onNext={onContinue}
|
|
274
287
|
nextLabel={c.isProcessing ? "Processing…" : isFree ? "Confirm Purchase" : "Continue to Payment"}
|
|
275
288
|
nextDisabled={c.isProcessing}
|
|
@@ -291,7 +304,9 @@ function PaymentStep({
|
|
|
291
304
|
renderPayment?: (props: PaymentRenderProps) => ReactNode;
|
|
292
305
|
}) {
|
|
293
306
|
const theme = useForgeTheme();
|
|
294
|
-
|
|
307
|
+
// The server's figure once it exists — the booking quote is already net of
|
|
308
|
+
// any discount, so the buyer never sees a total that disagrees with the charge.
|
|
309
|
+
const amount = c.totalAmount ?? Number(course.price);
|
|
295
310
|
const compareAt = course.compareAtPrice != null ? Number(course.compareAtPrice) : null;
|
|
296
311
|
// Authoritative tax quote from start-payment (display only) — the charged
|
|
297
312
|
// amount already reflects it.
|
|
@@ -306,11 +321,15 @@ function PaymentStep({
|
|
|
306
321
|
border: `2px solid ${a(theme.colors.primary, 0.25)}`,
|
|
307
322
|
borderRadius: theme.cornerRadius,
|
|
308
323
|
marginBottom: 16,
|
|
309
|
-
display: "flex",
|
|
310
|
-
justifyContent: "space-between",
|
|
311
|
-
alignItems: "center",
|
|
312
324
|
}}
|
|
313
325
|
>
|
|
326
|
+
<DiscountSummaryLines
|
|
327
|
+
subTotal={c.subTotal ?? amount}
|
|
328
|
+
discountAmount={c.coupon.discountAmount}
|
|
329
|
+
appliedCoupons={c.coupon.appliedCoupons}
|
|
330
|
+
fmt={fmt}
|
|
331
|
+
/>
|
|
332
|
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
314
333
|
<span style={{ fontSize: 16 }}>Total Amount</span>
|
|
315
334
|
<span style={{ fontSize: 22, fontWeight: 700, color: theme.colors.primary }}>
|
|
316
335
|
{compareAt != null && compareAt > amount && (
|
|
@@ -325,6 +344,7 @@ function PaymentStep({
|
|
|
325
344
|
</span>
|
|
326
345
|
)}
|
|
327
346
|
</span>
|
|
347
|
+
</div>
|
|
328
348
|
</div>
|
|
329
349
|
{!renderPayment ? (
|
|
330
350
|
<p style={{ color: "#ef4444", fontSize: 14 }}>Payment is not configured.</p>
|
|
@@ -349,6 +369,9 @@ function ActionBar({
|
|
|
349
369
|
fmt,
|
|
350
370
|
amount,
|
|
351
371
|
compareAt,
|
|
372
|
+
subTotal,
|
|
373
|
+
discountAmount = 0,
|
|
374
|
+
appliedCoupons,
|
|
352
375
|
onBack,
|
|
353
376
|
onNext,
|
|
354
377
|
nextLabel,
|
|
@@ -357,6 +380,9 @@ function ActionBar({
|
|
|
357
380
|
fmt: (n: number) => string;
|
|
358
381
|
amount: number;
|
|
359
382
|
compareAt?: number | null;
|
|
383
|
+
subTotal?: number;
|
|
384
|
+
discountAmount?: number;
|
|
385
|
+
appliedCoupons?: AppliedDiscountCoupon[];
|
|
360
386
|
onBack?: () => void;
|
|
361
387
|
onNext: () => void;
|
|
362
388
|
nextLabel: string;
|
|
@@ -371,20 +397,25 @@ function ActionBar({
|
|
|
371
397
|
border: `1px solid ${a(theme.colors.primary, 0.25)}`,
|
|
372
398
|
borderRadius: theme.cornerRadius,
|
|
373
399
|
marginBottom: 16,
|
|
374
|
-
display: "flex",
|
|
375
|
-
justifyContent: "space-between",
|
|
376
|
-
alignItems: "center",
|
|
377
400
|
}}
|
|
378
401
|
>
|
|
379
|
-
<
|
|
380
|
-
|
|
381
|
-
{
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
{
|
|
387
|
-
|
|
402
|
+
<DiscountSummaryLines
|
|
403
|
+
subTotal={subTotal ?? amount}
|
|
404
|
+
discountAmount={discountAmount}
|
|
405
|
+
appliedCoupons={appliedCoupons}
|
|
406
|
+
fmt={fmt}
|
|
407
|
+
/>
|
|
408
|
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
409
|
+
<span style={{ fontWeight: 600 }}>Total</span>
|
|
410
|
+
<span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>
|
|
411
|
+
{compareAt != null && compareAt > amount && (
|
|
412
|
+
<span style={{ textDecoration: "line-through", opacity: 0.6, marginRight: 8, fontWeight: 400, fontSize: 14 }}>
|
|
413
|
+
{fmt(compareAt)}
|
|
414
|
+
</span>
|
|
415
|
+
)}
|
|
416
|
+
{fmt(amount)}
|
|
417
|
+
</span>
|
|
418
|
+
</div>
|
|
388
419
|
</div>
|
|
389
420
|
<div style={{ display: "flex", gap: 8, justifyContent: "space-between" }}>
|
|
390
421
|
<button onClick={onBack} disabled={!onBack} style={{ ...secondaryButtonStyle(theme), opacity: onBack ? 1 : 0.4 }}>
|