@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.
Files changed (54) hide show
  1. package/package.json +1 -1
  2. package/src/client/createForgeClient.ts +85 -2
  3. package/src/client/tokenStorage.ts +31 -0
  4. package/src/contexts/AppAuthContext.tsx +100 -15
  5. package/src/contexts/PublicAuthContext.tsx +46 -9
  6. package/src/data/queries/useCheckouts.ts +84 -1
  7. package/src/data/queries/useCoachingAvailability.ts +18 -3
  8. package/src/data/queries/useCourseAccess.ts +1 -1
  9. package/src/data/queries/useCourses.ts +42 -1
  10. package/src/data/queries/useEvents.ts +15 -1
  11. package/src/data/queries/usePaymentFlow.ts +12 -0
  12. package/src/data/queries/useWebsite.ts +6 -0
  13. package/src/index.ts +19 -2
  14. package/src/provider/ForgeProvider.tsx +30 -1
  15. package/src/server/_tests/platformEvents.spec.ts +315 -0
  16. package/src/server/index.ts +17 -0
  17. package/src/server/jobs.ts +41 -10
  18. package/src/server/platform.ts +234 -9
  19. package/src/server/platformEvents.generated.ts +422 -0
  20. package/src/types/models.ts +110 -3
  21. package/src/ui/headless/auth/useSignupForm.ts +69 -4
  22. package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
  23. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
  24. package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
  25. package/src/ui/headless/checkout/useCheckout.ts +156 -8
  26. package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
  27. package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
  28. package/src/ui/headless/coupon/useCouponField.ts +164 -0
  29. package/src/ui/headless/course/useCourseCheckout.ts +113 -18
  30. package/src/ui/headless/event/useEventCheckout.ts +53 -2
  31. package/src/ui/headless/index.ts +15 -0
  32. package/src/ui/headless/work/useWorkPortal.ts +24 -21
  33. package/src/ui/index.ts +7 -0
  34. package/src/ui/shell/PoweredBy.tsx +60 -0
  35. package/src/ui/shell/TribeNestApp.tsx +15 -1
  36. package/src/ui/shell/shellGating.spec.ts +21 -1
  37. package/src/ui/shell/shellGating.ts +14 -0
  38. package/src/ui/styled/AddToCalendar.tsx +104 -0
  39. package/src/ui/styled/Checkout.tsx +45 -14
  40. package/src/ui/styled/CoachingBooking.tsx +28 -8
  41. package/src/ui/styled/CoachingConfirmation.tsx +12 -0
  42. package/src/ui/styled/CourseCheckout.tsx +49 -18
  43. package/src/ui/styled/DiscountCode.tsx +206 -0
  44. package/src/ui/styled/EventConfirmation.tsx +68 -22
  45. package/src/ui/styled/EventDetail.tsx +18 -5
  46. package/src/ui/styled/EventTickets.tsx +49 -5
  47. package/src/ui/styled/SignupForm.tsx +86 -35
  48. package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
  49. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
  50. package/src/ui/styled/work/WorkInviteAccept.tsx +54 -5
  51. package/src/utils/_tests/safeRedirect.spec.ts +117 -0
  52. package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
  53. package/src/utils/safeRedirect.ts +41 -0
  54. 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
- {c.currentStage === "payment" && c.orderId && (
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
- : `${formatCurrency(c.appliedCoupon.discountValue ?? 0)} off`}
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
- <button
690
- type="button"
691
- className="text-sm underline"
692
- style={{ color: theme.colors.text }}
693
- onClick={c.removeCoupon}
694
- disabled={c.isApplyingCoupon}
695
- >
696
- Remove
697
- </button>
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
- <span style={{ fontWeight: 600 }}>Total</span>
563
- <span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>{fmt(total)}</span>
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
- const amount = Number(course.price);
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
- <span style={{ fontWeight: 600 }}>Total</span>
380
- <span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>
381
- {compareAt != null && compareAt > amount && (
382
- <span style={{ textDecoration: "line-through", opacity: 0.6, marginRight: 8, fontWeight: 400, fontSize: 14 }}>
383
- {fmt(compareAt)}
384
- </span>
385
- )}
386
- {fmt(amount)}
387
- </span>
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
+ });