@tribe-nest/forge 3.4.0 → 3.9.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/_tests/eventWaitlist.spec.ts +122 -0
- package/src/data/queries/_tests/passTransfers.spec.ts +89 -0
- package/src/data/queries/_tests/walletPass.spec.ts +159 -0
- package/src/data/queries/useEventWaitlist.ts +429 -0
- package/src/data/queries/useMyBookings.ts +211 -0
- package/src/data/queries/useMyTickets.ts +154 -0
- package/src/data/queries/usePassTransfers.ts +318 -0
- package/src/data/queries/useWalletPass.ts +236 -0
- package/src/index.ts +5 -0
- package/src/server/_tests/siteBootstrap.spec.ts +131 -0
- package/src/server/index.ts +122 -6
- package/src/types/diagnostics.ts +49 -0
- package/src/ui/index.ts +19 -0
- package/src/ui/shell/PoweredBy.tsx +6 -4
- package/src/ui/shell/PreviewDiagnostics.tsx +80 -0
- package/src/ui/shell/TribeNestApp.tsx +24 -0
- package/src/ui/shell/diagnosticsGating.spec.ts +102 -0
- package/src/ui/shell/diagnosticsGating.ts +90 -0
- package/src/ui/shell/shellGating.spec.ts +40 -1
- package/src/ui/shell/shellGating.ts +33 -0
- package/src/ui/styled/AccountDashboard.tsx +598 -1
- package/src/ui/styled/EventDetail.tsx +6 -0
- package/src/ui/styled/EventWaitlist.tsx +448 -0
- package/src/ui/styled/TicketTransfer.tsx +393 -0
- package/src/ui/styled/WalletPassButtons.tsx +208 -0
- package/src/ui/styled/_tests/WalletPassButtons.spec.tsx +223 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { useState, type CSSProperties } from "react";
|
|
2
|
+
import {
|
|
3
|
+
useSendPassTransfer,
|
|
4
|
+
useCancelPassTransfer,
|
|
5
|
+
useClaimPassTransfer,
|
|
6
|
+
usePendingPassTransfers,
|
|
7
|
+
type PassTransfer,
|
|
8
|
+
} from "../../data/queries/usePassTransfers";
|
|
9
|
+
import { useThemeTokens, type ResolvedThemeTokens } from "../theme/ForgeThemeProvider";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Events 2.2 — handing a ticket to somebody else, and taking one.
|
|
13
|
+
*
|
|
14
|
+
* Two components, because the two halves have nothing in common but the
|
|
15
|
+
* endpoint family:
|
|
16
|
+
*
|
|
17
|
+
* `<TicketTransferPanel>` — the HOLDER's affordance, inside their portal.
|
|
18
|
+
* `<ClaimTicketTransfer>` — the RECIPIENT's page, reached from an email and
|
|
19
|
+
* usable while signed out.
|
|
20
|
+
*
|
|
21
|
+
* ## Nothing here decides anything
|
|
22
|
+
*
|
|
23
|
+
* Whether a pass may be handed on (checked in, refunded, show cancelled, show
|
|
24
|
+
* over), whether a claim still works, whether a cancel is still possible — all
|
|
25
|
+
* of it is the server's, re-checked at claim time because days pass between
|
|
26
|
+
* sending a link and clicking it. Every refusal is rendered as the server's own
|
|
27
|
+
* message. The states are not enumerable from here and inventing copy for them
|
|
28
|
+
* is how a buyer gets told the wrong thing at a venue door.
|
|
29
|
+
*
|
|
30
|
+
* The one thing the UI does decide is where to DRAW the send form, and it draws
|
|
31
|
+
* it for any pass the buyer's own ticket list gave it. A pass that cannot be
|
|
32
|
+
* transferred is answered on submit, in the server's words, rather than by a
|
|
33
|
+
* hidden button that leaves the buyer with no explanation at all.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
// ── shared styling ──────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const inputStyle = (t: ResolvedThemeTokens): CSSProperties => ({
|
|
39
|
+
width: "100%",
|
|
40
|
+
padding: "8px 12px",
|
|
41
|
+
borderRadius: t.cornerRadius,
|
|
42
|
+
border: `1px solid ${t.primary}40`,
|
|
43
|
+
background: "transparent",
|
|
44
|
+
color: t.text,
|
|
45
|
+
fontFamily: t.fontFamily,
|
|
46
|
+
outline: "none",
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const primaryButton = (t: ResolvedThemeTokens, busy: boolean): CSSProperties => ({
|
|
50
|
+
padding: "8px 14px",
|
|
51
|
+
borderRadius: t.cornerRadius,
|
|
52
|
+
background: t.primary,
|
|
53
|
+
color: t.textPrimary,
|
|
54
|
+
border: "none",
|
|
55
|
+
fontWeight: 600,
|
|
56
|
+
cursor: busy ? "wait" : "pointer",
|
|
57
|
+
opacity: busy ? 0.7 : 1,
|
|
58
|
+
fontFamily: t.fontFamily,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const ghostButton = (t: ResolvedThemeTokens, busy: boolean): CSSProperties => ({
|
|
62
|
+
padding: "8px 14px",
|
|
63
|
+
borderRadius: t.cornerRadius,
|
|
64
|
+
background: "transparent",
|
|
65
|
+
color: t.text,
|
|
66
|
+
border: `1px solid ${t.primary}40`,
|
|
67
|
+
fontWeight: 600,
|
|
68
|
+
cursor: busy ? "wait" : "pointer",
|
|
69
|
+
fontFamily: t.fontFamily,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** The server's own words, or a last-resort fallback for a transport failure. */
|
|
73
|
+
const serverMessage = (error: unknown, fallback: string): string =>
|
|
74
|
+
(error as { response?: { data?: { message?: string } } })?.response?.data?.message ?? fallback;
|
|
75
|
+
|
|
76
|
+
const formatWhen = (value: string | null | undefined): string | null => {
|
|
77
|
+
if (!value) return null;
|
|
78
|
+
const date = new Date(value);
|
|
79
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
80
|
+
return date.toLocaleString(undefined, {
|
|
81
|
+
weekday: "short",
|
|
82
|
+
day: "numeric",
|
|
83
|
+
month: "short",
|
|
84
|
+
hour: "numeric",
|
|
85
|
+
minute: "2-digit",
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// ── The holder's side ───────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export interface TicketTransferPanelProps {
|
|
92
|
+
/** The `TN-…` pass ids on this order — `myTicketPassIds(ticket)`. */
|
|
93
|
+
passIds: string[];
|
|
94
|
+
/** Extra class(es) on the root element. */
|
|
95
|
+
className?: string;
|
|
96
|
+
/** Inline style merged LAST into the root element. */
|
|
97
|
+
style?: CSSProperties;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* "Transfer this ticket" for the passes on one order, plus a way to withdraw a
|
|
102
|
+
* transfer that has not been claimed.
|
|
103
|
+
*
|
|
104
|
+
* Renders nothing when the order carries no pass ids — an older API build that
|
|
105
|
+
* does not emit them cannot address any pass-scoped endpoint, so an affordance
|
|
106
|
+
* would only ever 404.
|
|
107
|
+
*
|
|
108
|
+
* The pending list comes from this browser's local record of its own sends,
|
|
109
|
+
* because the transfer HISTORY endpoint is operator-only by design (it names
|
|
110
|
+
* every previous holder) and nothing on `my-tickets` carries transfer state.
|
|
111
|
+
* That limit is stated to the buyer rather than hidden: the recipient's email is
|
|
112
|
+
* the durable copy.
|
|
113
|
+
*/
|
|
114
|
+
export function TicketTransferPanel({ passIds, className, style }: TicketTransferPanelProps) {
|
|
115
|
+
const t = useThemeTokens();
|
|
116
|
+
const [openFor, setOpenFor] = useState<string | null>(null);
|
|
117
|
+
|
|
118
|
+
if (passIds.length === 0) return null;
|
|
119
|
+
|
|
120
|
+
const single = passIds.length === 1;
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div
|
|
124
|
+
data-testid="ticket-transfer-panel"
|
|
125
|
+
className={className}
|
|
126
|
+
style={{ display: "flex", flexDirection: "column", gap: 10, ...style }}
|
|
127
|
+
>
|
|
128
|
+
{passIds.map((passId) => (
|
|
129
|
+
<TicketTransferRow
|
|
130
|
+
key={passId}
|
|
131
|
+
passId={passId}
|
|
132
|
+
t={t}
|
|
133
|
+
showPassId={!single}
|
|
134
|
+
isOpen={openFor === passId}
|
|
135
|
+
onToggle={() => setOpenFor((current) => (current === passId ? null : passId))}
|
|
136
|
+
/>
|
|
137
|
+
))}
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function TicketTransferRow({
|
|
143
|
+
passId,
|
|
144
|
+
t,
|
|
145
|
+
showPassId,
|
|
146
|
+
isOpen,
|
|
147
|
+
onToggle,
|
|
148
|
+
}: {
|
|
149
|
+
passId: string;
|
|
150
|
+
t: ResolvedThemeTokens;
|
|
151
|
+
showPassId: boolean;
|
|
152
|
+
isOpen: boolean;
|
|
153
|
+
onToggle: () => void;
|
|
154
|
+
}) {
|
|
155
|
+
const send = useSendPassTransfer();
|
|
156
|
+
const cancel = useCancelPassTransfer();
|
|
157
|
+
const pending = usePendingPassTransfers(passId);
|
|
158
|
+
|
|
159
|
+
const [toEmail, setToEmail] = useState("");
|
|
160
|
+
const [toName, setToName] = useState("");
|
|
161
|
+
const [error, setError] = useState<string | null>(null);
|
|
162
|
+
const [sent, setSent] = useState<PassTransfer | null>(null);
|
|
163
|
+
|
|
164
|
+
const onSend = async (event: React.FormEvent) => {
|
|
165
|
+
event.preventDefault();
|
|
166
|
+
setError(null);
|
|
167
|
+
try {
|
|
168
|
+
const transfer = await send.mutateAsync({ passId, toEmail: toEmail.trim(), toName: toName.trim() });
|
|
169
|
+
setSent(transfer);
|
|
170
|
+
setToEmail("");
|
|
171
|
+
setToName("");
|
|
172
|
+
} catch (err) {
|
|
173
|
+
// The server distinguishes "already has a live transfer", "you have
|
|
174
|
+
// checked in", "the show was cancelled" and several more. Its wording,
|
|
175
|
+
// not ours.
|
|
176
|
+
setError(serverMessage(err, "We could not send this ticket. Please try again."));
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const onCancel = async (transferId: string) => {
|
|
181
|
+
setError(null);
|
|
182
|
+
setSent(null);
|
|
183
|
+
try {
|
|
184
|
+
await cancel.mutateAsync({ transferId });
|
|
185
|
+
} catch (err) {
|
|
186
|
+
setError(serverMessage(err, "We could not withdraw this transfer. Please try again."));
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div
|
|
192
|
+
style={{
|
|
193
|
+
border: `1px solid ${t.primary}20`,
|
|
194
|
+
borderRadius: t.cornerRadius,
|
|
195
|
+
padding: 12,
|
|
196
|
+
color: t.text,
|
|
197
|
+
fontFamily: t.fontFamily,
|
|
198
|
+
}}
|
|
199
|
+
>
|
|
200
|
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
|
|
201
|
+
<div>
|
|
202
|
+
<div style={{ fontWeight: 600, fontSize: 14 }}>Transfer this ticket</div>
|
|
203
|
+
{showPassId && <div style={{ fontSize: 12, color: t.muted }}>{passId}</div>}
|
|
204
|
+
</div>
|
|
205
|
+
<button type="button" onClick={onToggle} style={ghostButton(t, false)} aria-expanded={isOpen}>
|
|
206
|
+
{isOpen ? "Close" : "Send to someone"}
|
|
207
|
+
</button>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
{/* A live transfer this browser sent. One per pass is a database
|
|
211
|
+
guarantee, so this is a list only because the store is one. */}
|
|
212
|
+
{pending.map((item) => {
|
|
213
|
+
const expires = formatWhen(item.expiresAt);
|
|
214
|
+
return (
|
|
215
|
+
<div
|
|
216
|
+
key={item.transferId}
|
|
217
|
+
style={{
|
|
218
|
+
marginTop: 12,
|
|
219
|
+
padding: 12,
|
|
220
|
+
borderRadius: t.cornerRadius,
|
|
221
|
+
border: `1px solid ${t.primary}4d`,
|
|
222
|
+
fontSize: 14,
|
|
223
|
+
}}
|
|
224
|
+
>
|
|
225
|
+
<div>
|
|
226
|
+
Sent to <strong>{item.toEmail}</strong>
|
|
227
|
+
{expires ? ` — the link works until ${expires}.` : "."}
|
|
228
|
+
</div>
|
|
229
|
+
<div style={{ fontSize: 13, color: t.muted, marginTop: 4 }}>
|
|
230
|
+
Your QR code keeps working until they claim it.
|
|
231
|
+
</div>
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
onClick={() => void onCancel(item.transferId)}
|
|
235
|
+
disabled={cancel.isPending}
|
|
236
|
+
style={{ ...ghostButton(t, cancel.isPending), marginTop: 10 }}
|
|
237
|
+
>
|
|
238
|
+
{cancel.isPending ? "Withdrawing…" : "Withdraw transfer"}
|
|
239
|
+
</button>
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
})}
|
|
243
|
+
|
|
244
|
+
{sent && (
|
|
245
|
+
<p style={{ fontSize: 13, marginTop: 10 }}>
|
|
246
|
+
We emailed <strong>{sent.toEmail}</strong> a link to claim this ticket.
|
|
247
|
+
</p>
|
|
248
|
+
)}
|
|
249
|
+
|
|
250
|
+
{error && (
|
|
251
|
+
<p style={{ fontSize: 13, marginTop: 10, color: t.primary }} role="alert">
|
|
252
|
+
{error}
|
|
253
|
+
</p>
|
|
254
|
+
)}
|
|
255
|
+
|
|
256
|
+
{isOpen && (
|
|
257
|
+
<form onSubmit={onSend} style={{ display: "flex", flexDirection: "column", gap: 10, marginTop: 12 }}>
|
|
258
|
+
<input
|
|
259
|
+
type="email"
|
|
260
|
+
required
|
|
261
|
+
value={toEmail}
|
|
262
|
+
onChange={(event) => setToEmail(event.target.value)}
|
|
263
|
+
placeholder="Their email address"
|
|
264
|
+
aria-label="Recipient email address"
|
|
265
|
+
style={inputStyle(t)}
|
|
266
|
+
/>
|
|
267
|
+
<input
|
|
268
|
+
type="text"
|
|
269
|
+
maxLength={240}
|
|
270
|
+
value={toName}
|
|
271
|
+
onChange={(event) => setToName(event.target.value)}
|
|
272
|
+
placeholder="Their name (optional)"
|
|
273
|
+
aria-label="Recipient name"
|
|
274
|
+
style={inputStyle(t)}
|
|
275
|
+
/>
|
|
276
|
+
<p style={{ fontSize: 12, color: t.muted, margin: 0 }}>
|
|
277
|
+
They get a link to claim it. Once they do, this ticket becomes theirs and your QR code stops working.
|
|
278
|
+
</p>
|
|
279
|
+
<button type="submit" disabled={send.isPending} style={primaryButton(t, send.isPending)}>
|
|
280
|
+
{send.isPending ? "Sending…" : "Send ticket"}
|
|
281
|
+
</button>
|
|
282
|
+
</form>
|
|
283
|
+
)}
|
|
284
|
+
</div>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── The recipient's side ────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
export interface ClaimTicketTransferProps {
|
|
291
|
+
/** The 64-character token out of the claim link's `?token=`. */
|
|
292
|
+
token?: string | null;
|
|
293
|
+
/** Called once the claim lands, with the pass now in the claimant's name. */
|
|
294
|
+
onClaimed?: (result: PassTransfer & { eventPassId: string }) => void;
|
|
295
|
+
className?: string;
|
|
296
|
+
style?: CSSProperties;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The claim page's body. **Works for a visitor who is not signed in and has no
|
|
301
|
+
* account** — which is the common case and the whole reason the endpoint is
|
|
302
|
+
* open to anonymous callers.
|
|
303
|
+
*
|
|
304
|
+
* The only input is a name, and it is optional: the server falls back to
|
|
305
|
+
* whatever the sender addressed them as, and then to the address itself, so
|
|
306
|
+
* `owner_name` is never blank on the ticket that goes to the door.
|
|
307
|
+
*
|
|
308
|
+
* A missing token is the one state judged locally, because there is no request
|
|
309
|
+
* to make. Everything else — expired, already claimed, withdrawn, a show that
|
|
310
|
+
* was cancelled since — comes back from the server with its own wording.
|
|
311
|
+
*/
|
|
312
|
+
export function ClaimTicketTransfer({ token, onClaimed, className, style }: ClaimTicketTransferProps) {
|
|
313
|
+
const t = useThemeTokens();
|
|
314
|
+
const claim = useClaimPassTransfer();
|
|
315
|
+
const [name, setName] = useState("");
|
|
316
|
+
const [error, setError] = useState<string | null>(null);
|
|
317
|
+
const [claimed, setClaimed] = useState<(PassTransfer & { eventPassId: string }) | null>(null);
|
|
318
|
+
|
|
319
|
+
const card: CSSProperties = {
|
|
320
|
+
maxWidth: 460,
|
|
321
|
+
margin: "0 auto",
|
|
322
|
+
color: t.text,
|
|
323
|
+
fontFamily: t.fontFamily,
|
|
324
|
+
...style,
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
if (!token) {
|
|
328
|
+
return (
|
|
329
|
+
<div className={className} style={{ ...card, textAlign: "center" }}>
|
|
330
|
+
<h1 style={{ fontSize: 24, fontWeight: 700, fontFamily: t.headingFontFamily }}>Invalid claim link</h1>
|
|
331
|
+
<p style={{ color: t.muted, marginTop: 8 }}>
|
|
332
|
+
Your link is missing its code. Please use the link from your email.
|
|
333
|
+
</p>
|
|
334
|
+
</div>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (claimed) {
|
|
339
|
+
return (
|
|
340
|
+
<div className={className} style={card}>
|
|
341
|
+
<h1 style={{ fontSize: 24, fontWeight: 700, fontFamily: t.headingFontFamily }}>The ticket is yours</h1>
|
|
342
|
+
<p style={{ marginTop: 8 }}>
|
|
343
|
+
Ticket <strong>{claimed.eventPassId}</strong> is now in your name. We have emailed it to{" "}
|
|
344
|
+
<strong>{claimed.toEmail}</strong> — bring that QR code to the door.
|
|
345
|
+
</p>
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const onSubmit = async (event: React.FormEvent) => {
|
|
351
|
+
event.preventDefault();
|
|
352
|
+
setError(null);
|
|
353
|
+
try {
|
|
354
|
+
const result = await claim.mutateAsync({ token, name: name.trim() });
|
|
355
|
+
setClaimed(result);
|
|
356
|
+
onClaimed?.(result);
|
|
357
|
+
} catch (err) {
|
|
358
|
+
// "Already claimed" and "expired" are materially different answers to
|
|
359
|
+
// somebody standing outside a venue. Never collapsed into one sentence.
|
|
360
|
+
setError(serverMessage(err, "We could not claim this ticket. Your link may have expired or already been used."));
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
return (
|
|
365
|
+
<div className={className} style={card}>
|
|
366
|
+
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8, fontFamily: t.headingFontFamily }}>
|
|
367
|
+
Claim your ticket
|
|
368
|
+
</h1>
|
|
369
|
+
<p style={{ color: t.muted, marginBottom: 24 }}>
|
|
370
|
+
Someone sent you their ticket. Tell us what to put on it — you do not need an account.
|
|
371
|
+
</p>
|
|
372
|
+
<form onSubmit={onSubmit} style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
|
373
|
+
<input
|
|
374
|
+
type="text"
|
|
375
|
+
maxLength={240}
|
|
376
|
+
value={name}
|
|
377
|
+
onChange={(event) => setName(event.target.value)}
|
|
378
|
+
placeholder="Your name (optional)"
|
|
379
|
+
aria-label="Your name"
|
|
380
|
+
style={inputStyle(t)}
|
|
381
|
+
/>
|
|
382
|
+
{error && (
|
|
383
|
+
<p style={{ color: t.primary, fontSize: 14 }} role="alert">
|
|
384
|
+
{error}
|
|
385
|
+
</p>
|
|
386
|
+
)}
|
|
387
|
+
<button type="submit" disabled={claim.isPending} style={primaryButton(t, claim.isPending)}>
|
|
388
|
+
{claim.isPending ? "Claiming…" : "Claim ticket"}
|
|
389
|
+
</button>
|
|
390
|
+
</form>
|
|
391
|
+
</div>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { type CSSProperties } from "react";
|
|
2
|
+
import { usePublicAuth } from "../../contexts/PublicAuthContext";
|
|
3
|
+
import { useDownloadApplePass, useWalletPasses, type WalletPassEntry } from "../../data/queries/useWalletPass";
|
|
4
|
+
import { useThemeTokens, type ResolvedThemeTokens } from "../theme/ForgeThemeProvider";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Events 2.3 — "Add to Apple Wallet" / "Save to Google Wallet", on a ticket the
|
|
8
|
+
* buyer already holds.
|
|
9
|
+
*
|
|
10
|
+
* ## This component's whole job is knowing when NOT to exist
|
|
11
|
+
*
|
|
12
|
+
* No wallet signing credentials are configured in production yet, so the status
|
|
13
|
+
* endpoint currently answers `available: false` for every pass. In that state
|
|
14
|
+
* **this renders `null`** — not a disabled button, not a "coming soon", not a
|
|
15
|
+
* hint. A ticket-holder who will never get a wallet pass must not be able to
|
|
16
|
+
* tell the feature is there at all.
|
|
17
|
+
*
|
|
18
|
+
* It reaches `null` the same way for every other negative case too: anonymous
|
|
19
|
+
* visitor, a pass that is not theirs (the API answers 404 by design), a
|
|
20
|
+
* cancelled order, a pass on signed rotating QR that no static wallet barcode
|
|
21
|
+
* could satisfy, a network failure, or simply not having answered yet. There is
|
|
22
|
+
* no loading placeholder — a spinner where a button will never appear is the
|
|
23
|
+
* same tell as the button itself.
|
|
24
|
+
*
|
|
25
|
+
* `useWalletPasses` returns ONLY the passes with a real artefact, so the check
|
|
26
|
+
* below is a length test rather than a chain of conditions that a later edit
|
|
27
|
+
* could get wrong.
|
|
28
|
+
*
|
|
29
|
+
* ## The badges
|
|
30
|
+
*
|
|
31
|
+
* Apple and Google both specify a badge lockup: a high-contrast pill, the
|
|
32
|
+
* brandmark on the left, a fixed phrase. The shape, the phrasing and the
|
|
33
|
+
* brandmarks here follow that; the COLOURS come from the site's theme rather
|
|
34
|
+
* than the official black, because Forge blocks may only draw with theme tokens
|
|
35
|
+
* and a hardcoded black pill is unreadable on half the themes it would land in.
|
|
36
|
+
* The Google "G" keeps its official four colours — a monochrome G is a brand
|
|
37
|
+
* violation, and it is a mark rather than a styling choice.
|
|
38
|
+
*
|
|
39
|
+
* Official artwork is deliberately not committed: the assets are large binaries
|
|
40
|
+
* whose licence travels with brand approval, and inline SVG costs nothing.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
export interface WalletPassButtonsProps {
|
|
44
|
+
/** One pass id (`TN-…`). Ignored when `passIds` is given. */
|
|
45
|
+
passId?: string;
|
|
46
|
+
/** Several pass ids — an order is usually more than one ticket. */
|
|
47
|
+
passIds?: string[];
|
|
48
|
+
/**
|
|
49
|
+
* `default` stacks a labelled row per pass. `compact` drops the per-pass label
|
|
50
|
+
* and packs the badges into one wrapping row — for a dense list.
|
|
51
|
+
*/
|
|
52
|
+
variant?: "default" | "compact";
|
|
53
|
+
/** Extra class(es) appended to the root element. */
|
|
54
|
+
className?: string;
|
|
55
|
+
/** Inline style merged LAST into the root element (callers can override). */
|
|
56
|
+
style?: CSSProperties;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The Apple mark. `currentColor` so it inherits the badge's themed label colour. */
|
|
60
|
+
function AppleMark({ size = 15 }: { size?: number }) {
|
|
61
|
+
return (
|
|
62
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
|
|
63
|
+
<path d="M17.05 12.04c-.03-2.9 2.37-4.3 2.48-4.36-1.35-1.98-3.46-2.25-4.21-2.28-1.79-.18-3.5 1.05-4.41 1.05-.91 0-2.31-1.03-3.8-1-1.96.03-3.76 1.14-4.77 2.89-2.03 3.53-.52 8.76 1.46 11.62.97 1.4 2.12 2.97 3.63 2.91 1.46-.06 2.01-.94 3.77-.94 1.76 0 2.26.94 3.8.91 1.57-.03 2.56-1.42 3.52-2.83 1.11-1.62 1.57-3.19 1.6-3.27-.04-.02-3.06-1.17-3.09-4.66zM14.2 4.6c.8-.98 1.34-2.33 1.19-3.68-1.15.05-2.55.77-3.38 1.74-.74.86-1.39 2.24-1.22 3.56 1.29.1 2.6-.65 3.41-1.62z" />
|
|
64
|
+
</svg>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The Google mark, in its official four colours — a monochrome "G" is a brand violation. */
|
|
69
|
+
function GoogleMark({ size = 15 }: { size?: number }) {
|
|
70
|
+
return (
|
|
71
|
+
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
|
72
|
+
<path
|
|
73
|
+
fill="#4285F4"
|
|
74
|
+
d="M23.49 12.27c0-.79-.07-1.54-.19-2.27H12v4.51h6.47a5.53 5.53 0 0 1-2.4 3.63v3.02h3.88c2.27-2.09 3.54-5.17 3.54-8.89z"
|
|
75
|
+
/>
|
|
76
|
+
<path
|
|
77
|
+
fill="#34A853"
|
|
78
|
+
d="M12 24c3.24 0 5.95-1.08 7.93-2.91l-3.88-3.01c-1.08.72-2.45 1.16-4.05 1.16-3.13 0-5.78-2.11-6.73-4.96H1.28v3.09A11.99 11.99 0 0 0 12 24z"
|
|
79
|
+
/>
|
|
80
|
+
<path fill="#FBBC05" d="M5.27 14.28a7.2 7.2 0 0 1 0-4.56V6.63H1.28a12 12 0 0 0 0 10.74l3.99-3.09z" />
|
|
81
|
+
<path
|
|
82
|
+
fill="#EA4335"
|
|
83
|
+
d="M12 4.75c1.77 0 3.35.61 4.6 1.8l3.44-3.44C17.95 1.19 15.24 0 12 0 7.31 0 3.26 2.69 1.28 6.63l3.99 3.09C6.22 6.86 8.87 4.75 12 4.75z"
|
|
84
|
+
/>
|
|
85
|
+
</svg>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The badge lockup, themed.
|
|
91
|
+
*
|
|
92
|
+
* Inverted against the page — `text` as the fill, `background` as the label —
|
|
93
|
+
* which is how both vendors' badges read (a high-contrast pill against the
|
|
94
|
+
* content) while staying entirely inside the theme.
|
|
95
|
+
*/
|
|
96
|
+
function badgeStyle(t: ResolvedThemeTokens, compact: boolean): CSSProperties {
|
|
97
|
+
return {
|
|
98
|
+
display: "inline-flex",
|
|
99
|
+
alignItems: "center",
|
|
100
|
+
gap: 7,
|
|
101
|
+
padding: compact ? "6px 12px" : "8px 14px",
|
|
102
|
+
fontSize: compact ? 12 : 13,
|
|
103
|
+
fontWeight: 600,
|
|
104
|
+
lineHeight: 1.2,
|
|
105
|
+
whiteSpace: "nowrap",
|
|
106
|
+
color: t.background,
|
|
107
|
+
background: t.text,
|
|
108
|
+
border: `1px solid ${t.text}`,
|
|
109
|
+
borderRadius: t.cornerRadius,
|
|
110
|
+
textDecoration: "none",
|
|
111
|
+
cursor: "pointer",
|
|
112
|
+
fontFamily: t.fontFamily,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function WalletPassButtons({ passId, passIds, variant = "default", className, style }: WalletPassButtonsProps) {
|
|
117
|
+
const t = useThemeTokens();
|
|
118
|
+
const { user } = usePublicAuth();
|
|
119
|
+
|
|
120
|
+
// One hook for both prop shapes — a branch here would violate the rules of
|
|
121
|
+
// hooks, and an empty array is exactly the disabled state the query wants.
|
|
122
|
+
const ids = passIds ?? (passId ? [passId] : []);
|
|
123
|
+
const { entries } = useWalletPasses(ids, user?.id);
|
|
124
|
+
const download = useDownloadApplePass();
|
|
125
|
+
|
|
126
|
+
// THE gate. Empty is the production state today, and it means: draw nothing.
|
|
127
|
+
if (entries.length === 0) return null;
|
|
128
|
+
|
|
129
|
+
const compact = variant === "compact";
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
data-testid="wallet-pass-buttons"
|
|
134
|
+
className={className}
|
|
135
|
+
style={{ display: "flex", flexDirection: "column", gap: compact ? 6 : 10, ...style }}
|
|
136
|
+
>
|
|
137
|
+
{entries.map((entry) => (
|
|
138
|
+
<WalletPassRow
|
|
139
|
+
key={entry.passId}
|
|
140
|
+
entry={entry}
|
|
141
|
+
t={t}
|
|
142
|
+
compact={compact}
|
|
143
|
+
showLabel={!compact && entries.length > 1}
|
|
144
|
+
onDownloadApple={(downloadUrl) => {
|
|
145
|
+
// Failure is swallowed on purpose. The buyer's ticket is the PDF and
|
|
146
|
+
// the QR in their portal; a wallet pass is an extra, and an error
|
|
147
|
+
// banner for an extra they never asked about is worse than nothing.
|
|
148
|
+
void download.mutateAsync({ passId: entry.passId, downloadUrl }).catch(() => undefined);
|
|
149
|
+
}}
|
|
150
|
+
isDownloading={download.isPending && download.variables?.passId === entry.passId}
|
|
151
|
+
/>
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function WalletPassRow({
|
|
158
|
+
entry,
|
|
159
|
+
t,
|
|
160
|
+
compact,
|
|
161
|
+
showLabel,
|
|
162
|
+
onDownloadApple,
|
|
163
|
+
isDownloading,
|
|
164
|
+
}: {
|
|
165
|
+
entry: WalletPassEntry;
|
|
166
|
+
t: ResolvedThemeTokens;
|
|
167
|
+
compact: boolean;
|
|
168
|
+
showLabel: boolean;
|
|
169
|
+
onDownloadApple: (downloadUrl: string) => void;
|
|
170
|
+
isDownloading: boolean;
|
|
171
|
+
}) {
|
|
172
|
+
const badge = badgeStyle(t, compact);
|
|
173
|
+
const { apple, google } = entry.status;
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
177
|
+
{showLabel && (
|
|
178
|
+
<span style={{ fontSize: 12, fontWeight: 600, color: t.muted, fontFamily: t.fontFamily }}>{entry.passId}</span>
|
|
179
|
+
)}
|
|
180
|
+
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
|
181
|
+
{apple.available && apple.downloadUrl && (
|
|
182
|
+
<button
|
|
183
|
+
type="button"
|
|
184
|
+
onClick={() => onDownloadApple(apple.downloadUrl!)}
|
|
185
|
+
disabled={isDownloading}
|
|
186
|
+
style={{ ...badge, cursor: isDownloading ? "wait" : "pointer" }}
|
|
187
|
+
aria-label={`Add ticket ${entry.passId} to Apple Wallet`}
|
|
188
|
+
>
|
|
189
|
+
<AppleMark size={compact ? 13 : 15} />
|
|
190
|
+
{isDownloading ? "Preparing…" : "Add to Apple Wallet"}
|
|
191
|
+
</button>
|
|
192
|
+
)}
|
|
193
|
+
{google.saveUrl && (
|
|
194
|
+
<a
|
|
195
|
+
href={google.saveUrl}
|
|
196
|
+
target="_blank"
|
|
197
|
+
rel="noopener noreferrer"
|
|
198
|
+
style={badge}
|
|
199
|
+
aria-label={`Save ticket ${entry.passId} to Google Wallet`}
|
|
200
|
+
>
|
|
201
|
+
<GoogleMark size={compact ? 13 : 15} />
|
|
202
|
+
Save to Google Wallet
|
|
203
|
+
</a>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|