@tribe-nest/forge 2.2.0 → 3.4.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 (54) hide show
  1. package/package.json +1 -1
  2. package/src/client/createForgeClient.ts +85 -2
  3. package/src/client/tokenStorage.ts +31 -0
  4. package/src/contexts/AppAuthContext.tsx +100 -15
  5. package/src/contexts/PublicAuthContext.tsx +46 -9
  6. package/src/data/queries/useCheckouts.ts +84 -1
  7. package/src/data/queries/useCoachingAvailability.ts +18 -3
  8. package/src/data/queries/useCourseAccess.ts +1 -1
  9. package/src/data/queries/useCourses.ts +42 -1
  10. package/src/data/queries/useEvents.ts +15 -1
  11. package/src/data/queries/usePaymentFlow.ts +12 -0
  12. package/src/data/queries/useWebsite.ts +6 -0
  13. package/src/index.ts +19 -2
  14. package/src/provider/ForgeProvider.tsx +30 -1
  15. package/src/server/_tests/platformEvents.spec.ts +315 -0
  16. package/src/server/index.ts +17 -0
  17. package/src/server/jobs.ts +41 -10
  18. package/src/server/platform.ts +234 -9
  19. package/src/server/platformEvents.generated.ts +422 -0
  20. package/src/types/models.ts +110 -3
  21. package/src/ui/headless/auth/useSignupForm.ts +69 -4
  22. package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
  23. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
  24. package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
  25. package/src/ui/headless/checkout/useCheckout.ts +156 -8
  26. package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
  27. package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
  28. package/src/ui/headless/coupon/useCouponField.ts +164 -0
  29. package/src/ui/headless/course/useCourseCheckout.ts +113 -18
  30. package/src/ui/headless/event/useEventCheckout.ts +53 -2
  31. package/src/ui/headless/index.ts +15 -0
  32. package/src/ui/headless/work/useWorkPortal.ts +24 -21
  33. package/src/ui/index.ts +7 -0
  34. package/src/ui/shell/PoweredBy.tsx +60 -0
  35. package/src/ui/shell/TribeNestApp.tsx +15 -1
  36. package/src/ui/shell/shellGating.spec.ts +21 -1
  37. package/src/ui/shell/shellGating.ts +14 -0
  38. package/src/ui/styled/AddToCalendar.tsx +104 -0
  39. package/src/ui/styled/Checkout.tsx +45 -14
  40. package/src/ui/styled/CoachingBooking.tsx +28 -8
  41. package/src/ui/styled/CoachingConfirmation.tsx +12 -0
  42. package/src/ui/styled/CourseCheckout.tsx +49 -18
  43. package/src/ui/styled/DiscountCode.tsx +206 -0
  44. package/src/ui/styled/EventConfirmation.tsx +68 -22
  45. package/src/ui/styled/EventDetail.tsx +18 -5
  46. package/src/ui/styled/EventTickets.tsx +49 -5
  47. package/src/ui/styled/SignupForm.tsx +86 -35
  48. package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
  49. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
  50. package/src/ui/styled/work/WorkInviteAccept.tsx +54 -5
  51. package/src/utils/_tests/safeRedirect.spec.ts +117 -0
  52. package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
  53. package/src/utils/safeRedirect.ts +41 -0
  54. package/src/utils/ticketOrderOutcome.ts +125 -0
@@ -0,0 +1,126 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ getTicketOrderOutcome,
4
+ isTicketOrderRefunded,
5
+ TICKET_ORDER_REFUNDED_COPY,
6
+ } from "../ticketOrderOutcome";
7
+ import { OrderStatus } from "../../types/models";
8
+
9
+ /**
10
+ * The whole point of this module: `event_ticket_orders.status = cancelled`
11
+ * means two different things and the buyer must not be told the wrong one.
12
+ *
13
+ * The fixtures below mirror exactly what the backend writes:
14
+ * - abandoned → the sweeper only ever touches `initiated_payment` rows, so the
15
+ * refund columns are still at their defaults (0 / "none");
16
+ * - refund-voided → `adjustPillarRefundTracking` runs BEFORE the void, and the
17
+ * void is gated on a FULL refund, so the row is (>0 / "full") by then.
18
+ */
19
+ const abandoned = {
20
+ status: OrderStatus.Cancelled,
21
+ refundedAmountCents: 0,
22
+ refundState: "none",
23
+ };
24
+
25
+ const refundVoided = {
26
+ status: OrderStatus.Cancelled,
27
+ // bigint over JSON — a string, which is the shape the API really sends.
28
+ refundedAmountCents: "2500",
29
+ refundState: "full",
30
+ };
31
+
32
+ describe("getTicketOrderOutcome", () => {
33
+ it("treats every fulfillment state of a successful order as paid", () => {
34
+ for (const status of [
35
+ OrderStatus.Paid,
36
+ OrderStatus.Processing,
37
+ OrderStatus.LabelPurchased,
38
+ OrderStatus.Shipped,
39
+ OrderStatus.Delivered,
40
+ OrderStatus.Processed,
41
+ ]) {
42
+ expect(getTicketOrderOutcome({ status })).toBe("paid");
43
+ }
44
+ });
45
+
46
+ it("calls a refund-voided cancelled order refunded, not not-completed", () => {
47
+ expect(getTicketOrderOutcome(refundVoided)).toBe("refunded");
48
+ });
49
+
50
+ it("still calls a swept, never-paid cancelled order not_completed", () => {
51
+ // Regression guard: this path is unchanged and MUST stay — the abandoned
52
+ // copy is correct for a buyer who never paid.
53
+ expect(getTicketOrderOutcome(abandoned)).toBe("not_completed");
54
+ });
55
+
56
+ it("treats a cancelled order with no refund fields at all as not_completed", () => {
57
+ // Rows written before the refund columns existed, and any caller that
58
+ // trims the payload: absence of evidence is not evidence of a refund.
59
+ expect(getTicketOrderOutcome({ status: OrderStatus.Cancelled })).toBe("not_completed");
60
+ expect(
61
+ getTicketOrderOutcome({ status: OrderStatus.Cancelled, refundedAmountCents: null, refundState: null }),
62
+ ).toBe("not_completed");
63
+ });
64
+
65
+ it("keeps a failed payment not_completed even though it is not cancelled", () => {
66
+ expect(getTicketOrderOutcome({ status: OrderStatus.PaymentFailed })).toBe("not_completed");
67
+ expect(getTicketOrderOutcome({ status: OrderStatus.InitiatedPayment })).toBe("not_completed");
68
+ expect(getTicketOrderOutcome({ status: OrderStatus.Failed })).toBe("not_completed");
69
+ });
70
+
71
+ it("leaves a PARTIALLY refunded, still-valid order as paid", () => {
72
+ // A partial refund voids nothing (786a20bc's rule) — the pass still admits
73
+ // entry, and this screen is about whether it does. Showing "refunded" here
74
+ // would be the mirror-image lie.
75
+ expect(
76
+ getTicketOrderOutcome({ status: OrderStatus.Paid, refundedAmountCents: "500", refundState: "partial" }),
77
+ ).toBe("paid");
78
+ });
79
+
80
+ it("tolerates a garbage refunded amount rather than inventing a refund", () => {
81
+ expect(
82
+ getTicketOrderOutcome({ status: OrderStatus.Cancelled, refundedAmountCents: "not-a-number" }),
83
+ ).toBe("not_completed");
84
+ });
85
+
86
+ it("falls back to refund_state when the amount is missing", () => {
87
+ // The two are written together, so this should not happen — but if it does,
88
+ // the state is still evidence money moved.
89
+ expect(getTicketOrderOutcome({ status: OrderStatus.Cancelled, refundState: "full" })).toBe("refunded");
90
+ });
91
+ });
92
+
93
+ describe("isTicketOrderRefunded", () => {
94
+ it("is false for an abandoned order and true for a refunded one", () => {
95
+ expect(isTicketOrderRefunded(abandoned)).toBe(false);
96
+ expect(isTicketOrderRefunded(refundVoided)).toBe(true);
97
+ });
98
+
99
+ it("does not treat a zero-cent refund row as a refund", () => {
100
+ expect(isTicketOrderRefunded({ status: OrderStatus.Cancelled, refundedAmountCents: "0", refundState: "none" })).toBe(
101
+ false,
102
+ );
103
+ });
104
+ });
105
+
106
+ describe("TICKET_ORDER_REFUNDED_COPY", () => {
107
+ it("never tells a refunded buyer their order was not completed", () => {
108
+ const all = Object.values(TICKET_ORDER_REFUNDED_COPY).join(" ").toLowerCase();
109
+ expect(all).not.toContain("not completed");
110
+ expect(all).not.toContain("contact support");
111
+ expect(all).not.toContain("if you were charged");
112
+ });
113
+
114
+ it("promises no settlement deadline we do not control", () => {
115
+ const body = TICKET_ORDER_REFUNDED_COPY.body.toLowerCase();
116
+ // No "5-10 business days" style claim: settlement is the provider's and the
117
+ // buyer's bank's, not ours.
118
+ expect(body).not.toMatch(/\d+\s*(-|–|to)?\s*\d*\s*(business\s+)?days?/);
119
+ expect(body).not.toContain("working days");
120
+ expect(body).toContain("bank or card issuer");
121
+ });
122
+
123
+ it("says the tickets no longer admit entry", () => {
124
+ expect(TICKET_ORDER_REFUNDED_COPY.body.toLowerCase()).toContain("will not admit entry");
125
+ });
126
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Sanitize a `?redirect=` target before navigating to it (C13).
3
+ *
4
+ * The login and signup templates assigned the raw query parameter to
5
+ * `window.location.href`. Two bugs in one line:
6
+ *
7
+ * - **Reflected XSS.** A `javascript:` target executes on the artist's REAL
8
+ * domain, after a genuine successful login — so it steals a live member
9
+ * token from a page the visitor has every reason to trust.
10
+ * - **Open redirect.** An absolute URL sends a just-authenticated fan
11
+ * off-site with the artist's domain as the referrer.
12
+ *
13
+ * The rule is deliberately strict: a same-origin ROOT-RELATIVE path, nothing
14
+ * else. Not "block javascript:" — a scheme denylist loses to tab/newline
15
+ * smuggling, `data:`, `vbscript:`, and whatever the next parser quirk turns out
16
+ * to be. Anything that fails falls back to `fallback`, so a mangled link still
17
+ * lands the user somewhere sensible instead of erroring.
18
+ *
19
+ * Rejected: absolute URLs, scheme-relative (`//evil.example`), the backslash
20
+ * variant (`/\\evil.example`, which browsers normalize to `//`), control
21
+ * characters used to smuggle a scheme past a naive check, and anything not
22
+ * starting with `/`.
23
+ */
24
+ export const safeRedirectPath = (value: unknown, fallback = "/i/account"): string => {
25
+ if (typeof value !== "string") return fallback;
26
+
27
+ // Strip characters browsers ignore when parsing a URL scheme: a tab or
28
+ // newline inside "java<TAB>script:" is dropped before the scheme is read.
29
+ const cleaned = value.replace(/[\u0000-\u0020]/g, "").trim();
30
+ if (!cleaned) return fallback;
31
+
32
+ // Must be root-relative. This single check rejects `javascript:`, `data:`,
33
+ // `https://evil` and a bare `evil.example` in one go.
34
+ if (!cleaned.startsWith("/")) return fallback;
35
+
36
+ // `//host` and `/\host` are protocol-relative — off-site despite the leading
37
+ // slash.
38
+ if (cleaned.startsWith("//") || cleaned.startsWith("/\\")) return fallback;
39
+
40
+ return cleaned;
41
+ };
@@ -0,0 +1,125 @@
1
+ import { OrderStatus } from "../types/models";
2
+
3
+ /**
4
+ * What a buyer looking at their ticket confirmation should be told.
5
+ *
6
+ * ── Why this exists ──────────────────────────────────────────────────────────
7
+ * `event_ticket_orders.status = cancelled` means TWO different things, and the
8
+ * schema carries no discriminator column for them:
9
+ *
10
+ * 1. **Abandoned** — the buyer opened checkout, never paid, and
11
+ * `SWEEP_ABANDONED_TICKET_ORDERS_JOB` retired the row. No money ever moved.
12
+ * 2. **Refund-voided** — the buyer DID pay, was refunded in full, and the
13
+ * refund voided the order so the released seat cannot also scan at the
14
+ * door (see `releaseInventoryForSource` / `voidTicketOrder`).
15
+ *
16
+ * Both surfaces used to render case 1's copy for both: "Order not completed —
17
+ * if you were charged, contact support." Told to someone who was charged and
18
+ * refunded, that is simply false.
19
+ *
20
+ * ── The signal, and why it is trustworthy ────────────────────────────────────
21
+ * `refunded_amount_cents` (> 0 once any money has gone back) and `refund_state`
22
+ * ("none" | "partial" | "full"). Both live on `event_ticket_orders` and both
23
+ * reach the buyer already: the public finalize endpoint answers with
24
+ * `EventTicketOrder.getOrderById`, which is `selectAll("eto")` — the whole row.
25
+ * No serializer change was needed to read them.
26
+ *
27
+ * They separate the two cases cleanly because of where each is written:
28
+ *
29
+ * • The sweeper matches ONLY `status = initiated_payment` — an order that was
30
+ * never paid — and touches no refund column. An abandoned order therefore
31
+ * always carries `refunded_amount_cents = 0` / `refund_state = 'none'`
32
+ * (the column defaults), and it is the only other writer of `cancelled` on
33
+ * this table.
34
+ * • Every path that voids a paid ticket order runs through the refund
35
+ * service, which writes `adjustPillarRefundTracking` BEFORE it voids
36
+ * (`recordRefund`: pillar sync, then `releaseInventoryForSource`). Both void
37
+ * triggers — the seat release and an explicit `revokeAccess` — are gated on
38
+ * `isFullyRefunded`, which requires `capturedAmountCents > 0`, so the state
39
+ * written is always `"full"`, never `"partial"`.
40
+ *
41
+ * So the order of writes guarantees that a refund-voided order is never
42
+ * observed with zero refunded, and the sweeper guarantees an abandoned order is
43
+ * never observed with more than zero.
44
+ *
45
+ * `refunded_amount_cents > 0` is the primary test rather than
46
+ * `refund_state === "full"`: it is the value money is actually counted in, and
47
+ * it stays honest if a future partial-refund flow ever voids an order.
48
+ */
49
+ export type TicketOrderOutcome =
50
+ /** Paid (or free) and still valid — the ticket admits entry. */
51
+ | "paid"
52
+ /** Was paid, money has gone back, the ticket no longer admits entry. */
53
+ | "refunded"
54
+ /** Never paid — abandoned checkout or a failed payment. */
55
+ | "not_completed";
56
+
57
+ /** The only fields of a ticket order this decision reads. */
58
+ export interface TicketOrderOutcomeInput {
59
+ status: OrderStatus | string;
60
+ /** Bigint column — arrives over JSON as a string. */
61
+ refundedAmountCents?: number | string | null;
62
+ refundState?: string | null;
63
+ }
64
+
65
+ /**
66
+ * A paid (or free) order advances through fulfillment — processing → shipped /
67
+ * delivered / processed. All of these mean the order succeeded, so the ticket
68
+ * confirmation treats them as "paid"; only failed/pending-payment states are
69
+ * "not completed". (A free order can be `delivered` by the time this loads.)
70
+ */
71
+ const PAID_STATES = new Set<string>([
72
+ OrderStatus.Paid,
73
+ OrderStatus.Processing,
74
+ OrderStatus.LabelPurchased,
75
+ OrderStatus.Shipped,
76
+ OrderStatus.Delivered,
77
+ OrderStatus.Processed,
78
+ ]);
79
+
80
+ /** Has any money gone back on this order? */
81
+ export function isTicketOrderRefunded(order: TicketOrderOutcomeInput): boolean {
82
+ const cents = Number(order.refundedAmountCents ?? 0);
83
+ if (Number.isFinite(cents) && cents > 0) return true;
84
+ // Belt and braces: a `refund_state` of full/partial without a positive amount
85
+ // should not happen (they are written together), but if it ever does, the
86
+ // state is still evidence that money moved — and saying "refunded" to someone
87
+ // who was refunded is the failure we can afford.
88
+ return order.refundState === "full" || order.refundState === "partial";
89
+ }
90
+
91
+ /**
92
+ * Which of the three things happened to this ticket order.
93
+ *
94
+ * A still-valid order that carries a PARTIAL refund stays `"paid"` — the pass
95
+ * still admits entry, which is what this screen is about. Only an order that no
96
+ * longer admits entry AND has money back is `"refunded"`.
97
+ */
98
+ export function getTicketOrderOutcome(order: TicketOrderOutcomeInput): TicketOrderOutcome {
99
+ if (PAID_STATES.has(order.status)) return "paid";
100
+ if (isTicketOrderRefunded(order)) return "refunded";
101
+ return "not_completed";
102
+ }
103
+
104
+ /**
105
+ * The refunded copy, shared verbatim by both rendering surfaces (the Forge
106
+ * `EventConfirmation` block and the client app's `/events/:id/finalise` page)
107
+ * so the two can never drift.
108
+ *
109
+ * Deliberately says nothing about how many days the money takes: settlement is
110
+ * the payment provider's and the buyer's bank's, not ours, and a number we
111
+ * cannot honour is the same kind of lie as "contact support" was.
112
+ *
113
+ * Equally deliberately does NOT invite the buyer to contact support: nothing
114
+ * went wrong here. The refund is the intended outcome.
115
+ */
116
+ export const TICKET_ORDER_REFUNDED_COPY = {
117
+ /** Small uppercase label above the heading. */
118
+ eyebrow: "Refunded",
119
+ heading: "This order was refunded",
120
+ body:
121
+ "Your payment has been sent back to the original payment method — how long it takes to appear is up to your bank or card issuer. " +
122
+ "These tickets have been cancelled and will not admit entry.",
123
+ /** Replaces "Total paid" on the amount line. */
124
+ totalLabel: "Total refunded",
125
+ } as const;