@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,154 @@
1
+ import { useForge } from "../../provider/ForgeProvider";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import type { PaginatedData } from "../../types/models";
4
+
5
+ /**
6
+ * The signed-in buyer's own event tickets, and cancelling one (S.6).
7
+ *
8
+ * Lives in Forge rather than in either app because both rendering surfaces —
9
+ * the client PWA and code websites — need exactly this, and a second copy is
10
+ * how the two drift.
11
+ */
12
+
13
+ /** Why the buyer cannot cancel, when they cannot. */
14
+ export type TicketCancellationReason = "no_policy" | "not_allowed" | "window_closed" | "event_started";
15
+
16
+ export type TicketCancellation = {
17
+ policy: "none" | "until" | "anytime" | null;
18
+ cutoffHours: number | null;
19
+ terms: string | null;
20
+ /** One sentence, already phrased for a buyer. Null when no policy was set. */
21
+ description: string | null;
22
+ canCancel: boolean;
23
+ reason: TicketCancellationReason | null;
24
+ /** The instant the window shuts. */
25
+ deadline: string | null;
26
+ };
27
+
28
+ /**
29
+ * One admitted person — an `event_passes` row, which is what a QR code, a
30
+ * transfer and a wallet pass are each *of*.
31
+ *
32
+ * The distinction matters because it is the one that trips people up: an order
33
+ * is a purchase, an ITEM is a purchase line ("2 × General Admission"), and a
34
+ * PASS is a single human being at a door. `MyTicket.id` is an order id and
35
+ * `MyTicketItem.id` is an order-item id, and NEITHER is accepted by any
36
+ * pass-scoped endpoint — `/public/events/passes/:passId/wallet` and
37
+ * `/public/events/passes/:passId/transfers` both take a `TN-…` id and answer
38
+ * 404 for anything else.
39
+ *
40
+ * Optional because the field is newer than the endpoint that reads it, and
41
+ * because a site pinned to an older API build would not have it. Always read it
42
+ * through `myTicketPassIds`, which tolerates its absence and yields `[]` — the
43
+ * "draw nothing" answer the wallet affordance needs anyway.
44
+ */
45
+ export type MyTicketPass = {
46
+ /** `event_passes.id` — the string `TN-` followed by digits. */
47
+ id: string;
48
+ ownerName?: string | null;
49
+ ownerEmail?: string | null;
50
+ /** Set once the pass has been scanned at the door. */
51
+ checkedInAt?: string | null;
52
+ };
53
+
54
+ export type MyTicketItem = {
55
+ id: string;
56
+ quantity: number;
57
+ price: string | number;
58
+ ticketTitle: string | null;
59
+ /** The individual admissions on this line — where the API puts them. */
60
+ passes?: MyTicketPass[];
61
+ };
62
+
63
+ export type MyTicket = {
64
+ id: string;
65
+ status: string;
66
+ totalAmount: string | number;
67
+ currency: string | null;
68
+ createdAt: string;
69
+ refundState: string;
70
+ refundedAmountCents: string | number;
71
+ selfCancelledAt: string | null;
72
+ eventId: string;
73
+ eventTitle: string;
74
+ eventSlug: string | null;
75
+ eventDateTime: string;
76
+ eventEndDateTime: string | null;
77
+ eventTimezone: string | null;
78
+ eventStatus: string | null;
79
+ items: MyTicketItem[];
80
+ cancellation: TicketCancellation;
81
+ /**
82
+ * Not emitted by the API, which nests passes under each ITEM. Tolerated by
83
+ * `myTicketPassIds` so an order-level shape would not need a frontend change.
84
+ */
85
+ passes?: MyTicketPass[];
86
+ };
87
+
88
+ /**
89
+ * Every pass id on an order.
90
+ *
91
+ * Reads the item-level passes the API emits, and tolerates an order-level array
92
+ * as well — de-duplicated, because a payload carrying both would otherwise draw
93
+ * each ticket's wallet buttons twice.
94
+ *
95
+ * Anything that is not a `TN-…` id is dropped rather than sent: the pass-scoped
96
+ * endpoints 404 on the wrong shape, and a request that cannot succeed is not
97
+ * worth making. An older API build with no passes in the payload yields `[]`,
98
+ * which callers must treat as "draw nothing" rather than as a failure.
99
+ */
100
+ export function myTicketPassIds(ticket: Pick<MyTicket, "passes" | "items">): string[] {
101
+ const seen = new Set<string>();
102
+ const collect = (passes?: MyTicketPass[]) => {
103
+ for (const pass of passes ?? []) {
104
+ if (typeof pass?.id === "string" && /^TN-\d+$/.test(pass.id)) seen.add(pass.id);
105
+ }
106
+ };
107
+
108
+ collect(ticket.passes);
109
+ for (const item of ticket.items ?? []) collect(item.passes);
110
+
111
+ return [...seen];
112
+ }
113
+
114
+ /**
115
+ * The buyer is resolved from the SESSION server-side — `accountId` here only
116
+ * gates the query on being signed in and keys the cache. It is never sent, and
117
+ * sending it would not help: the API ignores any buyer identity from input.
118
+ */
119
+ export function useMyTickets(accountId?: string, page = 1, limit = 20) {
120
+ const { client, profileId } = useForge();
121
+
122
+ return useQuery<PaginatedData<MyTicket>>({
123
+ queryKey: ["my-tickets", accountId, profileId, page, limit],
124
+ queryFn: async () => {
125
+ const res = await client.get("/public/events/my-tickets", {
126
+ params: { profileId, page, limit },
127
+ });
128
+ return res.data;
129
+ },
130
+ enabled: !!accountId && !!profileId && !!client,
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Cancel one of the buyer's own tickets.
136
+ *
137
+ * Invalidates the list on success so the row reflects its new state rather than
138
+ * offering a button that would now be refused — the server is the authority on
139
+ * eligibility, and re-reading is cheaper than mirroring its rules here.
140
+ */
141
+ export function useCancelMyTicket() {
142
+ const { client, profileId } = useForge();
143
+ const queryClient = useQueryClient();
144
+
145
+ return useMutation<{ orderId: string; refunded: boolean }, unknown, { orderId: string }>({
146
+ mutationFn: async ({ orderId }) => {
147
+ const res = await client.post(`/public/events/my-tickets/${orderId}/cancel`, { profileId });
148
+ return res.data;
149
+ },
150
+ onSuccess: () => {
151
+ void queryClient.invalidateQueries({ queryKey: ["my-tickets"] });
152
+ },
153
+ });
154
+ }
@@ -0,0 +1,318 @@
1
+ import { useMemo, useSyncExternalStore } from "react";
2
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
3
+ import { useForge } from "../../provider/ForgeProvider";
4
+
5
+ /**
6
+ * Events 2.2 — handing a ticket to somebody else, and taking one.
7
+ *
8
+ * Lives in Forge rather than in either app because both rendering surfaces —
9
+ * the client PWA and code websites — need exactly this, and a second copy is
10
+ * how the two drift (the same reason `useMyTickets` and `useEventWaitlist` are
11
+ * here).
12
+ *
13
+ * ## The endpoints
14
+ *
15
+ * ```
16
+ * POST /public/events/passes/:passId/transfers send → PassTransfer (201)
17
+ * POST /public/events/transfers/claim claim → PassTransfer & { eventPassId }
18
+ * POST /public/events/transfers/:id/cancel cancel → PassTransfer
19
+ * GET /public/events/passes/:passId/transfers history → OPERATOR ONLY (see below)
20
+ * ```
21
+ *
22
+ * `:passId` is an `event_passes` id — the string `TN-` followed by digits, NOT
23
+ * an order or order-item id. `myTicketPassIds()` in `useMyTickets` is how a
24
+ * buyer's portal gets one.
25
+ *
26
+ * ## Three things the server owns, which this file deliberately does not mirror
27
+ *
28
+ * 1. **Who may send.** The current holder, resolved from the SESSION against
29
+ * the pass's `owner_email`. No identity is ever sent from here; a `fromEmail`
30
+ * parameter is exactly the vulnerability the endpoint is shaped to avoid.
31
+ * 2. **Whether a pass may be handed on at all.** Checked-in, refunded, cancelled
32
+ * show, finished show — all refused server-side, at SEND and again at CLAIM
33
+ * because days pass in between. The UI draws the affordance and surfaces the
34
+ * server's own message on refusal; it never pre-judges the answer.
35
+ * 3. **Whether a claim succeeds.** The token is the capability and the claim is
36
+ * one atomic conditional UPDATE. A second click, an expired window and a
37
+ * cancelled transfer are all its answers to give, each with a distinct
38
+ * message.
39
+ *
40
+ * ## Why there is a local store for pending sends
41
+ *
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.
48
+ *
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.
55
+ */
56
+
57
+ /** The lifecycle, restated (Forge cannot import from the API). */
58
+ export type PassTransferStatus = "pending" | "claimed" | "cancelled" | "expired";
59
+
60
+ /** The live claim window, computed server-side on every read. */
61
+ export type PassTransferWindow = {
62
+ /** The claim link still works right now. */
63
+ open: boolean;
64
+ /** Whole seconds left when the server answered. `null` when there is no window. */
65
+ secondsRemaining: number | null;
66
+ /** The instant the link dies. */
67
+ expiresAt: string | null;
68
+ };
69
+
70
+ export type PassTransfer = {
71
+ id: string;
72
+ /** The `TN-…` pass this transfer is of. */
73
+ eventPassId: string;
74
+ eventId: string;
75
+ status: PassTransferStatus | string;
76
+ /** Denormalised at send time — the pass's own columns have moved on by now. */
77
+ fromName: string | null;
78
+ fromEmail: string;
79
+ toName: string | null;
80
+ toEmail: string;
81
+ expiresAt: string;
82
+ claimedAt: string | null;
83
+ cancelledAt: string | null;
84
+ createdAt: string;
85
+ window: PassTransferWindow;
86
+ };
87
+
88
+ export type SendPassTransferInput = {
89
+ /** The pass to hand over — a `TN-…` id from `myTicketPassIds()`. */
90
+ passId: string;
91
+ /** Where it goes. The only address the endpoint accepts. */
92
+ toEmail: string;
93
+ toName?: string;
94
+ /**
95
+ * How long the claim link lives, in hours. Bounded [1, 720] server-side and
96
+ * defaulted to 72 there; omit it and take the server's default rather than
97
+ * restating a number that would then have two definitions.
98
+ */
99
+ expiresInHours?: number;
100
+ };
101
+
102
+ export type ClaimPassTransferInput = {
103
+ /** 64 hex characters, out of the recipient's email link. */
104
+ token: string;
105
+ /** What the claimant is called at the door. Optional; the server falls back. */
106
+ name?: string;
107
+ };
108
+
109
+ /** Still live — the only status a cancel can act on. */
110
+ export const isPendingPassTransfer = (transfer: Pick<PassTransfer, "status">): boolean =>
111
+ transfer.status === "pending";
112
+
113
+ // ── The sender's own pending transfers, remembered locally ──────────────────
114
+ //
115
+ // See the file docblock: there is no buyer-readable list endpoint, so this is
116
+ // the only way the browser that sent a transfer can offer to cancel it.
117
+
118
+ const STORAGE_KEY = "tn.pass-transfers.pending";
119
+
120
+ export type RememberedPassTransfer = {
121
+ passId: string;
122
+ transferId: string;
123
+ toEmail: string;
124
+ /** The server's window end, kept so the row can say when the link dies. */
125
+ expiresAt: string | null;
126
+ };
127
+
128
+ const isRemembered = (item: unknown): item is RememberedPassTransfer =>
129
+ !!item &&
130
+ typeof (item as RememberedPassTransfer).passId === "string" &&
131
+ typeof (item as RememberedPassTransfer).transferId === "string" &&
132
+ typeof (item as RememberedPassTransfer).toEmail === "string";
133
+
134
+ const readRemembered = (): RememberedPassTransfer[] => {
135
+ if (typeof window === "undefined") return [];
136
+ try {
137
+ const raw = window.localStorage.getItem(STORAGE_KEY);
138
+ if (!raw) return [];
139
+ const parsed: unknown = JSON.parse(raw);
140
+ if (!Array.isArray(parsed)) return [];
141
+ return parsed.filter(isRemembered);
142
+ } catch {
143
+ return [];
144
+ }
145
+ };
146
+
147
+ /**
148
+ * Cached so `useSyncExternalStore` gets a STABLE snapshot — returning a fresh
149
+ * array from every read would re-render forever. Invalidated on every write.
150
+ */
151
+ let cache: RememberedPassTransfer[] | null = null;
152
+ const listeners = new Set<() => void>();
153
+
154
+ const snapshot = (): RememberedPassTransfer[] => {
155
+ if (!cache) cache = readRemembered();
156
+ return cache;
157
+ };
158
+
159
+ /** The server-rendered pass has no browser store; it must be empty and stable. */
160
+ const EMPTY: RememberedPassTransfer[] = [];
161
+ const serverSnapshot = (): RememberedPassTransfer[] => EMPTY;
162
+
163
+ const subscribe = (listener: () => void) => {
164
+ listeners.add(listener);
165
+ return () => {
166
+ listeners.delete(listener);
167
+ };
168
+ };
169
+
170
+ const writeRemembered = (entries: RememberedPassTransfer[]) => {
171
+ cache = entries;
172
+ if (typeof window !== "undefined") {
173
+ try {
174
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
175
+ } catch {
176
+ /* private mode / quota — the recipient's email is still the durable copy. */
177
+ }
178
+ }
179
+ listeners.forEach((listener) => listener());
180
+ };
181
+
182
+ export const getRememberedPassTransfers = (passId?: string): RememberedPassTransfer[] => {
183
+ const all = snapshot();
184
+ return passId ? all.filter((entry) => entry.passId === passId) : all;
185
+ };
186
+
187
+ export const rememberPassTransfer = (entry: RememberedPassTransfer) => {
188
+ const all = snapshot();
189
+ // One live transfer per pass is a DATABASE guarantee (a partial unique index),
190
+ // so a second remembered row for the same pass could only ever be stale.
191
+ const next = all.filter((item) => item.passId !== entry.passId && item.transferId !== entry.transferId);
192
+ writeRemembered([...next, entry]);
193
+ };
194
+
195
+ export const forgetPassTransfer = (transferId: string) => {
196
+ const all = snapshot();
197
+ if (!all.some((item) => item.transferId === transferId)) return;
198
+ writeRemembered(all.filter((item) => item.transferId !== transferId));
199
+ };
200
+
201
+ /**
202
+ * The remembered pending sends, as a REACTIVE read.
203
+ *
204
+ * There is no query to invalidate, so a plain read would leave a just-sent
205
+ * transfer invisible until a reload.
206
+ */
207
+ export function usePendingPassTransfers(passId?: string): RememberedPassTransfer[] {
208
+ const all = useSyncExternalStore(subscribe, snapshot, serverSnapshot);
209
+ return useMemo(() => (passId ? all.filter((entry) => entry.passId === passId) : all), [all, passId]);
210
+ }
211
+
212
+ // ── Hooks ───────────────────────────────────────────────────────────────────
213
+
214
+ /**
215
+ * Hand a pass to an email address.
216
+ *
217
+ * A pass that already has a live transfer is refused (the partial unique index
218
+ * is the double-spend guard), as is a pass the caller does not currently hold —
219
+ * which answers 404, not 403, so the endpoint cannot be used to probe pass ids.
220
+ * Both come back as the server's own message; render it rather than replacing
221
+ * it with a guess at which rule fired.
222
+ *
223
+ * The created transfer is remembered locally so this browser can offer to
224
+ * cancel it, and `my-tickets` is invalidated because a claim will move the
225
+ * pass's owner out from under the buyer's list.
226
+ */
227
+ export function useSendPassTransfer() {
228
+ const { client, profileId } = useForge();
229
+ const queryClient = useQueryClient();
230
+
231
+ return useMutation<PassTransfer, unknown, SendPassTransferInput>({
232
+ mutationFn: async ({ passId, toEmail, toName, expiresInHours }) => {
233
+ const res = await client.post(`/public/events/passes/${passId}/transfers`, {
234
+ profileId,
235
+ toEmail,
236
+ // Sent only when there is one — an empty string is not the same as "unset"
237
+ // to a `.max()`-bounded optional, and the server falls back sensibly.
238
+ ...(toName ? { toName } : {}),
239
+ ...(expiresInHours ? { expiresInHours } : {}),
240
+ });
241
+ return res.data;
242
+ },
243
+ onSuccess: (transfer) => {
244
+ rememberPassTransfer({
245
+ passId: transfer.eventPassId,
246
+ transferId: transfer.id,
247
+ toEmail: transfer.toEmail,
248
+ expiresAt: transfer.expiresAt ?? null,
249
+ });
250
+ void queryClient.invalidateQueries({ queryKey: ["my-tickets"] });
251
+ },
252
+ });
253
+ }
254
+
255
+ /**
256
+ * Withdraw an unclaimed transfer. Current holder only.
257
+ *
258
+ * `profileId` goes in the QUERY here, not the body — the endpoint validates
259
+ * `req.query`, and a body would be ignored and the request refused.
260
+ *
261
+ * A transfer that was claimed in the meantime is a 400 with the server's own
262
+ * wording, which is the correct outcome: the sender no longer holds the ticket,
263
+ * so there is nothing left to withdraw. The local record is dropped either way —
264
+ * whatever became of it, it is no longer pending.
265
+ */
266
+ export function useCancelPassTransfer() {
267
+ const { client, profileId } = useForge();
268
+ const queryClient = useQueryClient();
269
+
270
+ return useMutation<PassTransfer, unknown, { transferId: string }>({
271
+ mutationFn: async ({ transferId }) => {
272
+ const res = await client.post(
273
+ `/public/events/transfers/${transferId}/cancel`,
274
+ {},
275
+ { params: { profileId } },
276
+ );
277
+ return res.data;
278
+ },
279
+ onSuccess: (transfer) => {
280
+ forgetPassTransfer(transfer.id);
281
+ void queryClient.invalidateQueries({ queryKey: ["my-tickets"] });
282
+ },
283
+ });
284
+ }
285
+
286
+ /**
287
+ * Take a ticket somebody sent you. **Deliberately usable while signed out.**
288
+ *
289
+ * The recipient is a stranger who may have no account here; requiring one would
290
+ * strand most transfers. The token IS the capability — a signed-in claimant is
291
+ * merely recorded against the row. So this hook sends no identity and must
292
+ * never be gated on `user`.
293
+ *
294
+ * Every refusal (already claimed, expired, cancelled, the show was called off,
295
+ * the sender walked through the door) arrives as a distinct server message.
296
+ * Surface it verbatim: the states are not enumerable from here, and "expired"
297
+ * versus "already claimed" is a materially different thing to somebody standing
298
+ * outside a venue.
299
+ */
300
+ export function useClaimPassTransfer() {
301
+ const { client, profileId } = useForge();
302
+ const queryClient = useQueryClient();
303
+
304
+ return useMutation<PassTransfer & { eventPassId: string }, unknown, ClaimPassTransferInput>({
305
+ mutationFn: async ({ token, name }) => {
306
+ const res = await client.post("/public/events/transfers/claim", {
307
+ profileId,
308
+ token,
309
+ ...(name ? { name } : {}),
310
+ });
311
+ return res.data;
312
+ },
313
+ onSuccess: () => {
314
+ // The claimant may also be a ticket-holder here; the pass just moved.
315
+ void queryClient.invalidateQueries({ queryKey: ["my-tickets"] });
316
+ },
317
+ });
318
+ }