@tribe-nest/forge 3.4.0 → 3.11.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 (42) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/_tests/eventWaitlist.spec.ts +122 -0
  3. package/src/data/queries/_tests/passTransfers.spec.ts +89 -0
  4. package/src/data/queries/_tests/walletPass.spec.ts +159 -0
  5. package/src/data/queries/useCoachingAvailability.ts +14 -3
  6. package/src/data/queries/useEventSeries.ts +42 -0
  7. package/src/data/queries/useEventWaitlist.ts +429 -0
  8. package/src/data/queries/useEvents.ts +10 -1
  9. package/src/data/queries/useMyBookings.ts +211 -0
  10. package/src/data/queries/useMyTickets.ts +154 -0
  11. package/src/data/queries/usePassTransfers.ts +318 -0
  12. package/src/data/queries/useSubscriptions.ts +53 -0
  13. package/src/data/queries/useWalletPass.ts +236 -0
  14. package/src/index.ts +9 -0
  15. package/src/server/_tests/siteBootstrap.spec.ts +131 -0
  16. package/src/server/index.ts +142 -6
  17. package/src/types/diagnostics.ts +49 -0
  18. package/src/types/models.ts +146 -0
  19. package/src/ui/format/bookingFee.ts +98 -0
  20. package/src/ui/headless/coaching/useCoachingBooking.ts +19 -0
  21. package/src/ui/headless/event/useEventCheckout.ts +61 -2
  22. package/src/ui/headless/membership/MembershipGate.tsx +7 -2
  23. package/src/ui/index.ts +29 -0
  24. package/src/ui/shell/PoweredBy.tsx +6 -4
  25. package/src/ui/shell/PreviewDiagnostics.tsx +80 -0
  26. package/src/ui/shell/TribeNestApp.tsx +24 -0
  27. package/src/ui/shell/diagnosticsGating.spec.ts +102 -0
  28. package/src/ui/shell/diagnosticsGating.ts +90 -0
  29. package/src/ui/shell/shellGating.spec.ts +40 -1
  30. package/src/ui/shell/shellGating.ts +33 -0
  31. package/src/ui/styled/AccountDashboard.tsx +643 -5
  32. package/src/ui/styled/CancellationTerms.tsx +70 -0
  33. package/src/ui/styled/CoachingBooking.tsx +10 -0
  34. package/src/ui/styled/CoachingDetail.tsx +4 -0
  35. package/src/ui/styled/EventDetail.tsx +18 -0
  36. package/src/ui/styled/EventSeriesDetail.tsx +222 -0
  37. package/src/ui/styled/EventTickets.tsx +58 -0
  38. package/src/ui/styled/EventWaitlist.tsx +448 -0
  39. package/src/ui/styled/TicketTransfer.tsx +393 -0
  40. package/src/ui/styled/WalletPassButtons.tsx +208 -0
  41. package/src/ui/styled/_tests/WalletPassButtons.spec.tsx +223 -0
  42. package/src/utils/membershipAccess.ts +182 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.4.0",
3
+ "version": "3.11.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it, beforeEach } from "vitest";
2
+ import {
3
+ forgetWaitlistEntry,
4
+ getRememberedWaitlistEntries,
5
+ isEventWaitlistOpen,
6
+ isLiveWaitlistEntry,
7
+ isTicketSoldOut,
8
+ rememberWaitlistEntry,
9
+ ticketSeatsRemaining,
10
+ } from "../useEventWaitlist";
11
+ import type { IEvent, ITicket } from "../../../types/models";
12
+
13
+ /**
14
+ * Events 2.6 — the pure decisions behind the buyer-facing waitlist.
15
+ *
16
+ * Only the total functions are exercised here; everything else in the module is
17
+ * a thin transport whose behaviour belongs to the API's own specs.
18
+ */
19
+
20
+ const tier = (overrides: Partial<ITicket> = {}): ITicket =>
21
+ ({
22
+ id: "t1",
23
+ title: "Standing",
24
+ description: "",
25
+ price: 25,
26
+ quantity: 100,
27
+ order: 0,
28
+ sold: 0,
29
+ maxPerPerson: 4,
30
+ ...overrides,
31
+ }) as ITicket;
32
+
33
+ describe("ticketSeatsRemaining", () => {
34
+ it("counts what is left, floored at zero", () => {
35
+ expect(ticketSeatsRemaining(tier({ quantity: 100, sold: 40 }))).toBe(60);
36
+ expect(ticketSeatsRemaining(tier({ quantity: 100, sold: 100 }))).toBe(0);
37
+ // Oversold (a refund race, a manual capacity cut) is still zero, never negative.
38
+ expect(ticketSeatsRemaining(tier({ quantity: 100, sold: 120 }))).toBe(0);
39
+ });
40
+ });
41
+
42
+ describe("isTicketSoldOut — where a join control may be drawn", () => {
43
+ it("is false while anything is left to sell", () => {
44
+ expect(isTicketSoldOut(tier({ quantity: 10, sold: 9 }))).toBe(false);
45
+ });
46
+
47
+ it("is true once nothing is left", () => {
48
+ expect(isTicketSoldOut(tier({ quantity: 10, sold: 10 }))).toBe(true);
49
+ });
50
+
51
+ it("never offers a queue for an archived tier", () => {
52
+ expect(isTicketSoldOut(tier({ quantity: 10, sold: 10, archivedAt: "2026-01-01T00:00:00Z" }))).toBe(false);
53
+ });
54
+
55
+ it("never offers a queue once the sale window has closed", () => {
56
+ const now = new Date("2026-08-01T12:00:00Z");
57
+ // Waiting for a release that can no longer be sold is waiting for an email
58
+ // that will never arrive.
59
+ expect(isTicketSoldOut(tier({ quantity: 10, sold: 10, expiresAt: "2026-07-31T00:00:00Z" }), now)).toBe(false);
60
+ expect(isTicketSoldOut(tier({ quantity: 10, sold: 10, expiresAt: "2026-08-02T00:00:00Z" }), now)).toBe(true);
61
+ });
62
+ });
63
+
64
+ describe("isEventWaitlistOpen", () => {
65
+ const withStatus = (status: IEvent["status"]) => ({ status });
66
+
67
+ it("accepts the statuses a sign-up can still mean something on", () => {
68
+ expect(isEventWaitlistOpen(withStatus("scheduled"))).toBe(true);
69
+ expect(isEventWaitlistOpen(withStatus("on_sale"))).toBe(true);
70
+ expect(isEventWaitlistOpen(withStatus("sold_out"))).toBe(true);
71
+ });
72
+
73
+ it("refuses a show that is over or called off", () => {
74
+ expect(isEventWaitlistOpen(withStatus("cancelled"))).toBe(false);
75
+ expect(isEventWaitlistOpen(withStatus("completed"))).toBe(false);
76
+ expect(isEventWaitlistOpen(withStatus("draft"))).toBe(false);
77
+ });
78
+ });
79
+
80
+ describe("isLiveWaitlistEntry", () => {
81
+ it("counts a held offer as being on the list", () => {
82
+ expect(isLiveWaitlistEntry({ status: "waiting" })).toBe(true);
83
+ // An open offer IS a place in the queue, just a better one.
84
+ expect(isLiveWaitlistEntry({ status: "notified" })).toBe(true);
85
+ });
86
+
87
+ it("excludes every terminal status", () => {
88
+ expect(isLiveWaitlistEntry({ status: "converted" })).toBe(false);
89
+ expect(isLiveWaitlistEntry({ status: "left" })).toBe(false);
90
+ expect(isLiveWaitlistEntry({ status: "expired" })).toBe(false);
91
+ });
92
+ });
93
+
94
+ describe("the anonymous joiner's remembered capability ids", () => {
95
+ beforeEach(() => {
96
+ for (const entry of getRememberedWaitlistEntries()) forgetWaitlistEntry(entry.entryId);
97
+ });
98
+
99
+ it("remembers an entry and narrows by event", () => {
100
+ rememberWaitlistEntry({ eventId: "e1", entryId: "a" });
101
+ rememberWaitlistEntry({ eventId: "e2", entryId: "b" });
102
+
103
+ expect(getRememberedWaitlistEntries().map((e) => e.entryId)).toEqual(["a", "b"]);
104
+ expect(getRememberedWaitlistEntries("e1").map((e) => e.entryId)).toEqual(["a"]);
105
+ });
106
+
107
+ it("does not duplicate a re-join of the same entry", () => {
108
+ // Joining is idempotent server-side, so the SAME id comes back a second
109
+ // time — it must not become two places in the UI.
110
+ rememberWaitlistEntry({ eventId: "e1", entryId: "a" });
111
+ rememberWaitlistEntry({ eventId: "e1", entryId: "a" });
112
+
113
+ expect(getRememberedWaitlistEntries("e1")).toHaveLength(1);
114
+ });
115
+
116
+ it("forgets an entry once it is left", () => {
117
+ rememberWaitlistEntry({ eventId: "e1", entryId: "a" });
118
+ forgetWaitlistEntry("a");
119
+
120
+ expect(getRememberedWaitlistEntries("e1")).toEqual([]);
121
+ });
122
+ });
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it, beforeEach } from "vitest";
2
+ import {
3
+ forgetPassTransfer,
4
+ getRememberedPassTransfers,
5
+ isPendingPassTransfer,
6
+ rememberPassTransfer,
7
+ } from "../usePassTransfers";
8
+ import { myTicketPassIds } from "../useMyTickets";
9
+
10
+ /**
11
+ * Events 2.2 — the pure decisions behind the buyer-facing transfer surface.
12
+ *
13
+ * Only the total functions are exercised; the hooks themselves are thin
14
+ * 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.
18
+ */
19
+
20
+ const clear = () => {
21
+ for (const entry of getRememberedPassTransfers()) forgetPassTransfer(entry.transferId);
22
+ };
23
+
24
+ beforeEach(clear);
25
+
26
+ describe("isPendingPassTransfer", () => {
27
+ it("is true only for the one status a cancel can act on", () => {
28
+ expect(isPendingPassTransfer({ status: "pending" })).toBe(true);
29
+ for (const status of ["claimed", "cancelled", "expired"]) {
30
+ expect(isPendingPassTransfer({ status })).toBe(false);
31
+ }
32
+ });
33
+ });
34
+
35
+ describe("the remembered-send store", () => {
36
+ it("keeps a send and reads it back, narrowed to its pass", () => {
37
+ rememberPassTransfer({ passId: "TN-1", transferId: "a", toEmail: "x@y.com", expiresAt: null });
38
+ rememberPassTransfer({ passId: "TN-2", transferId: "b", toEmail: "z@y.com", expiresAt: null });
39
+
40
+ expect(getRememberedPassTransfers()).toHaveLength(2);
41
+ expect(getRememberedPassTransfers("TN-1")).toEqual([
42
+ { passId: "TN-1", transferId: "a", toEmail: "x@y.com", expiresAt: null },
43
+ ]);
44
+ });
45
+
46
+ it("keeps at most ONE row per pass — the database allows no more", () => {
47
+ // A partial unique index enforces one live transfer per pass, so a second
48
+ // remembered row for the same pass could only ever be stale. The newest
49
+ // send wins; a stale id would offer a cancel that answers 400.
50
+ rememberPassTransfer({ passId: "TN-1", transferId: "old", toEmail: "x@y.com", expiresAt: null });
51
+ rememberPassTransfer({ passId: "TN-1", transferId: "new", toEmail: "q@y.com", expiresAt: null });
52
+
53
+ expect(getRememberedPassTransfers("TN-1")).toEqual([
54
+ { passId: "TN-1", transferId: "new", toEmail: "q@y.com", expiresAt: null },
55
+ ]);
56
+ });
57
+
58
+ it("forgets by transfer id and leaves the others alone", () => {
59
+ rememberPassTransfer({ passId: "TN-1", transferId: "a", toEmail: "x@y.com", expiresAt: null });
60
+ rememberPassTransfer({ passId: "TN-2", transferId: "b", toEmail: "z@y.com", expiresAt: null });
61
+
62
+ forgetPassTransfer("a");
63
+
64
+ expect(getRememberedPassTransfers().map((entry) => entry.transferId)).toEqual(["b"]);
65
+ // Forgetting something that was never there is a no-op, not a throw — a
66
+ // cancel whose row had already been dropped must still resolve.
67
+ expect(() => forgetPassTransfer("a")).not.toThrow();
68
+ });
69
+ });
70
+
71
+ describe("the ids a transfer can be addressed by", () => {
72
+ it("only ever sends a TN-… pass id, which is what the endpoint accepts", () => {
73
+ // The send endpoint is `/public/events/passes/:passId/transfers` and 404s on
74
+ // an order or order-item id. `myTicketPassIds` is the only source the portal
75
+ // has, so this is the contract the affordance is drawn from.
76
+ const ids = myTicketPassIds({
77
+ items: [
78
+ { id: "order-item-uuid", quantity: 2, price: 25, ticketTitle: "GA", passes: [{ id: "TN-11" }, { id: "TN-12" }] },
79
+ { id: "another-item", quantity: 1, price: 25, ticketTitle: "GA", passes: [{ id: "not-a-pass" }] },
80
+ ],
81
+ });
82
+
83
+ expect(ids).toEqual(["TN-11", "TN-12"]);
84
+ });
85
+
86
+ it("yields nothing when the API build emits no passes — draw no affordance", () => {
87
+ expect(myTicketPassIds({ items: [{ id: "i", quantity: 1, price: 10, ticketTitle: null }] })).toEqual([]);
88
+ });
89
+ });
@@ -0,0 +1,159 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { hasWalletPass, isEventPassId, type WalletPassStatus } from "../useWalletPass";
3
+ import { myTicketPassIds, type MyTicket } from "../useMyTickets";
4
+
5
+ /**
6
+ * Events 2.3 — the pure decisions behind the wallet affordance.
7
+ *
8
+ * `hasWalletPass` is the single predicate that decides whether a buyer ever
9
+ * learns the feature exists, so it gets exhaustive coverage of the ways it must
10
+ * answer NO. Everything else in the module is transport, and belongs to the
11
+ * API's own specs.
12
+ */
13
+
14
+ const status = (overrides: Partial<WalletPassStatus> = {}): WalletPassStatus => ({
15
+ passId: "TN-1001",
16
+ available: true,
17
+ reason: null,
18
+ apple: { available: true, downloadUrl: "/public/events/passes/TN-1001/wallet/apple?profileId=p1" },
19
+ google: { available: true, saveUrl: "https://pay.google.com/gp/v/save/jwt" },
20
+ ...overrides,
21
+ });
22
+
23
+ describe("hasWalletPass", () => {
24
+ it("is false for anything that is not a settled, positive answer", () => {
25
+ // No answer yet, or a query that 401'd / 404'd / failed. Absence of data is
26
+ // absence of the feature — never a placeholder.
27
+ expect(hasWalletPass(undefined)).toBe(false);
28
+ expect(hasWalletPass(null)).toBe(false);
29
+ });
30
+
31
+ it("is false for the state production is actually in today", () => {
32
+ // No signing credentials configured. This is a 200 — a successful "the
33
+ // feature is off" — and it must be indistinguishable from the feature not
34
+ // existing at all.
35
+ expect(
36
+ hasWalletPass(
37
+ status({
38
+ available: false,
39
+ reason: "not_configured",
40
+ apple: { available: false, downloadUrl: null },
41
+ google: { available: false, saveUrl: null },
42
+ }),
43
+ ),
44
+ ).toBe(false);
45
+ });
46
+
47
+ it("is false for every other refusal the API can give", () => {
48
+ for (const reason of ["rotating_qr", "order_not_active"] as const) {
49
+ expect(
50
+ hasWalletPass(
51
+ status({
52
+ available: false,
53
+ reason,
54
+ apple: { available: false, downloadUrl: null },
55
+ google: { available: false, saveUrl: null },
56
+ }),
57
+ ),
58
+ ).toBe(false);
59
+ }
60
+ });
61
+
62
+ it("is false when the API says available but neither provider produced anything", () => {
63
+ // Belt and braces: a provider can throw AFTER the credential gate passed
64
+ // (a certificate that parses but will not sign), and the API degrades the
65
+ // two providers independently. `available` alone is not enough to draw on.
66
+ expect(
67
+ hasWalletPass(
68
+ status({ apple: { available: false, downloadUrl: null }, google: { available: false, saveUrl: null } }),
69
+ ),
70
+ ).toBe(false);
71
+ });
72
+
73
+ it("is true as soon as EITHER provider has something real", () => {
74
+ expect(hasWalletPass(status({ google: { available: false, saveUrl: null } }))).toBe(true);
75
+ expect(hasWalletPass(status({ apple: { available: false, downloadUrl: null } }))).toBe(true);
76
+ expect(hasWalletPass(status())).toBe(true);
77
+ });
78
+ });
79
+
80
+ describe("isEventPassId", () => {
81
+ it("accepts only `TN-` plus digits, matching the API's own guard", () => {
82
+ expect(isEventPassId("TN-1")).toBe(true);
83
+ expect(isEventPassId("TN-000000123")).toBe(true);
84
+ });
85
+
86
+ it("rejects the ids a caller is most likely to reach for by mistake", () => {
87
+ // An order id and an order-item id are what `useMyTickets` actually hands
88
+ // back; sending either would be a guaranteed 404.
89
+ expect(isEventPassId("0f9c8b7a-1111-2222-3333-444455556666")).toBe(false);
90
+ expect(isEventPassId("TN-")).toBe(false);
91
+ expect(isEventPassId("TN-12a")).toBe(false);
92
+ expect(isEventPassId("tn-12")).toBe(false);
93
+ expect(isEventPassId(" TN-12")).toBe(false);
94
+ expect(isEventPassId(undefined)).toBe(false);
95
+ expect(isEventPassId(null)).toBe(false);
96
+ expect(isEventPassId(12)).toBe(false);
97
+ });
98
+ });
99
+
100
+ const ticket = (overrides: Partial<MyTicket> = {}): MyTicket =>
101
+ ({
102
+ id: "order-1",
103
+ items: [],
104
+ ...overrides,
105
+ }) as MyTicket;
106
+
107
+ describe("myTicketPassIds", () => {
108
+ it("is empty when the payload carries no passes at all", () => {
109
+ // An older API build, before `/public/events/my-tickets` nested passes
110
+ // under each item. The affordance must simply not appear rather than error.
111
+ expect(
112
+ myTicketPassIds(
113
+ ticket({ items: [{ id: "item-1", quantity: 2, price: 10, ticketTitle: "General Admission" }] }),
114
+ ),
115
+ ).toEqual([]);
116
+ });
117
+
118
+ it("reads pass ids from the order level", () => {
119
+ expect(myTicketPassIds(ticket({ passes: [{ id: "TN-1" }, { id: "TN-2" }] }))).toEqual(["TN-1", "TN-2"]);
120
+ });
121
+
122
+ it("reads pass ids nested under items — the shape the API actually emits", () => {
123
+ expect(
124
+ myTicketPassIds(
125
+ ticket({
126
+ items: [
127
+ { id: "item-1", quantity: 1, price: 10, ticketTitle: "GA", passes: [{ id: "TN-3" }] },
128
+ { id: "item-2", quantity: 1, price: 20, ticketTitle: "VIP", passes: [{ id: "TN-4" }] },
129
+ ],
130
+ }),
131
+ ),
132
+ ).toEqual(["TN-3", "TN-4"]);
133
+ });
134
+
135
+ it("de-duplicates across both shapes, so a payload carrying both draws one set of buttons", () => {
136
+ expect(
137
+ myTicketPassIds(
138
+ ticket({
139
+ passes: [{ id: "TN-5" }],
140
+ items: [{ id: "item-1", quantity: 1, price: 10, ticketTitle: "GA", passes: [{ id: "TN-5" }] }],
141
+ }),
142
+ ),
143
+ ).toEqual(["TN-5"]);
144
+ });
145
+
146
+ it("drops anything that is not a pass id rather than sending a request that must 404", () => {
147
+ expect(
148
+ myTicketPassIds(
149
+ ticket({
150
+ passes: [{ id: "order-1" }, { id: "" }, { id: "TN-6" }] as never,
151
+ }),
152
+ ),
153
+ ).toEqual(["TN-6"]);
154
+ });
155
+
156
+ it("survives a payload with no items array at all", () => {
157
+ expect(myTicketPassIds({ items: undefined as never, passes: undefined })).toEqual([]);
158
+ });
159
+ });
@@ -1,4 +1,4 @@
1
- import type { BookingSlot, PillarDiscountQuote } from "../../types/models";
1
+ import type { BookingSlot, CancellationTermsView, PillarDiscountQuote } from "../../types/models";
2
2
  import { useForge } from "../../provider/ForgeProvider";
3
3
  import { useMutation, useQuery } from "@tanstack/react-query";
4
4
 
@@ -18,11 +18,22 @@ export function useCoachingAvailability(productId?: string, fromDate?: string, t
18
18
  });
19
19
  }
20
20
 
21
- /** Reserve a coaching booking slot. Returns the created booking id. */
21
+ export type ReserveCoachingBookingResult = {
22
+ bookingId: string;
23
+ totalAmount?: number;
24
+ /**
25
+ * The cancellation terms SNAPSHOTTED onto this booking (S.6). Authoritative
26
+ * from here on — the product's live policy may be edited while the hour is
27
+ * held, and the buyer's rights are governed by this copy, not by that read.
28
+ */
29
+ cancellation?: CancellationTermsView | null;
30
+ };
31
+
32
+ /** Reserve a coaching booking slot. Returns the created booking + its agreed terms. */
22
33
  export function useReserveCoachingBooking(productId?: string) {
23
34
  const { client } = useForge();
24
35
 
25
- return useMutation<{ bookingId: string }, unknown, { slotId: string }>({
36
+ return useMutation<ReserveCoachingBookingResult, unknown, { slotId: string }>({
26
37
  mutationFn: async ({ slotId }) => {
27
38
  const res = await client.post(`/public/coaching/products/${productId}/booking/reserve`, {
28
39
  slotId,
@@ -0,0 +1,42 @@
1
+ import type { IEventSeries } from "../../types/models";
2
+ import { useForge } from "../../provider/ForgeProvider";
3
+ import { useQuery } from "@tanstack/react-query";
4
+
5
+ /**
6
+ * A named, multi-date event series by slug (or id).
7
+ *
8
+ * ## There is deliberately no `useEventSeriesList`
9
+ *
10
+ * Every event belongs to a series, so a list endpoint would be a list of every
11
+ * event on the site wearing a different hat — and the ones the API refuses to
12
+ * publish (unnamed containers, single-date shows) would show up as holes in it.
13
+ * A series is a DESTINATION the operator shares, not a browsable index; the
14
+ * events list is already the browsable index.
15
+ *
16
+ * ## A 404 is the normal answer
17
+ *
18
+ * The API refuses any series that nobody named or that holds fewer than two
19
+ * publicly-visible dates. That is not an error state to surface as "something
20
+ * went wrong" — it means there is no such page — so a renderer should treat
21
+ * `isError` here the same way it treats a missing event.
22
+ *
23
+ * Pass `initialData` (a series fetched server-side in a route loader) to seed
24
+ * the query, so the page server-renders for SEO with no client spinner.
25
+ */
26
+ export function useEventSeries(slugOrId?: string, options?: { initialData?: IEventSeries }) {
27
+ const { client, profileId } = useForge();
28
+
29
+ return useQuery<IEventSeries>({
30
+ queryKey: ["event-series", slugOrId, profileId],
31
+ queryFn: async () => {
32
+ const res = await client.get(`/public/event-series/${encodeURIComponent(slugOrId!)}`, {
33
+ params: { profileId },
34
+ });
35
+ return res.data;
36
+ },
37
+ enabled: !!slugOrId && !!profileId && !!client,
38
+ initialData: options?.initialData,
39
+ // A 404 here means "no such page", which no amount of retrying changes.
40
+ retry: false,
41
+ });
42
+ }