@tribe-nest/forge 3.20.0 → 3.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
  3. package/src/data/queries/useAccountSettings.ts +15 -2
  4. package/src/data/queries/useAuthActions.ts +51 -7
  5. package/src/data/queries/useEvents.ts +66 -4
  6. package/src/data/queries/useMembership.ts +8 -2
  7. package/src/data/queries/useMyBookings.ts +12 -0
  8. package/src/data/queries/useMyTickets.ts +112 -0
  9. package/src/data/queries/useOrders.ts +10 -0
  10. package/src/data/queries/usePassTransfers.ts +73 -13
  11. package/src/server/index.ts +52 -0
  12. package/src/types/models.ts +151 -0
  13. package/src/ui/format/_tests/attendees.spec.ts +231 -0
  14. package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
  15. package/src/ui/format/attendees.ts +187 -0
  16. package/src/ui/format/membershipGate.ts +209 -0
  17. package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
  18. package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
  19. package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
  20. package/src/ui/headless/checkout/inventoryHold.ts +83 -0
  21. package/src/ui/headless/checkout/useCheckout.ts +72 -0
  22. package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
  23. package/src/ui/headless/event/useEventCheckout.ts +133 -2
  24. package/src/ui/headless/event/usePresaleCode.ts +181 -0
  25. package/src/ui/headless/index.ts +25 -0
  26. package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
  27. package/src/ui/headless/offer/OfferContext.tsx +55 -0
  28. package/src/ui/index.ts +42 -0
  29. package/src/ui/styled/AccountDashboard.tsx +70 -8
  30. package/src/ui/styled/AddToCalendar.tsx +34 -10
  31. package/src/ui/styled/Checkout.tsx +18 -1
  32. package/src/ui/styled/CoachingConfirmation.tsx +4 -0
  33. package/src/ui/styled/CourseDetail.tsx +30 -1
  34. package/src/ui/styled/EventConfirmation.tsx +2 -0
  35. package/src/ui/styled/EventDetail.tsx +53 -22
  36. package/src/ui/styled/EventTickets.tsx +156 -5
  37. package/src/ui/styled/HoldNotice.tsx +192 -0
  38. package/src/ui/styled/MembershipGateNotice.tsx +159 -0
  39. package/src/ui/styled/OfferButton.tsx +23 -0
  40. package/src/ui/styled/PresaleCode.tsx +174 -0
  41. package/src/ui/styled/ProductDetail.tsx +75 -5
  42. package/src/ui/styled/ProductGrid.tsx +26 -0
  43. package/src/ui/styled/TicketTransfer.tsx +69 -40
  44. package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
  45. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
  46. package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
  47. package/src/utils/_tests/presaleCode.spec.ts +168 -0
  48. package/src/utils/_tests/structuredData.spec.ts +275 -0
  49. package/src/utils/presaleCode.ts +96 -0
  50. package/src/utils/structuredData.ts +361 -27
@@ -0,0 +1,174 @@
1
+ import type { CSSProperties } from "react";
2
+ import { KeyRound } from "lucide-react";
3
+ import type { PresaleCodeField } from "../headless/event/usePresaleCode";
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 PresaleCodeFieldProps {
11
+ presale: PresaleCodeField;
12
+ /** Collapsed-state prompt. */
13
+ promptText?: string;
14
+ /** Extra class(es) on the root. */
15
+ className?: string;
16
+ /** Inline style merged LAST (callers can override). */
17
+ style?: CSSProperties;
18
+ }
19
+
20
+ /**
21
+ * The box a buyer types a presale code into (Events 1.2).
22
+ *
23
+ * **Renders nothing at all unless the event actually has a coded tier.** That
24
+ * is the single most important thing about it: a code box on an ordinary event
25
+ * tells the buyer there is a secret they are missing, and they have no way to
26
+ * find out there isn't. `presale.visible` comes from the server, because a
27
+ * hidden tier is filtered out of the response and no client can tell a presale
28
+ * apart from an event that has not put tickets on sale yet.
29
+ *
30
+ * Three states, three different jobs:
31
+ *
32
+ * - **Idle** — an inline prompt. Small, because most visitors have no code and
33
+ * should not feel gated.
34
+ * - **Accepted** — confirms it worked, with a way out. Without this, a code for
35
+ * a tier that was already VISIBLE (code-gated but listed) would appear to do
36
+ * nothing at all.
37
+ * - **Rejected** — says the code is not valid for this event and stops. It
38
+ * names no tier and confirms no partial guess; a box that said "that isn't
39
+ * the VIP code" would be a way to enumerate them.
40
+ */
41
+ export function PresaleCodeField({
42
+ presale,
43
+ promptText = "Have a presale code?",
44
+ className,
45
+ style,
46
+ }: PresaleCodeFieldProps) {
47
+ const theme = useForgeTheme();
48
+ if (!presale.visible) return null;
49
+
50
+ const isChecking = presale.status === "checking";
51
+ const isRejected = presale.status === "rejected";
52
+
53
+ if (presale.isUnlocked) {
54
+ return (
55
+ <div
56
+ data-testid="presale-code-applied"
57
+ className={className}
58
+ style={{
59
+ display: "flex",
60
+ justifyContent: "space-between",
61
+ alignItems: "center",
62
+ gap: 8,
63
+ padding: 12,
64
+ borderRadius: theme.cornerRadius,
65
+ background: a(theme.colors.primary, 0.1),
66
+ fontFamily: theme.fontFamily,
67
+ ...style,
68
+ }}
69
+ >
70
+ <span
71
+ style={{
72
+ display: "inline-flex",
73
+ alignItems: "center",
74
+ gap: 6,
75
+ fontSize: 14,
76
+ fontWeight: 600,
77
+ color: theme.colors.primary,
78
+ }}
79
+ >
80
+ <KeyRound size={14} />
81
+ Presale unlocked — {presale.code}
82
+ </span>
83
+ <button
84
+ type="button"
85
+ data-testid="presale-code-remove"
86
+ onClick={presale.clear}
87
+ style={{
88
+ background: "transparent",
89
+ border: "none",
90
+ padding: 0,
91
+ fontSize: 13,
92
+ textDecoration: "underline",
93
+ cursor: "pointer",
94
+ color: theme.colors.text,
95
+ fontFamily: theme.fontFamily,
96
+ }}
97
+ >
98
+ Remove
99
+ </button>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ return (
105
+ <div
106
+ data-testid="presale-code"
107
+ className={className}
108
+ style={{ display: "flex", flexDirection: "column", gap: 8, fontFamily: theme.fontFamily, ...style }}
109
+ >
110
+ <label
111
+ htmlFor="presale-code-input"
112
+ style={{ fontSize: 14, fontWeight: 600, display: "inline-flex", alignItems: "center", gap: 6 }}
113
+ >
114
+ <KeyRound size={14} />
115
+ {promptText}
116
+ </label>
117
+ <div style={{ display: "flex", gap: 8, alignItems: "flex-start" }}>
118
+ <input
119
+ id="presale-code-input"
120
+ data-testid="presale-code-input"
121
+ placeholder="Enter code"
122
+ value={presale.input}
123
+ onChange={(e) => presale.setInput(e.target.value.toUpperCase())}
124
+ // Enter is what a buyer does after typing a code into a one-field
125
+ // form; without this it either does nothing or submits the checkout
126
+ // form this box sits inside.
127
+ onKeyDown={(e) => {
128
+ if (e.key !== "Enter") return;
129
+ e.preventDefault();
130
+ presale.submit();
131
+ }}
132
+ aria-invalid={isRejected || undefined}
133
+ aria-describedby={presale.message ? "presale-code-message" : undefined}
134
+ style={{ ...inputStyle(theme), flex: 1 }}
135
+ />
136
+ <button
137
+ type="button"
138
+ data-testid="presale-code-apply"
139
+ onClick={presale.submit}
140
+ disabled={isChecking || !presale.input.trim()}
141
+ style={{
142
+ ...buttonStyle(theme, { variant: "primary" }),
143
+ opacity: isChecking || !presale.input.trim() ? 0.6 : 1,
144
+ }}
145
+ >
146
+ {isChecking ? "Checking…" : "Unlock"}
147
+ </button>
148
+ </div>
149
+ {presale.message && (
150
+ <p
151
+ id="presale-code-message"
152
+ data-testid="presale-code-message"
153
+ role={isRejected ? "alert" : undefined}
154
+ // Refusal is the one place a non-theme colour is right: red is the
155
+ // convention for "this input is wrong", and the artist's primary
156
+ // could be any hue including a green.
157
+ style={{ color: isRejected ? "#ef4444" : theme.colors.primary, fontSize: 13, margin: 0 }}
158
+ >
159
+ {presale.message}
160
+ </p>
161
+ )}
162
+ </div>
163
+ );
164
+ }
165
+
166
+ const inputStyle = (t: ForgeTheme): CSSProperties => ({
167
+ width: "100%",
168
+ padding: 10,
169
+ border: `1px solid ${a(t.colors.primary, 0.25)}`,
170
+ borderRadius: t.cornerRadius,
171
+ background: t.colors.background,
172
+ color: t.colors.text,
173
+ fontFamily: t.fontFamily,
174
+ });
@@ -6,6 +6,7 @@ import { useVariantSelection } from "../headless/useVariantSelection";
6
6
  import { useGetProduct } from "../../data/queries/useProducts";
7
7
  import { useCart } from "../../contexts/CartContext";
8
8
  import { useAudioPlayer } from "../../contexts/AudioPlayerContext";
9
+ import { MembershipGateNotice } from "./MembershipGateNotice";
9
10
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
10
11
  import { readableTextOn } from "../theme/contrast";
11
12
  import { useAmountFormatter } from "../format/useFormatCurrency";
@@ -35,6 +36,10 @@ export interface ProductDetailProps {
35
36
  */
36
37
  hrefForCategory?: (category: { id: string; slug: string; title: string }) => string;
37
38
  hrefForCollection?: (collection: { id: string; slug: string; title: string }) => string;
39
+ /** Where a members-only product sends a signed-OUT visitor. Default `/login`. */
40
+ loginPath?: string;
41
+ /** Where a members-only product sends a signed-in NON-member. Default `/membership`. */
42
+ membershipPath?: string;
38
43
  }
39
44
 
40
45
  const a = (hex: string, al: number) => hex + Math.round(al * 255).toString(16).padStart(2, "0");
@@ -55,6 +60,8 @@ export function ProductDetail({
55
60
  initialProduct,
56
61
  hrefForCategory,
57
62
  hrefForCollection,
63
+ loginPath,
64
+ membershipPath,
58
65
  }: ProductDetailProps) {
59
66
  const t = useThemeTokens();
60
67
  const { data: product, isLoading } = useGetProduct(slug, { initialData: initialProduct });
@@ -68,7 +75,15 @@ export function ProductDetail({
68
75
  // music (audio player over a PDF, etc.).
69
76
  const isMusic = product.productType === ProductType.Music;
70
77
  return isMusic ? (
71
- <MusicDetail product={product} fmt={fmt} checkoutPath={checkoutPath} className={className} style={style} />
78
+ <MusicDetail
79
+ product={product}
80
+ fmt={fmt}
81
+ checkoutPath={checkoutPath}
82
+ className={className}
83
+ style={style}
84
+ loginPath={loginPath}
85
+ membershipPath={membershipPath}
86
+ />
72
87
  ) : (
73
88
  <StandardDetail
74
89
  product={product}
@@ -78,6 +93,8 @@ export function ProductDetail({
78
93
  style={style}
79
94
  hrefForCategory={hrefForCategory}
80
95
  hrefForCollection={hrefForCollection}
96
+ loginPath={loginPath}
97
+ membershipPath={membershipPath}
81
98
  />
82
99
  );
83
100
  }
@@ -141,6 +158,8 @@ function StandardDetail({
141
158
  style,
142
159
  hrefForCategory,
143
160
  hrefForCollection,
161
+ loginPath,
162
+ membershipPath,
144
163
  }: {
145
164
  product: IPublicProduct;
146
165
  fmt: (n: number) => string;
@@ -149,6 +168,8 @@ function StandardDetail({
149
168
  style?: React.CSSProperties;
150
169
  hrefForCategory?: (category: { id: string; slug: string; title: string }) => string;
151
170
  hrefForCollection?: (collection: { id: string; slug: string; title: string }) => string;
171
+ loginPath?: string;
172
+ membershipPath?: string;
152
173
  }) {
153
174
  const t = useThemeTokens();
154
175
  // Inclusive stores caption the price as tax-inclusive (display only).
@@ -187,8 +208,24 @@ function StandardDetail({
187
208
  }, []);
188
209
 
189
210
  const outOfStock = !!selectedVariant && selectedVariant.availabilityStatus !== "active";
190
- const canAdd = !!selectedVariant && !outOfStock;
191
- const label = !selectedVariant ? "Select options" : outOfStock ? "Out of stock" : "Add to cart";
211
+ /**
212
+ * A members-only product the buyer cannot have (S.4).
213
+ *
214
+ * The product is LISTED rather than hidden — "Gold members get first refusal
215
+ * on the vinyl" only works as an offer if non-members can see it — so the page
216
+ * has to name the restriction and offer the way through. Without this the
217
+ * buyer adds it to a cart, fills in checkout, and `createOrder` refuses them
218
+ * at the end.
219
+ */
220
+ const isGated = !!product.membershipGate && !product.membershipGate.allowed;
221
+ const canAdd = !!selectedVariant && !outOfStock && !isGated;
222
+ const label = isGated
223
+ ? "Members only"
224
+ : !selectedVariant
225
+ ? "Select options"
226
+ : outOfStock
227
+ ? "Out of stock"
228
+ : "Add to cart";
192
229
  const addToCart = () => {
193
230
  if (!canAdd || !selectedVariant) return;
194
231
  add(selectedVariant, { quantity, coverImage: images[imageIndex]?.url ?? images[0]?.url });
@@ -338,6 +375,17 @@ function StandardDetail({
338
375
  <AddButton innerRef={addRef} />
339
376
  </div>
340
377
 
378
+ {/* Directly under the disabled button, because "Members only" on a
379
+ dead button is a locked door; this is the key beside it. */}
380
+ <MembershipGateNotice
381
+ gate={product.membershipGate}
382
+ variant="card"
383
+ itemLabel="This product"
384
+ loginPath={loginPath}
385
+ membershipPath={membershipPath}
386
+ style={{ marginTop: 12 }}
387
+ />
388
+
341
389
  <div style={{ margin: "16px 0", opacity: 0.9 }}>
342
390
  <RichText html={product.description || ""} />
343
391
  </div>
@@ -453,12 +501,16 @@ function MusicDetail({
453
501
  checkoutPath,
454
502
  className,
455
503
  style,
504
+ loginPath,
505
+ membershipPath,
456
506
  }: {
457
507
  product: IPublicProduct;
458
508
  fmt: (n: number) => string;
459
509
  checkoutPath: string;
460
510
  className?: string;
461
511
  style?: React.CSSProperties;
512
+ loginPath?: string;
513
+ membershipPath?: string;
462
514
  }) {
463
515
  const t = useThemeTokens();
464
516
  // Inclusive stores caption the price as tax-inclusive (display only).
@@ -470,6 +522,8 @@ function MusicDetail({
470
522
  // exactly what this used to: the default variant.
471
523
  const { axes, selection, select, selectedVariant } = useVariantSelection(product.variants);
472
524
  const defaultVariant = selectedVariant ?? product.variants.find((v) => v.isDefault) ?? product.variants[0];
525
+ /** Members-only release (S.4) — announced, so the buy buttons must say so. */
526
+ const isGated = !!product.membershipGate && !product.membershipGate.allowed;
473
527
  // The TRACKLIST is the release's contents and does not depend on which
474
528
  // version is selected — every version of an album has the same songs.
475
529
  const tracks = defaultVariant?.tracks ?? [];
@@ -604,11 +658,27 @@ function MusicDetail({
604
658
  ))}
605
659
 
606
660
  <div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginTop: 4 }}>
607
- <Button onClick={() => buyNow(defaultVariant, { canIncreaseQuantity: false })}>Buy now</Button>
608
- <Button variant="outline" onClick={() => add(defaultVariant, { canIncreaseQuantity: false })}>
661
+ <Button disabled={isGated} onClick={() => buyNow(defaultVariant, { canIncreaseQuantity: false })}>
662
+ Buy now
663
+ </Button>
664
+ <Button
665
+ variant="outline"
666
+ disabled={isGated}
667
+ onClick={() => add(defaultVariant, { canIncreaseQuantity: false })}
668
+ >
609
669
  Add to cart
610
670
  </Button>
611
671
  </div>
672
+ {/* A gated release is still listed and still PLAYABLE — the
673
+ preview is the upsell. What changes is that the buy buttons say
674
+ why they are dead and where to go. */}
675
+ <MembershipGateNotice
676
+ gate={product.membershipGate}
677
+ variant="badge"
678
+ itemLabel="This release"
679
+ loginPath={loginPath}
680
+ membershipPath={membershipPath}
681
+ />
612
682
  </div>
613
683
  </div>
614
684
  </div>
@@ -1,3 +1,4 @@
1
+ import { Lock } from "lucide-react";
1
2
  import { useGetProducts, useGetProductsByIds } from "../../data/queries/useProducts";
2
3
  import type { IPublicProduct, ProductType } from "../../types/models";
3
4
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
@@ -120,6 +121,31 @@ export function ProductGrid({
120
121
  </div>
121
122
  <div style={{ padding: 10 }}>
122
123
  <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 2 }}>{p.title}</div>
124
+ {/* The listing announces the gate rather than hiding the row —
125
+ hiding it would kill the very upsell a members-only product
126
+ exists for. A card is too small for the full prompt, so it
127
+ carries the label and the detail page carries the action. */}
128
+ {p.membershipGate && !p.membershipGate.allowed && (
129
+ <div
130
+ data-testid="membership-gate-card-badge"
131
+ style={{
132
+ display: "inline-flex",
133
+ alignItems: "center",
134
+ gap: 4,
135
+ padding: "1px 6px",
136
+ marginBottom: 4,
137
+ borderRadius: 6,
138
+ fontSize: 11,
139
+ fontWeight: 700,
140
+ background: `${t.primary}1a`,
141
+ color: t.primary,
142
+ border: `1px solid ${t.primary}4d`,
143
+ }}
144
+ >
145
+ <Lock size={10} aria-hidden />
146
+ Members only
147
+ </div>
148
+ )}
123
149
  <div style={{ color: t.primary, fontWeight: 600, fontSize: 14 }}>
124
150
  <PriceDisplay amount={minPrice(p)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
125
151
  </div>
@@ -4,8 +4,10 @@ import {
4
4
  useCancelPassTransfer,
5
5
  useClaimPassTransfer,
6
6
  usePendingPassTransfers,
7
+ mergePendingTransfer,
7
8
  type PassTransfer,
8
9
  } from "../../data/queries/usePassTransfers";
10
+ import type { MyTicketPass, MyTicketPendingTransfer } from "../../data/queries/useMyTickets";
9
11
  import { useThemeTokens, type ResolvedThemeTokens } from "../theme/ForgeThemeProvider";
10
12
 
11
13
  /**
@@ -89,8 +91,24 @@ const formatWhen = (value: string | null | undefined): string | null => {
89
91
  // ── The holder's side ───────────────────────────────────────────────────────
90
92
 
91
93
  export interface TicketTransferPanelProps {
92
- /** The `TN-…` pass ids on this order — `myTicketPassIds(ticket)`. */
94
+ /**
95
+ * The `TN-…` passes to draw a control for.
96
+ *
97
+ * Pass `myTicketHeldPassIds(ticket)`, not `myTicketPassIds(ticket)`: every
98
+ * pass-scoped endpoint resolves the current HOLDER, so a row drawn on a
99
+ * named guest's seat or on a ticket already claimed by somebody else could
100
+ * only ever answer 404.
101
+ */
93
102
  passIds: string[];
103
+ /**
104
+ * The pass OBJECTS, when the caller has them — `myTicketPasses(ticket)`.
105
+ *
106
+ * Only used to read each pass's `pendingTransfer`, which is how a sender sees
107
+ * and withdraws a transfer they started on ANOTHER DEVICE. Optional so a host
108
+ * on an older API build still gets the panel, falling back to this browser's
109
+ * own local record.
110
+ */
111
+ passes?: MyTicketPass[];
94
112
  /** Extra class(es) on the root element. */
95
113
  className?: string;
96
114
  /** Inline style merged LAST into the root element. */
@@ -105,19 +123,21 @@ export interface TicketTransferPanelProps {
105
123
  * does not emit them cannot address any pass-scoped endpoint, so an affordance
106
124
  * would only ever 404.
107
125
  *
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.
126
+ * The pending transfer comes from the SERVER `pendingTransfer` on each pass,
127
+ * narrowed by the API to a row this reader sent and still `pending`. That is
128
+ * what makes "withdraw" work for a sender who has since signed in somewhere
129
+ * else; the transfer HISTORY endpoint stays operator-only by design, because it
130
+ * names every previous holder. This browser's local record is kept only as a
131
+ * bridge across the refetch that follows a send.
113
132
  */
114
- export function TicketTransferPanel({ passIds, className, style }: TicketTransferPanelProps) {
133
+ export function TicketTransferPanel({ passIds, passes, className, style }: TicketTransferPanelProps) {
115
134
  const t = useThemeTokens();
116
135
  const [openFor, setOpenFor] = useState<string | null>(null);
117
136
 
118
137
  if (passIds.length === 0) return null;
119
138
 
120
139
  const single = passIds.length === 1;
140
+ const byId = new Map((passes ?? []).map((pass) => [pass.id, pass]));
121
141
 
122
142
  return (
123
143
  <div
@@ -129,6 +149,7 @@ export function TicketTransferPanel({ passIds, className, style }: TicketTransfe
129
149
  <TicketTransferRow
130
150
  key={passId}
131
151
  passId={passId}
152
+ serverTransfer={byId.get(passId)?.pendingTransfer ?? null}
132
153
  t={t}
133
154
  showPassId={!single}
134
155
  isOpen={openFor === passId}
@@ -141,12 +162,14 @@ export function TicketTransferPanel({ passIds, className, style }: TicketTransfe
141
162
 
142
163
  function TicketTransferRow({
143
164
  passId,
165
+ serverTransfer,
144
166
  t,
145
167
  showPassId,
146
168
  isOpen,
147
169
  onToggle,
148
170
  }: {
149
171
  passId: string;
172
+ serverTransfer: MyTicketPendingTransfer | null;
150
173
  t: ResolvedThemeTokens;
151
174
  showPassId: boolean;
152
175
  isOpen: boolean;
@@ -154,7 +177,10 @@ function TicketTransferRow({
154
177
  }) {
155
178
  const send = useSendPassTransfer();
156
179
  const cancel = useCancelPassTransfer();
157
- const pending = usePendingPassTransfers(passId);
180
+ const remembered = usePendingPassTransfers(passId);
181
+ // One live transfer per pass is a DATABASE guarantee, so exactly one row may
182
+ // ever be drawn. The server's copy wins whenever it has one.
183
+ const pending = mergePendingTransfer(serverTransfer, remembered);
158
184
 
159
185
  const [toEmail, setToEmail] = useState("");
160
186
  const [toName, setToName] = useState("");
@@ -207,39 +233,42 @@ function TicketTransferRow({
207
233
  </button>
208
234
  </div>
209
235
 
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>
236
+ {/* The live transfer out on this pass, from whichever device sent it. */}
237
+ {pending && (
238
+ <div
239
+ data-testid="pending-pass-transfer"
240
+ style={{
241
+ marginTop: 12,
242
+ padding: 12,
243
+ borderRadius: t.cornerRadius,
244
+ border: `1px solid ${t.primary}4d`,
245
+ fontSize: 14,
246
+ }}
247
+ >
248
+ <div>
249
+ Sent to <strong>{pending.toEmail}</strong>
250
+ {formatWhen(pending.expiresAt) && pending.open
251
+ ? ` — the link works until ${formatWhen(pending.expiresAt)}.`
252
+ : "."}
253
+ </div>
254
+ <div style={{ fontSize: 13, color: t.muted, marginTop: 4 }}>
255
+ {pending.open
256
+ ? "Your QR code keeps working until they claim it."
257
+ : /* Lapsed but not yet swept. Still `pending`, still withdrawable —
258
+ hiding it would leave the sender with a live row and no way to
259
+ address it. */
260
+ "That link has expired. Withdraw it to send this ticket to someone else."}
240
261
  </div>
241
- );
242
- })}
262
+ <button
263
+ type="button"
264
+ onClick={() => void onCancel(pending.transferId)}
265
+ disabled={cancel.isPending}
266
+ style={{ ...ghostButton(t, cancel.isPending), marginTop: 10 }}
267
+ >
268
+ {cancel.isPending ? "Withdrawing…" : "Withdraw transfer"}
269
+ </button>
270
+ </div>
271
+ )}
243
272
 
244
273
  {sent && (
245
274
  <p style={{ fontSize: 13, marginTop: 10 }}>
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
4
+ import { AddToCalendar } from "../AddToCalendar";
5
+
6
+ /**
7
+ * Rendered through `react-dom/server`, which needs no DOM — same approach as
8
+ * `EventConfirmation.spec.tsx`.
9
+ *
10
+ * What this guards is the anchor ATTRIBUTES, because they are what actually
11
+ * decides whether an iPhone opens Calendar. A `webcal:` link with
12
+ * `target="_blank"` leaves a blank tab behind and, on several browsers, never
13
+ * reaches the OS handler at all; one with `download` asks to save a file from a
14
+ * scheme that has no file. Both are easy to add back by reflex, and neither
15
+ * failure is visible from a desktop.
16
+ */
17
+
18
+ const THEME = {
19
+ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" },
20
+ cornerRadius: 8,
21
+ };
22
+
23
+ const render = (node: React.ReactNode) =>
24
+ renderToStaticMarkup(<ForgeThemeProvider theme={THEME}>{node}</ForgeThemeProvider>);
25
+
26
+ const ICS = "https://api.tribenest.co/public/calendar/events/abc/tok.ics";
27
+ const WEBCAL = "webcal://api.tribenest.co/public/calendar/events/abc/tok.ics";
28
+
29
+ const base = { title: "Night Show", start: "2026-03-15T20:00:00.000Z" };
30
+
31
+ describe("<AddToCalendar>", () => {
32
+ it("draws only the three template providers when no .ics is supplied", () => {
33
+ const html = render(<AddToCalendar {...base} />);
34
+
35
+ expect(html).toContain("add-to-calendar-google");
36
+ expect(html).toContain("add-to-calendar-outlook");
37
+ expect(html).toContain("add-to-calendar-office365");
38
+ // The pre-existing behaviour, preserved for a site on an older API build:
39
+ // no Apple chip rather than a dead one.
40
+ expect(html).not.toContain("add-to-calendar-apple");
41
+ expect(html).not.toContain("add-to-calendar-ics");
42
+ });
43
+
44
+ it("adds Apple Calendar and a download once the host has the address", () => {
45
+ const html = render(<AddToCalendar {...base} icsUrl={ICS} webcalUrl={WEBCAL} />);
46
+
47
+ expect(html).toContain("add-to-calendar-apple");
48
+ expect(html).toContain(`href="${WEBCAL}"`);
49
+ expect(html).toContain("Apple Calendar");
50
+
51
+ expect(html).toContain("add-to-calendar-ics");
52
+ expect(html).toContain("Download .ics");
53
+ });
54
+
55
+ it("the Apple anchor is a plain same-window navigation", () => {
56
+ const html = render(<AddToCalendar {...base} icsUrl={ICS} webcalUrl={WEBCAL} />);
57
+
58
+ // Isolate the Apple anchor so the assertions cannot be satisfied by the
59
+ // Google one sitting next to it.
60
+ const apple = html.slice(html.indexOf('data-testid="add-to-calendar-apple"'));
61
+ const anchor = apple.slice(0, apple.indexOf(">"));
62
+
63
+ expect(anchor).not.toContain('target="_blank"');
64
+ expect(anchor).not.toContain("download");
65
+ });
66
+
67
+ it("the .ics anchor DOES ask to download", () => {
68
+ const html = render(<AddToCalendar {...base} icsUrl={ICS} webcalUrl={WEBCAL} />);
69
+ const ics = html.slice(html.indexOf('data-testid="add-to-calendar-ics"'));
70
+ expect(ics.slice(0, ics.indexOf(">"))).toContain("download");
71
+ });
72
+
73
+ it("still offers Apple when only the https form is passed", () => {
74
+ // The webcal address is derived; a host that only forwards `calendarUrl`
75
+ // must not lose the one option iOS can use.
76
+ const html = render(<AddToCalendar {...base} icsUrl={ICS} />);
77
+ expect(html).toContain(`href="${WEBCAL}"`);
78
+ });
79
+
80
+ it("renders nothing at all for an unusable start time", () => {
81
+ // The theme provider still emits its `:root` variables, so the assertion is
82
+ // that no ANCHOR was drawn — a chip pointing at a garbage prefill is worse
83
+ // than no chip.
84
+ const html = render(<AddToCalendar title="X" start="not-a-date" icsUrl={ICS} webcalUrl={WEBCAL} />);
85
+ expect(html).not.toContain("<a ");
86
+ expect(html).not.toContain("add-to-calendar");
87
+ });
88
+ });
@@ -38,7 +38,11 @@ const baseOrder: ITicketOrder = {
38
38
  function render(order: ITicketOrder) {
39
39
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
40
40
  queryClient.setQueryData(["eventTicketOrderStatus", PROFILE_ID, ORDER_ID], order);
41
- queryClient.setQueryData(["event", EVENT_ID, PROFILE_ID], {
41
+ // Four-element key: `useEvent` gained a presale `accessCode` segment, which
42
+ // is `null` for an ordinary read. Seeding the old three-element key left
43
+ // `event` undefined, which silently dropped the add-to-calendar block — the
44
+ // assertion below is what caught it.
45
+ queryClient.setQueryData(["event", EVENT_ID, PROFILE_ID, null], {
42
46
  id: EVENT_ID,
43
47
  title: "Midnight Set",
44
48
  description: "A set",