@tribe-nest/forge 3.57.0 → 3.59.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 +4 -2
- package/src/_tests/workspaceAliases.ts +1 -0
- package/src/contexts/CartContext.tsx +88 -5
- package/src/data/queries/useCallBreakouts.ts +112 -0
- package/src/data/queries/useCheckouts.ts +15 -2
- package/src/data/queries/useEvents.ts +68 -0
- package/src/data/queries/useMyBookings.ts +16 -0
- package/src/data/queries/useNotifications.ts +1 -1
- package/src/i18n/de.json +79 -0
- package/src/i18n/en.json +79 -0
- package/src/index.ts +1 -0
- package/src/server/_tests/buildPwaHead.spec.ts +1 -1
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
- package/src/server/index.ts +1 -1
- package/src/server/pwa.ts +1 -1
- package/src/types/models.ts +10 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
- package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
- package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
- package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
- package/src/ui/headless/checkout/seatConflict.ts +117 -0
- package/src/ui/headless/checkout/useCheckout.ts +178 -55
- package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
- package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
- package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
- package/src/ui/headless/event/useEventCheckout.ts +362 -22
- package/src/ui/headless/event/useEventSeating.ts +233 -0
- package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
- package/src/ui/headless/forms/useEmailListForm.ts +37 -6
- package/src/ui/headless/forms/usePhoneInput.ts +294 -0
- package/src/ui/headless/forms/useSectionedForm.ts +2 -2
- package/src/ui/headless/index.ts +25 -1
- package/src/ui/index.ts +7 -0
- package/src/ui/media/CallStage.tsx +107 -10
- package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
- package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
- package/src/ui/media/_tests/callState.spec.ts +103 -1
- package/src/ui/media/callBreakouts.ts +57 -0
- package/src/ui/media/callState.ts +83 -23
- package/src/ui/media/index.ts +11 -0
- package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
- package/src/ui/media/panels/CallSidePanel.tsx +37 -4
- package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
- package/src/ui/media/stage/CallControlsBar.tsx +46 -0
- package/src/ui/media/stage/CallTopBar.tsx +1 -1
- package/src/ui/shell/TribeNestApp.tsx +1 -1
- package/src/ui/styled/AccountDashboard.tsx +5 -0
- package/src/ui/styled/BundleConfirmation.tsx +47 -2
- package/src/ui/styled/Cart.tsx +211 -135
- package/src/ui/styled/Checkout.tsx +163 -32
- package/src/ui/styled/EmailListForm.tsx +7 -9
- package/src/ui/styled/EventTickets.tsx +143 -4
- package/src/ui/styled/PhoneInput.tsx +127 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
- package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
- package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
- package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
- package/src/ui/styled/forge-utilities.css +67 -0
- package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
- package/src/ui/styled/seating/SeatLegend.tsx +73 -0
- package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
- package/src/ui/styled/seating/paint.ts +79 -0
- package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
- package/src/ui/styled/seating/viewport.ts +86 -0
- package/src/utils/_tests/engagementTracker.spec.ts +111 -0
- package/src/utils/engagementTracker.ts +73 -0
- package/src/utils/landing.ts +1 -1
|
@@ -25,8 +25,10 @@ export function CallCenterNotice({
|
|
|
25
25
|
onRetry: () => void;
|
|
26
26
|
}) {
|
|
27
27
|
const detail = notice.detailKey ? t(notice.detailKey) : notice.detailText;
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
// A breakout move is a reconnect the person asked for (or was sent on), so
|
|
29
|
+
// it spins like joining does: something is happening, nothing is wrong.
|
|
30
|
+
const busy = notice.kind === "joining" || notice.kind === "moving";
|
|
31
|
+
const Icon = busy ? Loader2 : notice.kind === "waiting" || notice.kind === "everyone_left" ? Users : PhoneOff;
|
|
30
32
|
|
|
31
33
|
return (
|
|
32
34
|
<div
|
|
@@ -59,7 +61,7 @@ export function CallCenterNotice({
|
|
|
59
61
|
color={notice.kind === "failed" ? tokens.danger : tokens.stageMuted}
|
|
60
62
|
style={busy ? { animation: "forge-call-spin 1.2s linear infinite" } : undefined}
|
|
61
63
|
/>
|
|
62
|
-
<strong style={{ fontSize: "1.05rem" }}>{t(notice.headlineKey)}</strong>
|
|
64
|
+
<strong style={{ fontSize: "1.05rem" }}>{t(notice.headlineKey, notice.headlineVars)}</strong>
|
|
63
65
|
{detail && <span style={{ fontSize: "0.875rem", color: tokens.stageMuted, maxWidth: "28rem" }}>{detail}</span>}
|
|
64
66
|
{notice.canRetry && (
|
|
65
67
|
<button
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
|
2
2
|
import {
|
|
3
3
|
Circle,
|
|
4
|
+
DoorOpen,
|
|
4
5
|
Hand,
|
|
5
6
|
MessageSquare,
|
|
6
7
|
Mic,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
PhoneOff,
|
|
11
12
|
SmilePlus,
|
|
12
13
|
Square,
|
|
14
|
+
Undo2,
|
|
13
15
|
Users,
|
|
14
16
|
Video,
|
|
15
17
|
VideoOff,
|
|
@@ -73,6 +75,18 @@ export type CallControlsBarProps = {
|
|
|
73
75
|
onTogglePeople: () => void;
|
|
74
76
|
peopleOpen: boolean;
|
|
75
77
|
chatOpen: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Breakout rooms. All absent by default, and then nothing here is drawn:
|
|
80
|
+
* the bar renders pixel for pixel as it did before rooms existed.
|
|
81
|
+
*/
|
|
82
|
+
roomsAvailable?: boolean | undefined;
|
|
83
|
+
roomsOpen?: boolean | undefined;
|
|
84
|
+
onToggleRooms?: (() => void) | undefined;
|
|
85
|
+
/** This seat is in a breakout room, so Return to main is offered. */
|
|
86
|
+
inBreakout?: boolean | undefined;
|
|
87
|
+
onReturnToMain?: (() => void) | undefined;
|
|
88
|
+
/** The self-move is in flight. Holds the button rather than letting it be pressed twice. */
|
|
89
|
+
movePending?: boolean | undefined;
|
|
76
90
|
/** Presses of any control unlock the chime AudioContext. */
|
|
77
91
|
onGesture?: (() => void) | undefined;
|
|
78
92
|
};
|
|
@@ -372,7 +386,39 @@ export function CallControlsBar(props: CallControlsBarProps) {
|
|
|
372
386
|
)}
|
|
373
387
|
</div>
|
|
374
388
|
|
|
389
|
+
{props.inBreakout && props.onReturnToMain && (
|
|
390
|
+
<button
|
|
391
|
+
type="button"
|
|
392
|
+
data-testid="return-to-main"
|
|
393
|
+
onClick={() => {
|
|
394
|
+
gesture();
|
|
395
|
+
props.onReturnToMain?.();
|
|
396
|
+
}}
|
|
397
|
+
disabled={props.movePending}
|
|
398
|
+
aria-busy={props.movePending}
|
|
399
|
+
style={barButton(tokens, false, props.movePending)}
|
|
400
|
+
>
|
|
401
|
+
<Undo2 width={16} height={16} aria-hidden="true" />
|
|
402
|
+
{t("forge.call_stage.breakouts.return_to_main")}
|
|
403
|
+
</button>
|
|
404
|
+
)}
|
|
405
|
+
|
|
375
406
|
<span style={{ marginLeft: "auto", display: "inline-flex", gap: "0.5rem" }}>
|
|
407
|
+
{props.roomsAvailable && props.onToggleRooms && (
|
|
408
|
+
<button
|
|
409
|
+
type="button"
|
|
410
|
+
data-testid="toggle-rooms"
|
|
411
|
+
onClick={() => {
|
|
412
|
+
gesture();
|
|
413
|
+
props.onToggleRooms?.();
|
|
414
|
+
}}
|
|
415
|
+
aria-pressed={!!props.roomsOpen}
|
|
416
|
+
style={barButton(tokens, !!props.roomsOpen)}
|
|
417
|
+
>
|
|
418
|
+
<DoorOpen width={16} height={16} aria-hidden="true" />
|
|
419
|
+
{t("forge.call_stage.breakouts.button")}
|
|
420
|
+
</button>
|
|
421
|
+
)}
|
|
376
422
|
{props.chatAvailable && (
|
|
377
423
|
<button
|
|
378
424
|
type="button"
|
|
@@ -250,7 +250,7 @@ function ConnectionPill({ status, tokens, t }: { status: CallStatus; tokens: Sta
|
|
|
250
250
|
{status.tone === "live" && (
|
|
251
251
|
<span aria-hidden="true" style={{ width: 7, height: 7, borderRadius: 9999, backgroundColor: tokens.accent, display: "inline-block" }} />
|
|
252
252
|
)}
|
|
253
|
-
<span style={{ fontWeight: status.tone === "failed" ? 600 : 400 }}>{t(status.headlineKey)}</span>
|
|
253
|
+
<span style={{ fontWeight: status.tone === "failed" ? 600 : 400 }}>{t(status.headlineKey, status.headlineVars)}</span>
|
|
254
254
|
{detail && status.tone !== "live" && <span>{detail}</span>}
|
|
255
255
|
</span>
|
|
256
256
|
);
|
|
@@ -28,7 +28,7 @@ registerHostRuntime();
|
|
|
28
28
|
// params + referrer, kept in the URL for page-level ad pixels). On a FRESH
|
|
29
29
|
// landing it also fires the visit-level beacon so the sale-less visit is counted.
|
|
30
30
|
// Client-only, renders null. Lives in the shell so every code website gets ad
|
|
31
|
-
// attribution without editing the tenant-owned __root. See docs/ads-attribution-initiative.md.
|
|
31
|
+
// attribution without editing the tenant-owned __root. See docs/archive/shipped/ads-attribution-initiative.md.
|
|
32
32
|
function AttributionCapture({ enabled, apiUrl, profileId }: { enabled: boolean; apiUrl?: string; profileId?: string }) {
|
|
33
33
|
useEffect(() => {
|
|
34
34
|
if (!enabled) return;
|
|
@@ -1351,6 +1351,11 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
|
|
|
1351
1351
|
{booking.cancellation.terms && (
|
|
1352
1352
|
<div style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>{booking.cancellation.terms}</div>
|
|
1353
1353
|
)}
|
|
1354
|
+
{/* A reschedule rule of its own. When it follows the cancellation window
|
|
1355
|
+
the sentence above already says it, so nothing is repeated. */}
|
|
1356
|
+
{booking.reschedulePolicy && !booking.reschedulePolicy.followsCancellation && booking.reschedulePolicy.description && (
|
|
1357
|
+
<div style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>{booking.reschedulePolicy.description}</div>
|
|
1358
|
+
)}
|
|
1354
1359
|
|
|
1355
1360
|
{/*
|
|
1356
1361
|
The action row. Both controls are drawn from the SERVER's flags —
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, type ReactNode } from "react";
|
|
1
|
+
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
2
2
|
import { Clock, Loader2, Ticket, XCircle } from "lucide-react";
|
|
3
3
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
4
|
import { readableTextOn } from "../theme/contrast";
|
|
@@ -40,7 +40,31 @@ export function BundleConfirmation({
|
|
|
40
40
|
}: BundleConfirmationProps) {
|
|
41
41
|
const theme = useForgeTheme();
|
|
42
42
|
const t = useForgeT();
|
|
43
|
-
const { clearCart } = useCart();
|
|
43
|
+
const { clearCart, ticketItems } = useCart();
|
|
44
|
+
/**
|
|
45
|
+
* The reserved seats this bundle bought, by name, read off the cart lines
|
|
46
|
+
* BEFORE the cart is emptied below. The finalize answer names the child
|
|
47
|
+
* orders and nothing in them, and the cart is the one place the labels the
|
|
48
|
+
* buyer chose on the map still exist on this page.
|
|
49
|
+
*/
|
|
50
|
+
const seatLines = useMemo(
|
|
51
|
+
() =>
|
|
52
|
+
ticketItems.flatMap((item) =>
|
|
53
|
+
Object.entries(item.ticketMeta)
|
|
54
|
+
.filter(([ticketId, meta]) => (meta.seats?.length ?? 0) > 0 && (item.tickets[ticketId] ?? 0) > 0)
|
|
55
|
+
.map(([ticketId, meta]) => ({
|
|
56
|
+
key: `${item.eventId}-${ticketId}`,
|
|
57
|
+
eventTitle: item.eventTitle,
|
|
58
|
+
title: meta.title,
|
|
59
|
+
labels: (meta.seats ?? []).map((seat) => seat.label),
|
|
60
|
+
})),
|
|
61
|
+
),
|
|
62
|
+
[ticketItems],
|
|
63
|
+
);
|
|
64
|
+
const [seatsBought, setSeatsBought] = useState<typeof seatLines>([]);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (seatLines.length > 0) setSeatsBought(seatLines);
|
|
67
|
+
}, [seatLines]);
|
|
44
68
|
// A guest has no account screen behind "View your orders": the link would
|
|
45
69
|
// land them on a login wall for an order their EMAIL is the receipt for.
|
|
46
70
|
const { user } = usePublicAuth();
|
|
@@ -190,6 +214,27 @@ export function BundleConfirmation({
|
|
|
190
214
|
</div>
|
|
191
215
|
)}
|
|
192
216
|
|
|
217
|
+
{seatsBought.length > 0 && (
|
|
218
|
+
<div
|
|
219
|
+
data-testid="confirmation-seats"
|
|
220
|
+
style={{ textAlign: "left", margin: "0 auto 20px", maxWidth: 360, fontSize: 14 }}
|
|
221
|
+
>
|
|
222
|
+
<div style={{ fontWeight: 600, marginBottom: 4 }}>{t("forge.bundle_confirmation.seats_title")}</div>
|
|
223
|
+
{seatsBought.map((line) => (
|
|
224
|
+
<div key={line.key} style={{ marginBottom: 6 }}>
|
|
225
|
+
<div style={{ opacity: 0.75 }}>
|
|
226
|
+
{line.eventTitle} · {line.title}
|
|
227
|
+
</div>
|
|
228
|
+
{line.labels.map((label) => (
|
|
229
|
+
<div key={label} style={{ paddingLeft: 12 }}>
|
|
230
|
+
{label}
|
|
231
|
+
</div>
|
|
232
|
+
))}
|
|
233
|
+
</div>
|
|
234
|
+
))}
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
|
|
193
238
|
<div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
|
|
194
239
|
{user && link(accountPath, t("forge.bundle_confirmation.view_orders"), true)}
|
|
195
240
|
{link(explorePath, t("forge.bundle_confirmation.continue_shopping"), !user)}
|
package/src/ui/styled/Cart.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { readableTextOn } from "../theme/contrast";
|
|
|
8
8
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
9
9
|
import { usePricesIncludeTax } from "../../data/queries/useWebsite";
|
|
10
10
|
import { CartLineOptions } from "./CartLineOptions";
|
|
11
|
+
import { internalPath } from "../../runtime/pages";
|
|
11
12
|
|
|
12
13
|
export interface CartProps {
|
|
13
14
|
/** Custom cart icon (defaults to a shopping bag). */
|
|
@@ -122,114 +123,178 @@ export function Cart({ icon, checkoutPath = "/i/checkout", productHref, formatAm
|
|
|
122
123
|
)}
|
|
123
124
|
</button>
|
|
124
125
|
|
|
125
|
-
{isCartOpen &&
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
aria-label={translate("forge.cart.aria_close")}
|
|
131
|
-
style={{ position: "fixed", inset: 0, zIndex: OVERLAY_Z, background: "rgba(0,0,0,0.3)" }}
|
|
132
|
-
/>
|
|
133
|
-
<aside
|
|
134
|
-
style={{
|
|
135
|
-
position: "fixed",
|
|
136
|
-
top: 0,
|
|
137
|
-
right: 0,
|
|
138
|
-
height: "100%",
|
|
139
|
-
width: "100%",
|
|
140
|
-
maxWidth: 420,
|
|
141
|
-
zIndex: DRAWER_Z,
|
|
142
|
-
background: t.background,
|
|
143
|
-
color: t.text,
|
|
144
|
-
borderLeft: `2px solid ${t.primary}`,
|
|
145
|
-
boxShadow: "-8px 0 30px -20px rgba(0,0,0,0.5)",
|
|
146
|
-
display: "flex",
|
|
147
|
-
flexDirection: "column",
|
|
148
|
-
fontFamily: t.fontFamily,
|
|
149
|
-
animation: "forge-cart-slide .3s cubic-bezier(.4,0,.2,1)",
|
|
150
|
-
}}
|
|
151
|
-
>
|
|
126
|
+
{isCartOpen &&
|
|
127
|
+
typeof document !== "undefined" &&
|
|
128
|
+
createPortal(
|
|
129
|
+
<>
|
|
130
|
+
<style>{KEYFRAMES}</style>
|
|
152
131
|
<div
|
|
132
|
+
onClick={() => setCartOpen(false)}
|
|
133
|
+
aria-label={translate("forge.cart.aria_close")}
|
|
134
|
+
style={{ position: "fixed", inset: 0, zIndex: OVERLAY_Z, background: "rgba(0,0,0,0.3)" }}
|
|
135
|
+
/>
|
|
136
|
+
<aside
|
|
153
137
|
style={{
|
|
138
|
+
position: "fixed",
|
|
139
|
+
top: 0,
|
|
140
|
+
right: 0,
|
|
141
|
+
height: "100%",
|
|
142
|
+
width: "100%",
|
|
143
|
+
maxWidth: 420,
|
|
144
|
+
zIndex: DRAWER_Z,
|
|
145
|
+
background: t.background,
|
|
146
|
+
color: t.text,
|
|
147
|
+
borderLeft: `2px solid ${t.primary}`,
|
|
148
|
+
boxShadow: "-8px 0 30px -20px rgba(0,0,0,0.5)",
|
|
154
149
|
display: "flex",
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
borderBottom: `1px solid ${t.border}`,
|
|
150
|
+
flexDirection: "column",
|
|
151
|
+
fontFamily: t.fontFamily,
|
|
152
|
+
animation: "forge-cart-slide .3s cubic-bezier(.4,0,.2,1)",
|
|
159
153
|
}}
|
|
160
154
|
>
|
|
161
|
-
<
|
|
162
|
-
{
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
155
|
+
<div
|
|
156
|
+
style={{
|
|
157
|
+
display: "flex",
|
|
158
|
+
alignItems: "center",
|
|
159
|
+
justifyContent: "space-between",
|
|
160
|
+
padding: 16,
|
|
161
|
+
borderBottom: `1px solid ${t.border}`,
|
|
162
|
+
}}
|
|
169
163
|
>
|
|
170
|
-
<
|
|
171
|
-
|
|
172
|
-
|
|
164
|
+
<h2 style={{ fontSize: 18, fontWeight: 800, margin: 0, fontFamily: t.headingFontFamily }}>
|
|
165
|
+
{translate("forge.cart.title")}
|
|
166
|
+
</h2>
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
onClick={() => setCartOpen(false)}
|
|
170
|
+
aria-label={translate("forge.cart.aria_close")}
|
|
171
|
+
style={{ background: "transparent", border: "none", cursor: "pointer", color: t.text }}
|
|
172
|
+
>
|
|
173
|
+
<X size={22} />
|
|
174
|
+
</button>
|
|
175
|
+
</div>
|
|
173
176
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
177
|
+
<div
|
|
178
|
+
style={{ flex: 1, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 16 }}
|
|
179
|
+
>
|
|
180
|
+
{count === 0 && (
|
|
181
|
+
<p style={{ textAlign: "center", opacity: 0.7 }}>{translate("forge.cart.empty_title")}</p>
|
|
182
|
+
)}
|
|
178
183
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
style={{
|
|
184
|
-
display: "flex",
|
|
185
|
-
gap: 12,
|
|
186
|
-
alignItems: "flex-start",
|
|
187
|
-
borderBottom: `1px solid ${t.border}`,
|
|
188
|
-
paddingBottom: 16,
|
|
189
|
-
position: "relative",
|
|
190
|
-
}}
|
|
191
|
-
>
|
|
192
|
-
<img
|
|
193
|
-
src={ticket.coverImage || undefined}
|
|
194
|
-
alt={ticket.eventTitle}
|
|
184
|
+
{ticketItems.map((ticket) => (
|
|
185
|
+
<div
|
|
186
|
+
key={ticket.eventId}
|
|
187
|
+
data-testid="cart-ticket-line"
|
|
195
188
|
style={{
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
189
|
+
display: "flex",
|
|
190
|
+
gap: 12,
|
|
191
|
+
alignItems: "flex-start",
|
|
192
|
+
borderBottom: `1px solid ${t.border}`,
|
|
193
|
+
paddingBottom: 16,
|
|
194
|
+
position: "relative",
|
|
202
195
|
}}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
{ticket.eventTitle}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
196
|
+
>
|
|
197
|
+
<img
|
|
198
|
+
src={ticket.coverImage || undefined}
|
|
199
|
+
alt={ticket.eventTitle}
|
|
200
|
+
style={{
|
|
201
|
+
width: 64,
|
|
202
|
+
height: 64,
|
|
203
|
+
objectFit: "cover",
|
|
204
|
+
borderRadius: t.cornerRadius,
|
|
205
|
+
background: `${t.primary}10`,
|
|
206
|
+
flexShrink: 0,
|
|
207
|
+
}}
|
|
208
|
+
/>
|
|
209
|
+
<div style={{ flex: 1, minWidth: 0, paddingRight: 28 }}>
|
|
210
|
+
<div
|
|
211
|
+
style={{ fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
|
212
|
+
>
|
|
213
|
+
{ticket.eventTitle}
|
|
214
214
|
</div>
|
|
215
|
-
|
|
216
|
-
|
|
215
|
+
{Object.entries(ticket.tickets).map(([id, qty]) => {
|
|
216
|
+
// A reserved-seating tier: its quantity is its seat
|
|
217
|
+
// count, so the seats are listed by name and there is
|
|
218
|
+
// no stepper. Changing them happens on the event page,
|
|
219
|
+
// where the map is.
|
|
220
|
+
const seats = ticket.ticketMeta[id]?.seats ?? [];
|
|
221
|
+
return (
|
|
222
|
+
<div key={id}>
|
|
223
|
+
<div style={{ fontSize: 12, marginTop: 4, opacity: 0.75 }}>
|
|
224
|
+
{translate("forge.cart.ticket_line", {
|
|
225
|
+
ticket_title:
|
|
226
|
+
ticket.ticketMeta[id]?.title ?? translate("forge.cart.ticket_fallback_title"),
|
|
227
|
+
quantity: qty,
|
|
228
|
+
})}
|
|
229
|
+
</div>
|
|
230
|
+
{seats.length > 0 && (
|
|
231
|
+
<div
|
|
232
|
+
data-testid="cart-seat-chips"
|
|
233
|
+
style={{ display: "flex", flexWrap: "wrap", gap: 4, marginTop: 4 }}
|
|
234
|
+
>
|
|
235
|
+
{seats.map((seat) => (
|
|
236
|
+
<span
|
|
237
|
+
key={seat.id}
|
|
238
|
+
data-testid="cart-seat-chip"
|
|
239
|
+
style={{
|
|
240
|
+
padding: "2px 8px",
|
|
241
|
+
borderRadius: 999,
|
|
242
|
+
background: `${t.primary}14`,
|
|
243
|
+
color: t.text,
|
|
244
|
+
fontSize: 11,
|
|
245
|
+
lineHeight: 1.4,
|
|
246
|
+
}}
|
|
247
|
+
>
|
|
248
|
+
{seat.label}
|
|
249
|
+
</span>
|
|
250
|
+
))}
|
|
251
|
+
</div>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
})}
|
|
256
|
+
{Object.values(ticket.ticketMeta).some((meta) => (meta.seats?.length ?? 0) > 0) && (
|
|
257
|
+
<a
|
|
258
|
+
href={internalPath("events/$slug", { slug: ticket.eventSlug || ticket.eventId })}
|
|
259
|
+
data-testid="cart-change-seats"
|
|
260
|
+
onClick={() => setCartOpen(false)}
|
|
261
|
+
style={{
|
|
262
|
+
display: "inline-block",
|
|
263
|
+
fontSize: 12,
|
|
264
|
+
marginTop: 6,
|
|
265
|
+
color: t.primary,
|
|
266
|
+
textDecoration: "underline",
|
|
267
|
+
}}
|
|
268
|
+
>
|
|
269
|
+
{translate("forge.cart.change_seats")}
|
|
270
|
+
</a>
|
|
271
|
+
)}
|
|
272
|
+
<div style={{ fontSize: 14, marginTop: 4, opacity: 0.8 }}>{fmt(ticketTotal(ticket))}</div>
|
|
273
|
+
</div>
|
|
274
|
+
<button
|
|
275
|
+
type="button"
|
|
276
|
+
// Removing the ticket takes its add-ons with it — they
|
|
277
|
+
// cannot be bought on their own, and leaving them would
|
|
278
|
+
// produce a checkout the server refuses.
|
|
279
|
+
onClick={() => removeTickets(ticket.eventId)}
|
|
280
|
+
aria-label={translate("forge.cart.aria_remove_tickets")}
|
|
281
|
+
style={{
|
|
282
|
+
position: "absolute",
|
|
283
|
+
top: 0,
|
|
284
|
+
right: 0,
|
|
285
|
+
background: "transparent",
|
|
286
|
+
border: "none",
|
|
287
|
+
cursor: "pointer",
|
|
288
|
+
color: t.primary,
|
|
289
|
+
padding: 4,
|
|
290
|
+
}}
|
|
291
|
+
>
|
|
292
|
+
<Trash2 size={16} />
|
|
293
|
+
</button>
|
|
217
294
|
</div>
|
|
218
|
-
|
|
219
|
-
type="button"
|
|
220
|
-
// Removing the ticket takes its add-ons with it — they
|
|
221
|
-
// cannot be bought on their own, and leaving them would
|
|
222
|
-
// produce a checkout the server refuses.
|
|
223
|
-
onClick={() => removeTickets(ticket.eventId)}
|
|
224
|
-
aria-label={translate("forge.cart.aria_remove_tickets")}
|
|
225
|
-
style={{ position: "absolute", top: 0, right: 0, background: "transparent", border: "none", cursor: "pointer", color: t.primary, padding: 4 }}
|
|
226
|
-
>
|
|
227
|
-
<Trash2 size={16} />
|
|
228
|
-
</button>
|
|
229
|
-
</div>
|
|
230
|
-
))}
|
|
295
|
+
))}
|
|
231
296
|
|
|
232
|
-
|
|
297
|
+
{cartItems.map((item) => (
|
|
233
298
|
<div
|
|
234
299
|
key={item.productId + item.productVariantId + String(item.isGift) + (item.recipientEmail || "")}
|
|
235
300
|
// Named so a test can assert on the LINE rather than the
|
|
@@ -300,55 +365,66 @@ export function Cart({ icon, checkoutPath = "/i/checkout", productHref, formatAm
|
|
|
300
365
|
type="button"
|
|
301
366
|
onClick={() => removeFromCart(item.productVariantId, item.isGift, item.recipientEmail)}
|
|
302
367
|
aria-label={translate("forge.cart.aria_remove_item")}
|
|
303
|
-
style={{
|
|
368
|
+
style={{
|
|
369
|
+
position: "absolute",
|
|
370
|
+
top: 0,
|
|
371
|
+
right: 0,
|
|
372
|
+
background: "transparent",
|
|
373
|
+
border: "none",
|
|
374
|
+
cursor: "pointer",
|
|
375
|
+
color: t.primary,
|
|
376
|
+
padding: 4,
|
|
377
|
+
}}
|
|
304
378
|
>
|
|
305
379
|
<Trash2 size={16} />
|
|
306
380
|
</button>
|
|
307
381
|
</div>
|
|
308
382
|
))}
|
|
309
|
-
|
|
383
|
+
</div>
|
|
310
384
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
style={{
|
|
314
|
-
padding: 16,
|
|
315
|
-
borderTop: `1px solid ${t.border}`,
|
|
316
|
-
background: t.background,
|
|
317
|
-
}}
|
|
318
|
-
>
|
|
319
|
-
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
|
|
320
|
-
<span style={{ fontSize: 18, fontWeight: 600 }}>{translate("forge.cart.total")}</span>
|
|
321
|
-
<span style={{ fontSize: 18, fontWeight: 800, color: t.primary, textAlign: "right" }}>
|
|
322
|
-
{fmt(total)}
|
|
323
|
-
{pricesIncludeTax && (
|
|
324
|
-
<span style={{ display: "block", fontSize: 11, fontWeight: 400, color: t.text, opacity: 0.65 }}>
|
|
325
|
-
{translate("forge.cart.incl_tax")}
|
|
326
|
-
</span>
|
|
327
|
-
)}
|
|
328
|
-
</span>
|
|
329
|
-
</div>
|
|
330
|
-
<button
|
|
331
|
-
type="button"
|
|
332
|
-
onClick={goCheckout}
|
|
385
|
+
{count > 0 && (
|
|
386
|
+
<div
|
|
333
387
|
style={{
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
border: "none",
|
|
338
|
-
fontWeight: 700,
|
|
339
|
-
cursor: "pointer",
|
|
340
|
-
background: t.primary,
|
|
341
|
-
color: onPrimary,
|
|
388
|
+
padding: 16,
|
|
389
|
+
borderTop: `1px solid ${t.border}`,
|
|
390
|
+
background: t.background,
|
|
342
391
|
}}
|
|
343
392
|
>
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
393
|
+
<div
|
|
394
|
+
style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}
|
|
395
|
+
>
|
|
396
|
+
<span style={{ fontSize: 18, fontWeight: 600 }}>{translate("forge.cart.total")}</span>
|
|
397
|
+
<span style={{ fontSize: 18, fontWeight: 800, color: t.primary, textAlign: "right" }}>
|
|
398
|
+
{fmt(total)}
|
|
399
|
+
{pricesIncludeTax && (
|
|
400
|
+
<span style={{ display: "block", fontSize: 11, fontWeight: 400, color: t.text, opacity: 0.65 }}>
|
|
401
|
+
{translate("forge.cart.incl_tax")}
|
|
402
|
+
</span>
|
|
403
|
+
)}
|
|
404
|
+
</span>
|
|
405
|
+
</div>
|
|
406
|
+
<button
|
|
407
|
+
type="button"
|
|
408
|
+
onClick={goCheckout}
|
|
409
|
+
style={{
|
|
410
|
+
width: "100%",
|
|
411
|
+
padding: "12px 16px",
|
|
412
|
+
borderRadius: t.cornerRadius,
|
|
413
|
+
border: "none",
|
|
414
|
+
fontWeight: 700,
|
|
415
|
+
cursor: "pointer",
|
|
416
|
+
background: t.primary,
|
|
417
|
+
color: onPrimary,
|
|
418
|
+
}}
|
|
419
|
+
>
|
|
420
|
+
{translate("forge.cart.checkout")}
|
|
421
|
+
</button>
|
|
422
|
+
</div>
|
|
423
|
+
)}
|
|
424
|
+
</aside>
|
|
425
|
+
</>,
|
|
426
|
+
document.body,
|
|
427
|
+
)}
|
|
352
428
|
</>
|
|
353
429
|
);
|
|
354
430
|
}
|