@tribe-nest/forge 2.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/client/createForgeClient.ts +85 -2
- package/src/client/tokenStorage.ts +31 -0
- package/src/contexts/AppAuthContext.tsx +100 -15
- package/src/contexts/PublicAuthContext.tsx +46 -9
- package/src/data/queries/useCheckouts.ts +84 -1
- package/src/data/queries/useCoachingAvailability.ts +18 -3
- package/src/data/queries/useCourseAccess.ts +1 -1
- 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 +19 -2
- package/src/provider/ForgeProvider.tsx +30 -1
- package/src/server/_tests/platformEvents.spec.ts +315 -0
- package/src/server/index.ts +17 -0
- package/src/server/jobs.ts +41 -10
- package/src/server/platform.ts +234 -9
- package/src/server/platformEvents.generated.ts +422 -0
- package/src/types/models.ts +110 -3
- package/src/ui/headless/auth/useSignupForm.ts +69 -4
- 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/headless/work/useWorkPortal.ts +24 -21
- 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/SignupForm.tsx +86 -35
- package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +54 -5
- package/src/utils/_tests/safeRedirect.spec.ts +117 -0
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
- package/src/utils/safeRedirect.ts +41 -0
- package/src/utils/ticketOrderOutcome.ts +125 -0
|
@@ -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 }}>
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import type { AppliedDiscountCoupon } from "../../types/models";
|
|
3
|
+
import type { CouponField } from "../headless/coupon/useCouponField";
|
|
4
|
+
import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
|
|
5
|
+
import { buttonStyle } from "./Button";
|
|
6
|
+
|
|
7
|
+
/** Append an alpha to a 6-digit hex color (mirrors frontend-shared addAlphaToHexCode). */
|
|
8
|
+
const a = (hex: string, alpha: number) => hex + Math.round(alpha * 255).toString(16).padStart(2, "0");
|
|
9
|
+
|
|
10
|
+
export interface DiscountCodeFieldProps {
|
|
11
|
+
coupon: CouponField;
|
|
12
|
+
/** Format a numeric amount for display. */
|
|
13
|
+
fmt: (n: number) => string;
|
|
14
|
+
/**
|
|
15
|
+
* Disable the input. Used once the underlying record has been created and the
|
|
16
|
+
* code can no longer be changed (tickets, bundles).
|
|
17
|
+
*/
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* What "Apply" does. `"quote"` re-prices immediately against the server;
|
|
21
|
+
* `"deferred"` means the code is carried into the next step, because the
|
|
22
|
+
* pillar has no endpoint that can price it without creating a record.
|
|
23
|
+
*/
|
|
24
|
+
mode?: "quote" | "deferred";
|
|
25
|
+
/** Collapsed-state prompt. */
|
|
26
|
+
promptText?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The buyer's discount-code input, shared by every checkout.
|
|
31
|
+
*
|
|
32
|
+
* Two things it deliberately does NOT do:
|
|
33
|
+
*
|
|
34
|
+
* 1. It never writes its own rejection copy. The backend answers a refused code
|
|
35
|
+
* with the actual reason, already translated — minimum not met, expired,
|
|
36
|
+
* already used, members only. Collapsing all of that into "invalid code"
|
|
37
|
+
* throws away the only thing that tells the buyer whether to fix the cart,
|
|
38
|
+
* fix the code, or give up.
|
|
39
|
+
* 2. It never computes a discount. Every figure it renders came back from the
|
|
40
|
+
* server, including automatic (no-code) discounts, which is the ONLY way a
|
|
41
|
+
* buyer learns one applied.
|
|
42
|
+
*/
|
|
43
|
+
export function DiscountCodeField({
|
|
44
|
+
coupon,
|
|
45
|
+
fmt,
|
|
46
|
+
disabled,
|
|
47
|
+
mode = "quote",
|
|
48
|
+
promptText = "Have a discount code?",
|
|
49
|
+
}: DiscountCodeFieldProps) {
|
|
50
|
+
const theme = useForgeTheme();
|
|
51
|
+
// Automatic discounts have no code the buyer typed, so they are shown as
|
|
52
|
+
// applied even when the input was never opened.
|
|
53
|
+
const applied = coupon.appliedCoupons;
|
|
54
|
+
const showInput = coupon.showInput && !disabled;
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div data-testid="discount-code" style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
58
|
+
{!showInput && applied.length === 0 && !disabled && (
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
data-testid="discount-code-open"
|
|
62
|
+
onClick={() => coupon.setShowInput(true)}
|
|
63
|
+
style={{
|
|
64
|
+
background: "transparent",
|
|
65
|
+
border: "none",
|
|
66
|
+
padding: 0,
|
|
67
|
+
textAlign: "left",
|
|
68
|
+
fontSize: 14,
|
|
69
|
+
textDecoration: "underline",
|
|
70
|
+
cursor: "pointer",
|
|
71
|
+
color: theme.colors.primary,
|
|
72
|
+
fontFamily: theme.fontFamily,
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
{promptText}
|
|
76
|
+
</button>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
{showInput && (
|
|
80
|
+
<div style={{ display: "flex", gap: 8, alignItems: "flex-start" }}>
|
|
81
|
+
<input
|
|
82
|
+
aria-label="Discount code"
|
|
83
|
+
data-testid="discount-code-input"
|
|
84
|
+
placeholder="Enter code"
|
|
85
|
+
value={coupon.code}
|
|
86
|
+
onChange={(e) => coupon.setCode(e.target.value.toUpperCase())}
|
|
87
|
+
style={{ ...inputStyle(theme), flex: 1 }}
|
|
88
|
+
/>
|
|
89
|
+
{mode === "quote" && (
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
data-testid="discount-code-apply"
|
|
93
|
+
onClick={() => void coupon.apply()}
|
|
94
|
+
disabled={coupon.isApplying || !coupon.code.trim()}
|
|
95
|
+
style={{
|
|
96
|
+
...buttonStyle(theme, { variant: "primary" }),
|
|
97
|
+
opacity: coupon.isApplying || !coupon.code.trim() ? 0.6 : 1,
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
{coupon.isApplying ? "Applying…" : "Apply"}
|
|
101
|
+
</button>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
{showInput && mode === "deferred" && (
|
|
107
|
+
<p style={{ fontSize: 12, opacity: 0.6, margin: 0 }}>Your code is applied at the next step.</p>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{coupon.error && (
|
|
111
|
+
<p data-testid="discount-code-error" style={{ color: "#ef4444", fontSize: 13, margin: 0 }}>
|
|
112
|
+
{coupon.error}
|
|
113
|
+
</p>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{applied.length > 0 && (
|
|
117
|
+
<div
|
|
118
|
+
data-testid="discount-code-applied"
|
|
119
|
+
style={{
|
|
120
|
+
display: "flex",
|
|
121
|
+
justifyContent: "space-between",
|
|
122
|
+
alignItems: "center",
|
|
123
|
+
gap: 8,
|
|
124
|
+
padding: 12,
|
|
125
|
+
borderRadius: theme.cornerRadius,
|
|
126
|
+
background: a(theme.colors.primary, 0.1),
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
<div>
|
|
130
|
+
{applied.map((c) => (
|
|
131
|
+
<p key={c.code} style={{ margin: 0, fontSize: 14, fontWeight: 600, color: theme.colors.primary }}>
|
|
132
|
+
{c.code} — {fmt(c.discountAmount)} off
|
|
133
|
+
</p>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
{coupon.canQuote && !disabled && (
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
data-testid="discount-code-remove"
|
|
140
|
+
onClick={() => void coupon.remove()}
|
|
141
|
+
disabled={coupon.isApplying}
|
|
142
|
+
style={{
|
|
143
|
+
background: "transparent",
|
|
144
|
+
border: "none",
|
|
145
|
+
padding: 0,
|
|
146
|
+
fontSize: 13,
|
|
147
|
+
textDecoration: "underline",
|
|
148
|
+
cursor: "pointer",
|
|
149
|
+
color: theme.colors.text,
|
|
150
|
+
fontFamily: theme.fontFamily,
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
Remove
|
|
154
|
+
</button>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface DiscountSummaryLinesProps {
|
|
163
|
+
/** GROSS, before the discount. */
|
|
164
|
+
subTotal: number;
|
|
165
|
+
/** MAJOR units. Nothing renders when this is not positive. */
|
|
166
|
+
discountAmount: number;
|
|
167
|
+
/** Named on the Discount row when the server told us which codes applied. */
|
|
168
|
+
appliedCoupons?: AppliedDiscountCoupon[];
|
|
169
|
+
fmt: (n: number) => string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* `Subtotal` + `Discount −X` rows, shown ABOVE the total.
|
|
174
|
+
*
|
|
175
|
+
* The whole point: a buyer who sees only a smaller number has no way to tell a
|
|
176
|
+
* working code from a silently-ignored one, and no reason to trust either.
|
|
177
|
+
*/
|
|
178
|
+
export function DiscountSummaryLines({ subTotal, discountAmount, appliedCoupons, fmt }: DiscountSummaryLinesProps) {
|
|
179
|
+
const theme = useForgeTheme();
|
|
180
|
+
if (!(discountAmount > 0)) return null;
|
|
181
|
+
const codes = (appliedCoupons ?? []).map((c) => c.code).filter(Boolean);
|
|
182
|
+
return (
|
|
183
|
+
<div data-testid="discount-summary" style={{ display: "flex", flexDirection: "column", gap: 4, marginBottom: 8 }}>
|
|
184
|
+
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 14 }}>
|
|
185
|
+
<span style={{ opacity: 0.75 }}>Subtotal</span>
|
|
186
|
+
<span style={{ opacity: 0.75 }}>{fmt(subTotal)}</span>
|
|
187
|
+
</div>
|
|
188
|
+
<div data-testid="discount-line" style={{ display: "flex", justifyContent: "space-between", fontSize: 14 }}>
|
|
189
|
+
<span style={{ color: theme.colors.primary }}>
|
|
190
|
+
Discount{codes.length > 0 ? ` (${codes.join(", ")})` : ""}
|
|
191
|
+
</span>
|
|
192
|
+
<span style={{ color: theme.colors.primary }}>−{fmt(discountAmount)}</span>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const inputStyle = (t: ForgeTheme): CSSProperties => ({
|
|
199
|
+
width: "100%",
|
|
200
|
+
padding: 10,
|
|
201
|
+
border: `1px solid ${a(t.colors.primary, 0.25)}`,
|
|
202
|
+
borderRadius: t.cornerRadius,
|
|
203
|
+
background: t.colors.background,
|
|
204
|
+
color: t.colors.text,
|
|
205
|
+
fontFamily: t.fontFamily,
|
|
206
|
+
});
|