@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.20.0",
3
+ "version": "3.22.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -3,18 +3,19 @@ import {
3
3
  forgetPassTransfer,
4
4
  getRememberedPassTransfers,
5
5
  isPendingPassTransfer,
6
+ mergePendingTransfer,
6
7
  rememberPassTransfer,
7
8
  } from "../usePassTransfers";
8
- import { myTicketPassIds } from "../useMyTickets";
9
+ import { myTicketHeldPassIds, myTicketPassIds, type MyTicket } from "../useMyTickets";
9
10
 
10
11
  /**
11
12
  * Events 2.2 — the pure decisions behind the buyer-facing transfer surface.
12
13
  *
13
14
  * Only the total functions are exercised; the hooks themselves are thin
14
15
  * transport whose behaviour belongs to the API's own specs. The local store is
15
- * the one thing here with real logic, and it exists because there is NO
16
- * buyer-readable list endpoint the transfer history is operator-only so a
17
- * bug in it is a pending transfer the sender can never withdraw.
16
+ * the one thing here with real logic; it used to be the ONLY record a buyer had
17
+ * of their own send, and is now demoted to a bridge across the refetch that
18
+ * follows one, with `my-tickets`' `pendingTransfer` as the source of truth.
18
19
  */
19
20
 
20
21
  const clear = () => {
@@ -87,3 +88,98 @@ describe("the ids a transfer can be addressed by", () => {
87
88
  expect(myTicketPassIds({ items: [{ id: "i", quantity: 1, price: 10, ticketTitle: null }] })).toEqual([]);
88
89
  });
89
90
  });
91
+
92
+
93
+ /**
94
+ * One live transfer per pass is a DATABASE guarantee (a partial unique index),
95
+ * so exactly one row may ever be drawn. Two would be a lie about the ticket's
96
+ * state and would offer two "withdraw" buttons for one thing.
97
+ */
98
+ describe("mergePendingTransfer", () => {
99
+ const remembered = {
100
+ passId: "TN-1",
101
+ transferId: "local-1",
102
+ toEmail: "local@example.com",
103
+ expiresAt: "2026-04-01T00:00:00.000Z",
104
+ };
105
+
106
+ const fromServer = {
107
+ id: "server-1",
108
+ status: "pending",
109
+ toName: null,
110
+ toEmail: "server@example.com",
111
+ expiresAt: "2026-04-02T00:00:00.000Z",
112
+ createdAt: "2026-03-30T00:00:00.000Z",
113
+ window: { open: true, secondsRemaining: 3600, expiresAt: "2026-04-02T00:00:00.000Z" },
114
+ };
115
+
116
+ it("is null when nothing is in flight anywhere", () => {
117
+ expect(mergePendingTransfer(null, [])).toBeNull();
118
+ });
119
+
120
+ it("THE POINT: the server's row is what a second device sees", () => {
121
+ // `localStorage` is empty here — this browser never sent anything. Before
122
+ // the API carried `pendingTransfer` this returned nothing, which is why a
123
+ // sender on another device had no transfer to withdraw.
124
+ const merged = mergePendingTransfer(fromServer, [])!;
125
+
126
+ expect(merged.transferId).toBe("server-1");
127
+ expect(merged.toEmail).toBe("server@example.com");
128
+ expect(merged.fromServer).toBe(true);
129
+ expect(merged.open).toBe(true);
130
+ });
131
+
132
+ it("the server WINS over a stale local record, rather than drawing both", () => {
133
+ const merged = mergePendingTransfer(fromServer, [remembered])!;
134
+ expect(merged.transferId).toBe("server-1");
135
+ expect(merged.fromServer).toBe(true);
136
+ });
137
+
138
+ it("falls back to the local bridge before the refetch lands", () => {
139
+ const merged = mergePendingTransfer(null, [remembered])!;
140
+ expect(merged.transferId).toBe("local-1");
141
+ expect(merged.fromServer).toBe(false);
142
+ // Optimistic: a just-sent transfer is live by construction, and the server
143
+ // corrects it on the very next read.
144
+ expect(merged.open).toBe(true);
145
+ });
146
+
147
+ it("reports a lapsed-but-unswept transfer as closed, and still withdrawable", () => {
148
+ const lapsed = { ...fromServer, window: { open: false, secondsRemaining: 0, expiresAt: fromServer.expiresAt } };
149
+ const merged = mergePendingTransfer(lapsed, [])!;
150
+
151
+ // Hiding it would put the sender back where they started: a live row they
152
+ // cannot address. The row stays, the copy changes.
153
+ expect(merged.open).toBe(false);
154
+ expect(merged.transferId).toBe("server-1");
155
+ });
156
+ });
157
+
158
+ /**
159
+ * Which passes may an affordance be drawn on?
160
+ *
161
+ * Every pass-scoped endpoint — transfer, wallet, QR — resolves the current
162
+ * HOLDER, and a purchaser sees passes they do not hold: named guests at
163
+ * checkout, and (until the read was fixed) tickets already claimed by somebody
164
+ * else. A control on one of those could only ever answer 404.
165
+ */
166
+ describe("myTicketHeldPassIds", () => {
167
+ const ticket = (passes: { id: string; isHolder?: boolean }[]) =>
168
+ ({ items: [{ id: "item-1", quantity: passes.length, price: 10, ticketTitle: "GA", passes }] }) as unknown as MyTicket;
169
+
170
+ it("keeps only the passes the caller holds", () => {
171
+ const t = ticket([
172
+ { id: "TN-1", isHolder: true },
173
+ { id: "TN-2", isHolder: false },
174
+ ]);
175
+
176
+ expect(myTicketHeldPassIds(t)).toEqual(["TN-1"]);
177
+ // The unfiltered helper still reports everything on the order — the wallet
178
+ // and transfer controls are the things that need narrowing, not the list.
179
+ expect(myTicketPassIds(t)).toEqual(["TN-1", "TN-2"]);
180
+ });
181
+
182
+ it("treats an ABSENT flag as held, so an older API build is unchanged", () => {
183
+ expect(myTicketHeldPassIds(ticket([{ id: "TN-9" }]))).toEqual(["TN-9"]);
184
+ });
185
+ });
@@ -69,13 +69,26 @@ export function useUpdateAccount() {
69
69
  });
70
70
  }
71
71
 
72
- /** Change the member's password. */
72
+ /**
73
+ * Change the member's password.
74
+ *
75
+ * Tenant-scoped (`/public/sessions/password`), because that is where a fan's
76
+ * credential lives — see the note at the top of `useAuthActions.ts`. The global
77
+ * `/public/accounts/password` this used to call wrote a hash that login never
78
+ * reads: it returned 200, invalidated the session, and left the old password
79
+ * working.
80
+ *
81
+ * Requires an association-scoped session (the endpoint is behind
82
+ * `requireAssociation`). Every current login path mints one; a member still
83
+ * holding a pre-cutover account-scoped token gets a 401 and needs to sign in
84
+ * again — honest, where the old call was silently a no-op.
85
+ */
73
86
  export function useChangePassword() {
74
87
  const { client } = useForge();
75
88
 
76
89
  return useMutation<unknown, unknown, { currentPassword: string; newPassword: string }>({
77
90
  mutationFn: async (body) => {
78
- const res = await client.put("/public/accounts/password", body);
91
+ const res = await client.put("/public/sessions/password", body);
79
92
  return res.data;
80
93
  },
81
94
  });
@@ -2,26 +2,70 @@ import { useForge } from "../../provider/ForgeProvider";
2
2
  import { useMutation } from "@tanstack/react-query";
3
3
 
4
4
  // Stateless account actions (no session state) — kept out of PublicAuthContext.
5
+ //
6
+ // ## Why these hit `/public/sessions/*` and not `/public/accounts/*`
7
+ //
8
+ // A member's credential for a tenant lives on their `account_associations` row,
9
+ // not on `accounts` — that is the hash `POST /public/sessions` checks at login
10
+ // (see `resolveAssociationLogin` on the backend). `accounts.password` is the
11
+ // CREATOR/admin-dashboard credential; for a fan it is at most a lazy-heal
12
+ // fallback, gated on the account having some other relationship to the profile.
13
+ //
14
+ // These three calls used to point at the global `/public/accounts/*` endpoints,
15
+ // which write `accounts.password`. Every one of them returned 200 and changed
16
+ // nothing that login reads:
17
+ //
18
+ // - "Change password" logged the member out (a credential change invalidates
19
+ // sessions), then refused the new password and kept accepting the old one.
20
+ // - "Forgot password" was worse: it reset a hash login never consults, so the
21
+ // member — who by definition no longer knows the old password — was locked
22
+ // out with a reset link that appeared to work.
23
+ //
24
+ // The tenant-scoped endpoints below are the ones the credential actually lives
25
+ // behind, and they invalidate only this context's sessions.
5
26
 
6
- /** Request a password-reset email. */
27
+ /**
28
+ * Request a password-reset email for this tenant.
29
+ *
30
+ * `profileId` comes from the provider (one deploy = one tenant); pass it
31
+ * explicitly only when rendering outside a pinned context.
32
+ */
7
33
  export function useForgotPassword() {
8
- const { client } = useForge();
34
+ const { client, profileId } = useForge();
9
35
 
10
- return useMutation<unknown, unknown, { email: string; origin?: string }>({
11
- mutationFn: async ({ email, origin }) => {
12
- const res = await client.post("/public/accounts/forgot-password", { email, origin });
36
+ return useMutation<unknown, unknown, { email: string; origin?: string; profileId?: string }>({
37
+ mutationFn: async ({ email, origin, profileId: overrideProfileId }) => {
38
+ const contextId = overrideProfileId ?? profileId;
39
+ if (!contextId) {
40
+ // Better than posting without it: the endpoint would 400 on schema
41
+ // validation and the form would show "something went wrong".
42
+ throw new Error("useForgotPassword: no profileId — pass one or render inside a ForgeProvider with profileId");
43
+ }
44
+ const res = await client.post("/public/sessions/forgot-password", {
45
+ profileId: contextId,
46
+ email,
47
+ // The reset link is built server-side from this, and the association
48
+ // endpoint requires it (the global one treated it as optional).
49
+ origin: origin ?? (typeof window !== "undefined" ? window.location.origin : undefined),
50
+ });
13
51
  return res.data;
14
52
  },
15
53
  });
16
54
  }
17
55
 
18
- /** Complete a password reset with the emailed token. */
56
+ /**
57
+ * Complete a password reset with the emailed token.
58
+ *
59
+ * The token is looked up on the association row, so it must be redeemed against
60
+ * the same surface that issued it — a token minted by the hook above will not
61
+ * resolve on `/public/accounts/reset-password`, and vice versa.
62
+ */
19
63
  export function useResetPassword() {
20
64
  const { client } = useForge();
21
65
 
22
66
  return useMutation<unknown, unknown, { token: string; password: string }>({
23
67
  mutationFn: async ({ token, password }) => {
24
- const res = await client.post("/public/accounts/reset-password", { token, password });
68
+ const res = await client.post("/public/sessions/reset-password", { token, password });
25
69
  return res.data;
26
70
  },
27
71
  });
@@ -25,19 +25,48 @@ export function useEvents() {
25
25
  * seed the query — the hook then returns data on first render with no loading
26
26
  * state, so the page can be server-rendered for SEO with no client spinner.
27
27
  */
28
- export function useEvent(id?: string, options?: { initialData?: IEvent }) {
28
+ export function useEvent(
29
+ id?: string,
30
+ options?: {
31
+ initialData?: IEvent;
32
+ /**
33
+ * A presale code the buyer holds (1.2). A correct one reveals the hidden
34
+ * tiers it unlocks, in this same response, and sets `presale.codeAccepted`.
35
+ *
36
+ * Part of the query KEY, so entering a code refetches rather than serving
37
+ * the unrevealed cache — and so the seeded `initialData` (fetched without a
38
+ * code) is not mistaken for a coded response.
39
+ */
40
+ accessCode?: string | null;
41
+ },
42
+ ) {
29
43
  const { client, profileId } = useForge();
44
+ const accessCode = options?.accessCode || undefined;
45
+ // Only callers that pass the option at all — presale-aware ones — get the
46
+ // hold-the-last-page behaviour below. Everyone else keeps the old semantics
47
+ // exactly, including "the event goes undefined while a DIFFERENT event
48
+ // loads", which is what a router navigation wants.
49
+ const presaleAware = !!options && "accessCode" in options;
30
50
 
31
51
  return useQuery<IEvent>({
32
- queryKey: ["event", id, profileId],
52
+ queryKey: ["event", id, profileId, accessCode ?? null],
33
53
  queryFn: async () => {
34
54
  const res = await client.get(`/public/events/${id}`, {
35
- params: { profileId },
55
+ params: { profileId, ...(accessCode ? { accessCode } : {}) },
36
56
  });
37
57
  return res.data;
38
58
  },
39
59
  enabled: !!id && !!profileId && !!client,
40
- initialData: options?.initialData,
60
+ // Seeding only applies to the uncoded query: `initialData` came from a
61
+ // loader that had no code, so handing it to a coded key would show an
62
+ // unrevealed tier list and claim the code was applied.
63
+ initialData: accessCode ? undefined : options?.initialData,
64
+ // Applying a code changes the query key, which would otherwise blank the
65
+ // whole event page back to a spinner while the coded read is in flight —
66
+ // the buyer presses Apply and the show they were reading disappears. Keep
67
+ // the last event on screen and let `isFetching` drive the field's own
68
+ // "Checking…".
69
+ placeholderData: presaleAware ? (previous?: IEvent) => previous : undefined,
41
70
  });
42
71
  }
43
72
 
@@ -49,6 +78,21 @@ export type CreateEventOrderInput = {
49
78
  * the tier's own `price` — it can raise what is charged, never lower it.
50
79
  */
51
80
  amounts?: Record<string, number>;
81
+ /**
82
+ * Who is actually coming, keyed by ticket id exactly as `items` is, one entry
83
+ * per seat of that tier in issue order (Events 2.1).
84
+ *
85
+ * Send it only when the event has `collectAttendeeDetails` on — the server
86
+ * does not read it otherwise. A list whose length does not equal that tier's
87
+ * quantity is REFUSED (a short list would leave a silent extra pass in the
88
+ * purchaser's name), so build it with `attendeesPayload` from
89
+ * `forge/ui`'s attendee helpers rather than by hand.
90
+ *
91
+ * `email` is accepted per attendee and deliberately not sent by either
92
+ * storefront: it is the pass's OWNER, so naming another address hands that
93
+ * seat away. Handing a ticket to somebody else is what transfer is for.
94
+ */
95
+ attendees?: Record<string, { name: string; email?: string }[]>;
52
96
  email: string;
53
97
  firstName?: string;
54
98
  lastName?: string;
@@ -60,6 +104,16 @@ export type CreateEventOrderInput = {
60
104
  * the reason as its message and no order is created.
61
105
  */
62
106
  couponCode?: string;
107
+ /**
108
+ * The PRESALE code (1.2) — not the discount code above. It buys access to a
109
+ * tier, it does not change a price.
110
+ *
111
+ * The sell guard runs against the tier's own column on every order, so this
112
+ * has to travel with the purchase and not merely with the read that revealed
113
+ * the tier: a buyer who unlocks a presale and then checks out without it gets
114
+ * their order refused after entering their card details.
115
+ */
116
+ accessCode?: string;
63
117
  };
64
118
 
65
119
  export type CreateEventOrderResult = {
@@ -83,6 +137,14 @@ export type CreateEventOrderResult = {
83
137
  couponId?: string | null;
84
138
  /** Every discount that applied, entered OR automatic. */
85
139
  appliedCoupons?: AppliedDiscountCoupon[];
140
+ /**
141
+ * When these tickets stop being reserved, ISO-8601.
142
+ *
143
+ * `null` for a free order — its hold has already converted by the time this
144
+ * returns, so a countdown on it would be counting something that no longer
145
+ * exists — and for a profile with inventory holds switched off.
146
+ */
147
+ holdExpiresAt?: string | null;
86
148
  };
87
149
 
88
150
  /** Create an event ticket order (the step before start-payment). */
@@ -2,7 +2,13 @@ import type { MembershipTier } from "../../types/models";
2
2
  import { useForge } from "../../provider/ForgeProvider";
3
3
  import { useQuery } from "@tanstack/react-query";
4
4
 
5
- export function useGetMembershipTiers() {
5
+ /**
6
+ * `opts.enabled` gates the request on top of the usual profile/client checks —
7
+ * for a caller that only needs tier NAMES when something is actually gated, and
8
+ * must not add a request to every ungated storefront. Defaults to on, so every
9
+ * existing caller is unchanged.
10
+ */
11
+ export function useGetMembershipTiers(opts: { enabled?: boolean } = {}) {
6
12
  const { client, profileId } = useForge();
7
13
 
8
14
  return useQuery<MembershipTier[]>({
@@ -15,6 +21,6 @@ export function useGetMembershipTiers() {
15
21
  });
16
22
  return res.data;
17
23
  },
18
- enabled: !!profileId && !!client,
24
+ enabled: !!profileId && !!client && opts.enabled !== false,
19
25
  });
20
26
  }
@@ -111,6 +111,18 @@ export type MyBooking = {
111
111
  /** The server's answer. Never recomputed here. */
112
112
  canReschedule: boolean;
113
113
  rescheduleReason: RescheduleRefusal | null;
114
+ /**
115
+ * The session's `.ics`, both forms — what lets the portal offer Apple
116
+ * Calendar rather than only Google/Outlook/Microsoft 365. `null` unless the
117
+ * booking is confirmed.
118
+ *
119
+ * The address is stable across a RESCHEDULE (it is HMAC(kind:id) and the
120
+ * booking id does not change), which is the mechanism by which a re-fetch
121
+ * moves the entry already in the buyer's calendar instead of adding a second
122
+ * one. Optional: an older API build does not emit it.
123
+ */
124
+ calendarUrl?: string | null;
125
+ calendarWebcalUrl?: string | null;
114
126
  };
115
127
 
116
128
  export type CancelBookingResult = {
@@ -49,8 +49,65 @@ export type MyTicketPass = {
49
49
  ownerEmail?: string | null;
50
50
  /** Set once the pass has been scanned at the door. */
51
51
  checkedInAt?: string | null;
52
+ /**
53
+ * Does the CALLER hold this pass right now?
54
+ *
55
+ * Not the same question as "is it on my order", and the difference is the one
56
+ * that trips people up. A purchaser who named guests at checkout owns passes
57
+ * carrying their guests' addresses from the moment they are written, and a
58
+ * transfer moves the holder without touching the order. Every pass-scoped
59
+ * endpoint — transfer, wallet, QR — resolves the HOLDER, so a control drawn
60
+ * on a pass the caller does not hold can only ever 404.
61
+ *
62
+ * Optional because it is newer than the endpoint. `myTicketHeldPassIds`
63
+ * treats its absence as "assume held", which is the old behaviour.
64
+ */
65
+ isHolder?: boolean;
66
+ /**
67
+ * The caller's OWN live transfer on this pass, straight from the API.
68
+ *
69
+ * Only ever a transfer THIS reader sent (the server matches `from_email`) and
70
+ * only while it is `pending`, so it never exposes the operator-only chain of
71
+ * previous holders. `null` when nothing is in flight.
72
+ *
73
+ * This is what replaced `localStorage` as the source of truth for "you have a
74
+ * transfer out on this ticket": the id used to exist nowhere but the response
75
+ * to the sender's own send, so the same person on a second device had nothing
76
+ * to withdraw.
77
+ */
78
+ pendingTransfer?: MyTicketPendingTransfer | null;
79
+ };
80
+
81
+ /** The live claim window, computed server-side on every read. */
82
+ export type MyTicketTransferWindow = {
83
+ /** The claim link still works right now. */
84
+ open: boolean;
85
+ /** Whole seconds left when the server answered. `null` when there is no window. */
86
+ secondsRemaining: number | null;
87
+ /** The instant the link dies. */
88
+ expiresAt: string | null;
89
+ };
90
+
91
+ /**
92
+ * A pending transfer as the SENDER may see it — deliberately narrow.
93
+ *
94
+ * An id to address the withdraw endpoint with, the recipient THIS caller typed
95
+ * themselves, and the two timestamps that make "sent 2 hours ago, expires in
96
+ * 46" renderable. No token, no previous holder, no account ids.
97
+ */
98
+ export type MyTicketPendingTransfer = {
99
+ id: string;
100
+ status: string;
101
+ toName: string | null;
102
+ toEmail: string;
103
+ expiresAt: string;
104
+ createdAt: string;
105
+ window: MyTicketTransferWindow;
52
106
  };
53
107
 
108
+ /** How the caller comes to be looking at an order. */
109
+ export type MyTicketRole = "purchaser" | "holder";
110
+
54
111
  export type MyTicketItem = {
55
112
  id: string;
56
113
  quantity: number;
@@ -78,6 +135,27 @@ export type MyTicket = {
78
135
  eventStatus: string | null;
79
136
  items: MyTicketItem[];
80
137
  cancellation: TicketCancellation;
138
+ /**
139
+ * `purchaser` — they paid for this order. `holder` — a pass out of it was
140
+ * TRANSFERRED to them and they bought nothing.
141
+ *
142
+ * A holder's row is scrubbed server-side: `totalAmount` and every line price
143
+ * are null, lines they hold no pass on are gone, and `cancellation.canCancel`
144
+ * is false (cancelling refunds the sender's money and voids every seat, which
145
+ * is not a gift recipient's call). Render the money block only for a
146
+ * purchaser — `Number(null)` is `0`, and a confident "$0.00" on somebody
147
+ * else's purchase is worse than saying nothing.
148
+ *
149
+ * Optional: an older API build emits no role, and treating that as
150
+ * `purchaser` reproduces exactly the previous behaviour.
151
+ */
152
+ role?: MyTicketRole;
153
+ /**
154
+ * The event's `.ics`, both forms — what lets the portal offer Apple Calendar
155
+ * rather than only the three template providers. Null for a cancelled show.
156
+ */
157
+ calendarUrl?: string | null;
158
+ calendarWebcalUrl?: string | null;
81
159
  /**
82
160
  * Not emitted by the API, which nests passes under each ITEM. Tolerated by
83
161
  * `myTicketPassIds` so an order-level shape would not need a frontend change.
@@ -111,6 +189,40 @@ export function myTicketPassIds(ticket: Pick<MyTicket, "passes" | "items">): str
111
189
  return [...seen];
112
190
  }
113
191
 
192
+ /** Every pass on an order, flattened — the objects, not just the ids. */
193
+ export function myTicketPasses(ticket: Pick<MyTicket, "passes" | "items">): MyTicketPass[] {
194
+ const seen = new Map<string, MyTicketPass>();
195
+ const collect = (passes?: MyTicketPass[]) => {
196
+ for (const pass of passes ?? []) {
197
+ if (typeof pass?.id === "string" && /^TN-\d+$/.test(pass.id) && !seen.has(pass.id)) {
198
+ seen.set(pass.id, pass);
199
+ }
200
+ }
201
+ };
202
+
203
+ collect(ticket.passes);
204
+ for (const item of ticket.items ?? []) collect(item.passes);
205
+
206
+ return [...seen.values()];
207
+ }
208
+
209
+ /**
210
+ * The passes the caller can actually ACT on — transfer, wallet, QR.
211
+ *
212
+ * Every pass-scoped endpoint resolves the current HOLDER, so drawing those
213
+ * controls on a pass the caller merely paid for (a named guest's seat, a ticket
214
+ * already claimed by somebody else) produced a 404 with no explanation. The
215
+ * affordance is now drawn from the server's own `isHolder`.
216
+ *
217
+ * A pass with no `isHolder` at all is treated as held: that is an older API
218
+ * build, and the previous behaviour was to offer the control for every pass.
219
+ */
220
+ export function myTicketHeldPassIds(ticket: Pick<MyTicket, "passes" | "items">): string[] {
221
+ return myTicketPasses(ticket)
222
+ .filter((pass) => pass.isHolder !== false)
223
+ .map((pass) => pass.id);
224
+ }
225
+
114
226
  /**
115
227
  * The buyer is resolved from the SESSION server-side — `accountId` here only
116
228
  * gates the query on being signed in and keys the cache. It is never sent, and
@@ -56,6 +56,16 @@ export type CreateOrderResult = {
56
56
  totalAmount: number;
57
57
  subTotal: number;
58
58
  shippingCosts?: { deliveryGroupId: string; amount: number; currency: string }[];
59
+ /**
60
+ * When the stock reserved for this cart goes back on sale, ISO-8601.
61
+ *
62
+ * `null` when nothing was reserved — an order that already settled (a free
63
+ * one), a cart of digital/service items, or a profile with inventory holds
64
+ * switched off. `start-payment` restarts this clock and reports the newer
65
+ * instant, so treat that one as authoritative once it arrives; this is what
66
+ * bridges the gap while it is in flight.
67
+ */
68
+ holdExpiresAt?: string | null;
59
69
  };
60
70
 
61
71
  /** Create a cart/product order (the step before start-payment). */
@@ -37,21 +37,27 @@ import { useForge } from "../../provider/ForgeProvider";
37
37
  * cancelled transfer are all its answers to give, each with a distinct
38
38
  * message.
39
39
  *
40
- * ## Why there is a local store for pending sends
40
+ * ## Where a pending transfer comes from the SERVER, now
41
41
  *
42
42
  * `GET /public/events/passes/:passId/transfers` is gated on the operator
43
- * permission `events.read` the chain names every previous holder, which the
44
- * current one has no business reading and nothing on `/public/events/my-tickets`
45
- * carries transfer state. So a BUYER has no endpoint that lists their own
46
- * pending transfer back to them: the only place a transfer id ever appears is
47
- * the response to their own send.
43
+ * permission `events.read` and stays that way: the chain names every previous
44
+ * holder, which the current one has no business reading. For a while that left
45
+ * a buyer with no endpoint listing their own pending transfer back to them —
46
+ * the id existed nowhere but the response to their own send so this file
47
+ * remembered it in `localStorage`, exactly as `useEventWaitlist` does for an
48
+ * anonymous joiner's entry id.
48
49
  *
49
- * That id is remembered in `localStorage`, exactly as `useEventWaitlist` does
50
- * for an anonymous joiner's entry id and for the same reason — it is the only
51
- * way the browser that performed an action can show it again. It is a
52
- * convenience, not a source of truth: the durable copy is the recipient's email,
53
- * and every cancel is still decided by the server. A buyer on another device
54
- * sees no pending transfer, which is a gap in the API rather than in this file.
50
+ * `/public/events/my-tickets` now carries `pendingTransfer` on each pass:
51
+ * `status = 'pending'` AND a `from_email` match on the reader, so the only row
52
+ * that can surface is one the reader sent. **That is the source of truth.** A
53
+ * sender who signed in on a second device can see and withdraw a transfer they
54
+ * started on the first, which is the case `localStorage` could never serve.
55
+ *
56
+ * The local store is KEPT, demoted to what it always really was: a same-render
57
+ * bridge. `my-tickets` is a paginated query that has to round-trip before the
58
+ * new row appears, and a just-sent transfer vanishing for a second reads as a
59
+ * failed send. `mergePendingTransfers` below prefers the server's copy whenever
60
+ * it has one, so the two can never both draw a row.
55
61
  */
56
62
 
57
63
  /** The lifecycle, restated (Forge cannot import from the API). */
@@ -202,13 +208,67 @@ export const forgetPassTransfer = (transferId: string) => {
202
208
  * The remembered pending sends, as a REACTIVE read.
203
209
  *
204
210
  * There is no query to invalidate, so a plain read would leave a just-sent
205
- * transfer invisible until a reload.
211
+ * transfer invisible until `my-tickets` refetches.
206
212
  */
207
213
  export function usePendingPassTransfers(passId?: string): RememberedPassTransfer[] {
208
214
  const all = useSyncExternalStore(subscribe, snapshot, serverSnapshot);
209
215
  return useMemo(() => (passId ? all.filter((entry) => entry.passId === passId) : all), [all, passId]);
210
216
  }
211
217
 
218
+ /** One pending transfer, however the caller came by it. */
219
+ export type PendingTransferView = {
220
+ /** The id the withdraw endpoint takes. */
221
+ transferId: string;
222
+ toEmail: string;
223
+ /** The instant the claim link dies. `null` when unknown (a local-only row). */
224
+ expiresAt: string | null;
225
+ /** False once the window has lapsed — server-computed, never re-derived. */
226
+ open: boolean;
227
+ /** True when this came off `my-tickets` rather than out of `localStorage`. */
228
+ fromServer: boolean;
229
+ };
230
+
231
+ /**
232
+ * The pending transfer to DRAW for one pass — the server's if it has one, the
233
+ * local bridge otherwise.
234
+ *
235
+ * De-duplication matters more than it looks: one live transfer per pass is a
236
+ * DATABASE guarantee (a partial unique index), so two rows on screen would be a
237
+ * lie about the ticket's state and would offer two "withdraw" buttons for one
238
+ * thing. The server wins whenever it answers, because it also knows whether the
239
+ * window is still open.
240
+ *
241
+ * `null` means nothing is in flight — a pass with no outstanding transfer, on
242
+ * any device.
243
+ */
244
+ export function mergePendingTransfer(
245
+ serverTransfer: { id: string; toEmail: string; expiresAt: string; window?: { open: boolean } } | null | undefined,
246
+ remembered: RememberedPassTransfer[],
247
+ ): PendingTransferView | null {
248
+ if (serverTransfer) {
249
+ return {
250
+ transferId: serverTransfer.id,
251
+ toEmail: serverTransfer.toEmail,
252
+ expiresAt: serverTransfer.expiresAt ?? null,
253
+ // A lapsed-but-unswept transfer is still `pending` and still withdrawable;
254
+ // the server says `open: false` and the UI says so too rather than hiding
255
+ // a live row the sender cannot otherwise address.
256
+ open: serverTransfer.window?.open ?? true,
257
+ fromServer: true,
258
+ };
259
+ }
260
+
261
+ const local = remembered[0];
262
+ if (!local) return null;
263
+ return {
264
+ transferId: local.transferId,
265
+ toEmail: local.toEmail,
266
+ expiresAt: local.expiresAt,
267
+ open: true,
268
+ fromServer: false,
269
+ };
270
+ }
271
+
212
272
  // ── Hooks ───────────────────────────────────────────────────────────────────
213
273
 
214
274
  /**