@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.
@@ -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
+ }