@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
|
@@ -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 {
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
<
|
|
534
|
-
|
|
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 ? (
|