@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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/useCheckouts.ts +84 -1
  3. package/src/data/queries/useCoachingAvailability.ts +18 -3
  4. package/src/data/queries/useCourses.ts +42 -1
  5. package/src/data/queries/useEvents.ts +15 -1
  6. package/src/data/queries/usePaymentFlow.ts +12 -0
  7. package/src/data/queries/useWebsite.ts +6 -0
  8. package/src/index.ts +9 -0
  9. package/src/types/models.ts +66 -0
  10. package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
  11. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
  12. package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
  13. package/src/ui/headless/checkout/useCheckout.ts +156 -8
  14. package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
  15. package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
  16. package/src/ui/headless/coupon/useCouponField.ts +164 -0
  17. package/src/ui/headless/course/useCourseCheckout.ts +113 -18
  18. package/src/ui/headless/event/useEventCheckout.ts +53 -2
  19. package/src/ui/headless/index.ts +15 -0
  20. package/src/ui/index.ts +7 -0
  21. package/src/ui/shell/PoweredBy.tsx +60 -0
  22. package/src/ui/shell/TribeNestApp.tsx +15 -1
  23. package/src/ui/shell/shellGating.spec.ts +21 -1
  24. package/src/ui/shell/shellGating.ts +14 -0
  25. package/src/ui/styled/AddToCalendar.tsx +104 -0
  26. package/src/ui/styled/Checkout.tsx +45 -14
  27. package/src/ui/styled/CoachingBooking.tsx +28 -8
  28. package/src/ui/styled/CoachingConfirmation.tsx +12 -0
  29. package/src/ui/styled/CourseCheckout.tsx +49 -18
  30. package/src/ui/styled/DiscountCode.tsx +206 -0
  31. package/src/ui/styled/EventConfirmation.tsx +68 -22
  32. package/src/ui/styled/EventDetail.tsx +18 -5
  33. package/src/ui/styled/EventTickets.tsx +49 -5
  34. package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
  35. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
  36. package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
  37. package/src/utils/ticketOrderOutcome.ts +125 -0
@@ -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
+ });
@@ -1,4 +1,4 @@
1
- import { AlertTriangle, ArrowRight, Calendar, MapPin, Ticket } from "lucide-react";
1
+ import { AlertTriangle, ArrowRight, Calendar, MapPin, RotateCcw, Ticket } from "lucide-react";
2
2
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
3
3
  import { readableTextOn } from "../theme/contrast";
4
4
  import { useAmountFormatter } from "../format/useFormatCurrency";
@@ -6,20 +6,8 @@ import { Loading } from "./Loading";
6
6
  import { ConfirmationStage, ConfirmationCard, CheckSeal, WarnSeal, Perforation, ConfirmationRow, alpha } from "./Confirmation";
7
7
  import { useEventOrderFinalize } from "../../data/queries/useFinalize";
8
8
  import { useEvent } from "../../data/queries/useEvents";
9
- import { OrderStatus } from "../../types/models";
10
-
11
- // A paid (or free) order advances through fulfillment — processing → shipped /
12
- // delivered / processed. All of these mean the order succeeded, so the ticket
13
- // confirmation should treat them as "paid"; only failed/pending-payment states
14
- // are "not completed". (A free order can be `delivered` by the time this loads.)
15
- const PAID_STATES = new Set<OrderStatus>([
16
- OrderStatus.Paid,
17
- OrderStatus.Processing,
18
- OrderStatus.LabelPurchased,
19
- OrderStatus.Shipped,
20
- OrderStatus.Delivered,
21
- OrderStatus.Processed,
22
- ]);
9
+ import { AddToCalendar } from "./AddToCalendar";
10
+ import { getTicketOrderOutcome, TICKET_ORDER_REFUNDED_COPY } from "../../utils/ticketOrderOutcome";
23
11
 
24
12
  export interface EventConfirmationProps {
25
13
  slug: string;
@@ -78,7 +66,13 @@ export function EventConfirmation({
78
66
  );
79
67
  }
80
68
 
81
- const paid = PAID_STATES.has(order.status);
69
+ // `cancelled` means two different things — swept-abandoned and refund-voided —
70
+ // and the copy for one is a lie told to the other. `getTicketOrderOutcome`
71
+ // reads the order's refund columns to tell them apart; the reasoning for why
72
+ // that is a trustworthy signal lives there.
73
+ const outcome = getTicketOrderOutcome(order);
74
+ const paid = outcome === "paid";
75
+ const refunded = outcome === "refunded";
82
76
  const fmt = (n: number) => formatAmt(n, order.currency || undefined);
83
77
  const location =
84
78
  event && event.type !== "virtual" && event.address
@@ -90,7 +84,14 @@ export function EventConfirmation({
90
84
  <ConfirmationCard>
91
85
  {/* ── Ceremony ── */}
92
86
  <div style={{ padding: "38px 30px 26px", textAlign: "center" }}>
93
- {paid ? <CheckSeal /> : <WarnSeal icon={<AlertTriangle size={34} color="#f59e0b" />} />}
87
+ {paid ? (
88
+ <CheckSeal />
89
+ ) : refunded ? (
90
+ // Neutral, not alarmed: a refund is a completed outcome, not a fault.
91
+ <NeutralSeal icon={<RotateCcw size={32} color={alpha(text, 0.65)} />} text={text} />
92
+ ) : (
93
+ <WarnSeal icon={<AlertTriangle size={34} color="#f59e0b" />} />
94
+ )}
94
95
 
95
96
  <p
96
97
  className="cf-rise"
@@ -100,19 +101,21 @@ export function EventConfirmation({
100
101
  fontWeight: 700,
101
102
  letterSpacing: "0.22em",
102
103
  textTransform: "uppercase",
103
- color: paid ? primary : "#f59e0b",
104
+ color: paid ? primary : refunded ? alpha(text, 0.65) : "#f59e0b",
104
105
  animationDelay: "0.15s",
105
106
  }}
106
107
  >
107
- {paid ? "E-Ticket" : "Order " + order.status.replace(/_/g, " ")}
108
+ {paid ? "E-Ticket" : refunded ? TICKET_ORDER_REFUNDED_COPY.eyebrow : "Order " + order.status.replace(/_/g, " ")}
108
109
  </p>
109
110
  <h1 className="cf-rise" style={{ fontSize: 29, fontWeight: 800, lineHeight: 1.12, margin: "0 0 8px", animationDelay: "0.22s" }}>
110
- {paid ? "You’re going! 🎟" : "Order not completed"}
111
+ {paid ? "You’re going! 🎟" : refunded ? TICKET_ORDER_REFUNDED_COPY.heading : "Order not completed"}
111
112
  </h1>
112
113
  <p className="cf-rise" style={{ fontSize: 15, color: alpha(text, 0.7), margin: 0, animationDelay: "0.3s" }}>
113
114
  {paid
114
115
  ? `We’ve sent your tickets to ${order.email}.`
115
- : `Your order is ${order.status.replace(/_/g, " ")}. If you were charged, contact support.`}
116
+ : refunded
117
+ ? TICKET_ORDER_REFUNDED_COPY.body
118
+ : `Your order is ${order.status.replace(/_/g, " ")}. If you were charged, contact support.`}
116
119
  </p>
117
120
  </div>
118
121
 
@@ -134,6 +137,20 @@ export function EventConfirmation({
134
137
  </>
135
138
  )}
136
139
  </div>
140
+
141
+ {/* Diarise it while the confirmation is still on screen — the single
142
+ highest-intent moment there is. Not offered for a called-off event. */}
143
+ {paid && event && event.status !== "cancelled" && (
144
+ <AddToCalendar
145
+ variant="compact"
146
+ title={event.title}
147
+ start={event.dateTime}
148
+ end={event.endDateTime}
149
+ description={event.description}
150
+ location={location}
151
+ style={{ marginTop: 12 }}
152
+ />
153
+ )}
137
154
  </div>
138
155
 
139
156
  <Perforation />
@@ -173,7 +190,11 @@ export function EventConfirmation({
173
190
  borderTop: `1px solid ${alpha(text, 0.1)}`,
174
191
  }}
175
192
  >
176
- <span style={{ fontWeight: 700 }}>Total {paid ? "paid" : ""}</span>
193
+ {/* Never "Total paid" for money that has gone back, and never a bare
194
+ "Total" for money that never moved. */}
195
+ <span style={{ fontWeight: 700 }}>
196
+ {paid ? "Total paid" : refunded ? TICKET_ORDER_REFUNDED_COPY.totalLabel : "Total"}
197
+ </span>
177
198
  {/* The exact charged total (charged currency) when present — never a re-converted catalog amount. */}
178
199
  <span style={{ fontSize: 18, fontWeight: 800, color: primary }}>
179
200
  {order.totalAmountInChargedCurrency
@@ -219,6 +240,31 @@ export function EventConfirmation({
219
240
  );
220
241
  }
221
242
 
243
+ /**
244
+ * The seal for a refunded order: same geometry as `CheckSeal`/`WarnSeal`, but
245
+ * drawn in the theme's own text color rather than warning amber. A refund is a
246
+ * settled outcome, not an error, and an amber warning ring says otherwise.
247
+ */
248
+ function NeutralSeal({ icon, text }: { icon: React.ReactNode; text: string }) {
249
+ return (
250
+ <div
251
+ className="cf-pop"
252
+ style={{
253
+ width: 76,
254
+ height: 76,
255
+ margin: "0 auto",
256
+ borderRadius: "50%",
257
+ display: "grid",
258
+ placeItems: "center",
259
+ background: alpha(text, 0.06),
260
+ border: `1px solid ${alpha(text, 0.18)}`,
261
+ }}
262
+ >
263
+ {icon}
264
+ </div>
265
+ );
266
+ }
267
+
222
268
  function Line({ icon, text, muted }: { icon: React.ReactNode; text: string; muted: string }) {
223
269
  return (
224
270
  <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 14, color: muted, padding: "3px 0" }}>
@@ -8,6 +8,7 @@ import { usePricesIncludeTax } from "../../data/queries/useWebsite";
8
8
  import { Loading } from "./Loading";
9
9
  import { EventTickets } from "./EventTickets";
10
10
  import { EventCountdown } from "./EventCountdown";
11
+ import { AddToCalendar } from "./AddToCalendar";
11
12
 
12
13
  export interface EventDetailProps {
13
14
  /** Event slug (resolves the event). */
@@ -63,6 +64,10 @@ export function EventDetail({
63
64
 
64
65
  const cover = event.media?.find((m) => m.type === "image")?.url ?? event.media?.[0]?.url;
65
66
  const minPrice = event.tickets.length ? Math.min(...event.tickets.map((t) => t.price)) : 0;
67
+ const locationText =
68
+ event.type === "virtual" || !event.address
69
+ ? "Virtual"
70
+ : [event.address.name, event.address.street, event.address.city, event.address.country].filter(Boolean).join(", ");
66
71
 
67
72
  return (
68
73
  <div
@@ -96,14 +101,22 @@ export function EventDetail({
96
101
  </span>
97
102
  <span style={{ display: "flex", alignItems: "flex-start", gap: 8 }}>
98
103
  <MapPin size={16} style={{ marginTop: 2, flexShrink: 0 }} />
99
- {event.type === "virtual" || !event.address
100
- ? "Virtual"
101
- : [event.address.name, event.address.street, event.address.city, event.address.country]
102
- .filter(Boolean)
103
- .join(", ")}
104
+ {locationText}
104
105
  </span>
105
106
  </div>
106
107
 
108
+ {/* Nothing to diarise for an event that has been called off. */}
109
+ {event.status !== "cancelled" && (
110
+ <AddToCalendar
111
+ title={event.title}
112
+ start={event.dateTime}
113
+ end={event.endDateTime}
114
+ description={event.description}
115
+ location={locationText}
116
+ style={{ marginTop: 16 }}
117
+ />
118
+ )}
119
+
107
120
  {event.description && (
108
121
  <>
109
122
  <h2 style={{ fontSize: 20, fontWeight: 700, margin: "24px 0 8px", fontFamily: t.headingFontFamily }}>About this event</h2>
@@ -9,6 +9,7 @@ import { usePricesIncludeTax } from "../../data/queries/useWebsite";
9
9
  import { Loading } from "./Loading";
10
10
  import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
11
11
  import { buttonStyle } from "./Button";
12
+ import { DiscountCodeField, DiscountSummaryLines } from "./DiscountCode";
12
13
  import {
13
14
  DialogRoot,
14
15
  DialogTrigger,
@@ -420,6 +421,15 @@ function DetailsStep({ c, event, fmt }: { c: Checkout; event: IEvent; fmt: (n: n
420
421
  );
421
422
  })}
422
423
 
424
+ {/* Tickets have no apply-coupon endpoint, so the code is carried into
425
+ the order that "Continue" creates — hence `deferred`. Its result is
426
+ what draws the discount line on the payment step. */}
427
+ {c.subTotal > 0 && (
428
+ <div style={{ marginTop: 4, paddingTop: 12, borderTop: `1px solid ${a(theme.colors.primary, 0.13)}` }}>
429
+ <DiscountCodeField coupon={c.coupon} fmt={fmt} mode="deferred" />
430
+ </div>
431
+ )}
432
+
423
433
  {(localError || c.error) && <p style={{ color: "#ef4444", fontSize: 14 }}>{localError ?? c.error}</p>}
424
434
  </div>
425
435
  <ActionBar
@@ -461,6 +471,35 @@ function PaymentStep({
461
471
  marginBottom: 16,
462
472
  }}
463
473
  >
474
+ {/* What the code actually did, from the order response — never a total
475
+ that silently shrank. Automatic (no-code) discounts land here too. */}
476
+ <DiscountSummaryLines
477
+ subTotal={c.subTotal}
478
+ discountAmount={c.coupon.discountAmount}
479
+ appliedCoupons={c.coupon.appliedCoupons}
480
+ fmt={fmt}
481
+ />
482
+ {c.coupon.hasDiscount && (
483
+ <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 8 }}>
484
+ <button
485
+ type="button"
486
+ data-testid="discount-code-remove"
487
+ onClick={c.clearCoupon}
488
+ style={{
489
+ background: "transparent",
490
+ border: "none",
491
+ padding: 0,
492
+ fontSize: 13,
493
+ textDecoration: "underline",
494
+ cursor: "pointer",
495
+ color: theme.colors.text,
496
+ fontFamily: theme.fontFamily,
497
+ }}
498
+ >
499
+ Remove discount
500
+ </button>
501
+ </div>
502
+ )}
464
503
  {taxSummary?.mode === "exclusive" && (
465
504
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
466
505
  <span style={{ fontSize: 14, opacity: 0.75 }}>Tax</span>
@@ -525,13 +564,18 @@ function ActionBar({
525
564
  border: `1px solid ${a(theme.colors.primary, 0.25)}`,
526
565
  borderRadius: theme.cornerRadius,
527
566
  marginBottom: 16,
528
- display: "flex",
529
- justifyContent: "space-between",
530
- alignItems: "center",
531
567
  }}
532
568
  >
533
- <span style={{ fontWeight: 600 }}>Total</span>
534
- <span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>{fmt(c.totalAmount)}</span>
569
+ <DiscountSummaryLines
570
+ subTotal={c.subTotal}
571
+ discountAmount={c.coupon.discountAmount}
572
+ appliedCoupons={c.coupon.appliedCoupons}
573
+ fmt={fmt}
574
+ />
575
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
576
+ <span style={{ fontWeight: 600 }}>Total</span>
577
+ <span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>{fmt(c.totalAmount)}</span>
578
+ </div>
535
579
  </div>
536
580
  <div style={{ display: "flex", gap: 8, justifyContent: "space-between" }}>
537
581
  {secondary ? (