@tribe-nest/forge 3.2.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.
- package/package.json +1 -1
- package/src/data/queries/_tests/eventWaitlist.spec.ts +122 -0
- package/src/data/queries/_tests/passTransfers.spec.ts +89 -0
- package/src/data/queries/_tests/walletPass.spec.ts +159 -0
- package/src/data/queries/useCheckouts.ts +84 -1
- package/src/data/queries/useCoachingAvailability.ts +18 -3
- package/src/data/queries/useCourses.ts +42 -1
- package/src/data/queries/useEventWaitlist.ts +429 -0
- package/src/data/queries/useEvents.ts +15 -1
- package/src/data/queries/useMyBookings.ts +211 -0
- package/src/data/queries/useMyTickets.ts +154 -0
- package/src/data/queries/usePassTransfers.ts +318 -0
- package/src/data/queries/usePaymentFlow.ts +12 -0
- package/src/data/queries/useWalletPass.ts +236 -0
- package/src/data/queries/useWebsite.ts +6 -0
- package/src/index.ts +14 -0
- package/src/server/_tests/siteBootstrap.spec.ts +131 -0
- package/src/server/index.ts +122 -6
- package/src/types/diagnostics.ts +49 -0
- package/src/types/models.ts +66 -0
- package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
- package/src/ui/headless/checkout/useCheckout.ts +156 -8
- package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
- package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
- package/src/ui/headless/coupon/useCouponField.ts +164 -0
- package/src/ui/headless/course/useCourseCheckout.ts +113 -18
- package/src/ui/headless/event/useEventCheckout.ts +53 -2
- package/src/ui/headless/index.ts +15 -0
- package/src/ui/index.ts +26 -0
- package/src/ui/shell/PoweredBy.tsx +62 -0
- package/src/ui/shell/PreviewDiagnostics.tsx +80 -0
- package/src/ui/shell/TribeNestApp.tsx +39 -1
- package/src/ui/shell/diagnosticsGating.spec.ts +102 -0
- package/src/ui/shell/diagnosticsGating.ts +90 -0
- package/src/ui/shell/shellGating.spec.ts +60 -1
- package/src/ui/shell/shellGating.ts +47 -0
- package/src/ui/styled/AccountDashboard.tsx +598 -1
- package/src/ui/styled/AddToCalendar.tsx +104 -0
- package/src/ui/styled/Checkout.tsx +45 -14
- package/src/ui/styled/CoachingBooking.tsx +28 -8
- package/src/ui/styled/CoachingConfirmation.tsx +12 -0
- package/src/ui/styled/CourseCheckout.tsx +49 -18
- package/src/ui/styled/DiscountCode.tsx +206 -0
- package/src/ui/styled/EventConfirmation.tsx +68 -22
- package/src/ui/styled/EventDetail.tsx +24 -5
- package/src/ui/styled/EventTickets.tsx +49 -5
- package/src/ui/styled/EventWaitlist.tsx +448 -0
- package/src/ui/styled/TicketTransfer.tsx +393 -0
- package/src/ui/styled/WalletPassButtons.tsx +208 -0
- package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
- package/src/ui/styled/_tests/WalletPassButtons.spec.tsx +223 -0
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
- package/src/utils/ticketOrderOutcome.ts +125 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
4
|
+
import { DiscountCodeField, DiscountSummaryLines } from "../DiscountCode";
|
|
5
|
+
import type { CouponField } from "../../headless/coupon/useCouponField";
|
|
6
|
+
import type { PillarDiscountQuote } from "../../../types/models";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rendered through `react-dom/server`, which needs no DOM — so these run in the
|
|
10
|
+
* package's existing node vitest environment with no new dependency and no
|
|
11
|
+
* jsdom project. Effects never fire under `renderToStaticMarkup`, so the
|
|
12
|
+
* `CouponField` fixture is the only data source and nothing reaches the network.
|
|
13
|
+
*
|
|
14
|
+
* `DiscountCodeField` and `DiscountSummaryLines` are the REAL components every
|
|
15
|
+
* ticket, booking, course and (client-app) checkout renders, so these assertions
|
|
16
|
+
* are about shipped behaviour, not a harness.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fmt = (n: number) => `$${n.toFixed(2)}`;
|
|
20
|
+
|
|
21
|
+
/** A `CouponField` in a given state. The setters are inert — nothing clicks here. */
|
|
22
|
+
function field(overrides: Partial<CouponField> = {}): CouponField {
|
|
23
|
+
const quote = overrides.quote ?? null;
|
|
24
|
+
return {
|
|
25
|
+
code: "",
|
|
26
|
+
setCode: () => {},
|
|
27
|
+
submittedCode: undefined,
|
|
28
|
+
showInput: false,
|
|
29
|
+
setShowInput: () => {},
|
|
30
|
+
error: null,
|
|
31
|
+
setError: () => {},
|
|
32
|
+
isApplying: false,
|
|
33
|
+
quote,
|
|
34
|
+
appliedCoupons: quote?.appliedCoupons ?? [],
|
|
35
|
+
discountAmount: quote?.discountAmount ?? 0,
|
|
36
|
+
hasDiscount: (quote?.discountAmount ?? 0) > 0,
|
|
37
|
+
apply: async () => {},
|
|
38
|
+
remove: async () => {},
|
|
39
|
+
record: () => {},
|
|
40
|
+
fail: () => {},
|
|
41
|
+
reset: () => {},
|
|
42
|
+
canQuote: true,
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What the server returns once SUMMER10 has been applied to a $50 course. */
|
|
48
|
+
const appliedQuote: PillarDiscountQuote = {
|
|
49
|
+
subTotal: 50,
|
|
50
|
+
totalAmount: 42.5,
|
|
51
|
+
discountAmount: 7.5,
|
|
52
|
+
couponId: "coupon-1",
|
|
53
|
+
appliedCoupons: [{ code: "SUMMER10", discountKind: "simple", discountAmount: 7.5 }],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** The undiscounted answer — what `remove()` puts back. */
|
|
57
|
+
const clearedQuote: PillarDiscountQuote = {
|
|
58
|
+
subTotal: 50,
|
|
59
|
+
totalAmount: 50,
|
|
60
|
+
discountAmount: 0,
|
|
61
|
+
couponId: null,
|
|
62
|
+
appliedCoupons: [],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The summary as every checkout composes it: the lines, then the total. */
|
|
66
|
+
function summary(coupon: CouponField, total: number) {
|
|
67
|
+
return renderToStaticMarkup(
|
|
68
|
+
<ForgeThemeProvider theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}>
|
|
69
|
+
<div>
|
|
70
|
+
<DiscountSummaryLines
|
|
71
|
+
subTotal={coupon.quote?.subTotal ?? total}
|
|
72
|
+
discountAmount={coupon.discountAmount}
|
|
73
|
+
appliedCoupons={coupon.appliedCoupons}
|
|
74
|
+
fmt={fmt}
|
|
75
|
+
/>
|
|
76
|
+
<div>
|
|
77
|
+
<span>Total</span>
|
|
78
|
+
<span>{fmt(total)}</span>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</ForgeThemeProvider>,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function input(coupon: CouponField, props: Partial<{ mode: "quote" | "deferred"; disabled: boolean }> = {}) {
|
|
86
|
+
return renderToStaticMarkup(
|
|
87
|
+
<ForgeThemeProvider theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}>
|
|
88
|
+
<DiscountCodeField coupon={coupon} fmt={fmt} {...props} />
|
|
89
|
+
</ForgeThemeProvider>,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The markup carries HTML entities for the minus sign — normalise. */
|
|
94
|
+
const plain = (html: string) => html.replace(/−|−/g, "−");
|
|
95
|
+
|
|
96
|
+
describe("discount code — applying a valid code", () => {
|
|
97
|
+
const applied = field({ quote: appliedQuote, code: "SUMMER10", showInput: true });
|
|
98
|
+
|
|
99
|
+
it("shows a Discount LINE and the reduced total, not just a smaller number", () => {
|
|
100
|
+
const html = plain(summary(applied, appliedQuote.totalAmount));
|
|
101
|
+
|
|
102
|
+
// The line the whole feature exists for.
|
|
103
|
+
expect(html).toContain('data-testid="discount-line"');
|
|
104
|
+
expect(html).toContain("−$7.50");
|
|
105
|
+
// …with the subtotal beside it, so the buyer can see what it came off.
|
|
106
|
+
expect(html).toContain("Subtotal");
|
|
107
|
+
expect(html).toContain("$50.00");
|
|
108
|
+
// …and the reduced total.
|
|
109
|
+
expect(html).toContain("$42.50");
|
|
110
|
+
// The arithmetic on screen is the SERVER's and is self-consistent.
|
|
111
|
+
expect(appliedQuote.subTotal - appliedQuote.discountAmount).toBe(appliedQuote.totalAmount);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("names the code that produced the discount", () => {
|
|
115
|
+
expect(plain(summary(applied, appliedQuote.totalAmount))).toContain("Discount (SUMMER10)");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("shows the applied code with what it saved, and a way to remove it", () => {
|
|
119
|
+
const html = input(applied);
|
|
120
|
+
expect(html).toContain('data-testid="discount-code-applied"');
|
|
121
|
+
expect(html).toContain("SUMMER10");
|
|
122
|
+
expect(html).toContain("$7.50 off");
|
|
123
|
+
expect(html).toContain('data-testid="discount-code-remove"');
|
|
124
|
+
// The prompt is gone once something is applied.
|
|
125
|
+
expect(html).not.toContain('data-testid="discount-code-open"');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("discount code — an AUTOMATIC (no-code) discount", () => {
|
|
130
|
+
// The buyer typed nothing. Without this, the only evidence is a total that
|
|
131
|
+
// silently shrank.
|
|
132
|
+
const auto = field({
|
|
133
|
+
quote: {
|
|
134
|
+
subTotal: 50,
|
|
135
|
+
totalAmount: 45,
|
|
136
|
+
discountAmount: 5,
|
|
137
|
+
couponId: "coupon-auto",
|
|
138
|
+
appliedCoupons: [{ code: "AUTUMN", discountKind: "simple", discountAmount: 5 }],
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("is shown as applied even though the input was never opened", () => {
|
|
143
|
+
const html = input(auto);
|
|
144
|
+
expect(html).toContain('data-testid="discount-code-applied"');
|
|
145
|
+
expect(html).toContain("AUTUMN");
|
|
146
|
+
expect(html).toContain("$5.00 off");
|
|
147
|
+
expect(html).not.toContain('data-testid="discount-code-input"');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("gets its own discount line", () => {
|
|
151
|
+
const html = plain(summary(auto, 45));
|
|
152
|
+
expect(html).toContain('data-testid="discount-line"');
|
|
153
|
+
expect(html).toContain("Discount (AUTUMN)");
|
|
154
|
+
expect(html).toContain("−$5.00");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("discount code — a rejected code", () => {
|
|
159
|
+
it("shows the API's own reason, not a generic 'invalid code'", () => {
|
|
160
|
+
// Exactly what `errors.coupon.min_order_value_not_met` renders to in `en`.
|
|
161
|
+
const html = input(
|
|
162
|
+
field({ code: "SUMMER10", showInput: true, error: "Your order does not reach this coupon's minimum spend" }),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(html).toContain('data-testid="discount-code-error"');
|
|
166
|
+
expect(html).toContain("Your order does not reach this coupon");
|
|
167
|
+
expect(html).toContain("minimum spend");
|
|
168
|
+
// The generic copy that would have thrown that reason away.
|
|
169
|
+
expect(html.toLowerCase()).not.toContain("invalid coupon code");
|
|
170
|
+
expect(html.toLowerCase()).not.toContain("invalid code");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("carries each distinct reason through verbatim", () => {
|
|
174
|
+
for (const reason of [
|
|
175
|
+
"This coupon has expired",
|
|
176
|
+
"You have already used this coupon the maximum number of times",
|
|
177
|
+
"This coupon is only available to members of a specific tier",
|
|
178
|
+
"This coupon does not apply to anything in this order",
|
|
179
|
+
]) {
|
|
180
|
+
expect(input(field({ code: "X", showInput: true, error: reason }))).toContain(reason);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("does not record a discount for a code that was refused", () => {
|
|
185
|
+
// The total must keep matching the server: a refused code leaves the
|
|
186
|
+
// previous (here: absent) quote standing.
|
|
187
|
+
const html = plain(summary(field({ code: "X", showInput: true, error: "This coupon has expired" }), 50));
|
|
188
|
+
expect(html).not.toContain('data-testid="discount-line"');
|
|
189
|
+
expect(html).toContain("$50.00");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("discount code — removing a code", () => {
|
|
194
|
+
it("restores the original total and drops the discount line", () => {
|
|
195
|
+
// The state after `remove()` re-quoted with no code.
|
|
196
|
+
const html = plain(summary(field({ quote: clearedQuote }), clearedQuote.totalAmount));
|
|
197
|
+
|
|
198
|
+
expect(html).not.toContain('data-testid="discount-line"');
|
|
199
|
+
expect(html).not.toContain('data-testid="discount-summary"');
|
|
200
|
+
expect(html).toContain("$50.00");
|
|
201
|
+
expect(html).not.toContain("$42.50");
|
|
202
|
+
expect(clearedQuote.totalAmount).toBe(appliedQuote.subTotal);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("puts the 'have a code?' prompt back", () => {
|
|
206
|
+
const html = input(field({ quote: clearedQuote }));
|
|
207
|
+
expect(html).toContain('data-testid="discount-code-open"');
|
|
208
|
+
expect(html).toContain("Have a discount code?");
|
|
209
|
+
expect(html).not.toContain('data-testid="discount-code-applied"');
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("discount code — a checkout with no code (regression)", () => {
|
|
214
|
+
it("adds NOTHING to the summary", () => {
|
|
215
|
+
// The only thing inserted into every existing order summary is
|
|
216
|
+
// <DiscountSummaryLines>, and with no discount it must render nothing at
|
|
217
|
+
// all — so an undiscounted checkout looks exactly as it did before.
|
|
218
|
+
const html = renderToStaticMarkup(
|
|
219
|
+
<ForgeThemeProvider theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}>
|
|
220
|
+
<DiscountSummaryLines subTotal={50} discountAmount={0} fmt={fmt} />
|
|
221
|
+
</ForgeThemeProvider>,
|
|
222
|
+
);
|
|
223
|
+
// Only the provider's own theme <style> — the component itself emits nothing.
|
|
224
|
+
expect(html.replace(/<style>[\s\S]*?<\/style>/, "")).toBe("");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("offers only a collapsed prompt — no input, no error, no applied block", () => {
|
|
228
|
+
const html = input(field());
|
|
229
|
+
expect(html).toContain('data-testid="discount-code-open"');
|
|
230
|
+
expect(html).not.toContain('data-testid="discount-code-input"');
|
|
231
|
+
expect(html).not.toContain('data-testid="discount-code-error"');
|
|
232
|
+
expect(html).not.toContain('data-testid="discount-code-applied"');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("renders the untouched total", () => {
|
|
236
|
+
expect(plain(summary(field(), 50))).toContain("$50.00");
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("discount code — the two apply modes", () => {
|
|
241
|
+
it("offers an Apply button where the pillar can really re-quote", () => {
|
|
242
|
+
const html = input(field({ showInput: true, code: "X" }), { mode: "quote" });
|
|
243
|
+
expect(html).toContain('data-testid="discount-code-apply"');
|
|
244
|
+
expect(html).not.toContain("applied at the next step");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("offers no Apply button where the code can only be redeemed by the next call", () => {
|
|
248
|
+
// Tickets and bundles: `POST …/orders` and `POST /public/checkouts` CREATE
|
|
249
|
+
// the record, so there is nothing to press that would price a code without
|
|
250
|
+
// leaving a real abandoned order behind.
|
|
251
|
+
const html = input(field({ showInput: true, code: "X" }), { mode: "deferred" });
|
|
252
|
+
expect(html).not.toContain('data-testid="discount-code-apply"');
|
|
253
|
+
expect(html).toContain("Your code is applied at the next step.");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("freezes once the code can no longer be changed", () => {
|
|
257
|
+
// A created bundle: the code was consumed by the call that made it.
|
|
258
|
+
const html = input(field({ quote: appliedQuote }), { disabled: true });
|
|
259
|
+
expect(html).toContain('data-testid="discount-code-applied"');
|
|
260
|
+
expect(html).toContain("SUMMER10");
|
|
261
|
+
expect(html).not.toContain('data-testid="discount-code-remove"');
|
|
262
|
+
expect(html).not.toContain('data-testid="discount-code-open"');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("hides Remove for a pillar that cannot un-apply in place", () => {
|
|
266
|
+
// Event tickets expose their own "Remove discount" on the payment step,
|
|
267
|
+
// which restarts the order; the field itself must not pretend otherwise.
|
|
268
|
+
const html = input(field({ quote: appliedQuote, canQuote: false }));
|
|
269
|
+
expect(html).toContain('data-testid="discount-code-applied"');
|
|
270
|
+
expect(html).not.toContain('data-testid="discount-code-remove"');
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { ForgeClientProvider } from "../../../provider/ForgeProvider";
|
|
5
|
+
import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
|
|
6
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
7
|
+
import { EventConfirmation } from "../EventConfirmation";
|
|
8
|
+
import { OrderStatus, type ITicketOrder } from "../../../types/models";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Rendered through `react-dom/server`, which needs no DOM — so these run in the
|
|
12
|
+
* package's existing node vitest environment with no new dependency and no
|
|
13
|
+
* jsdom project. Effects never fire under `renderToStaticMarkup`, so the seeded
|
|
14
|
+
* query cache is the only data source and nothing reaches the network.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const PROFILE_ID = "profile-1";
|
|
18
|
+
const ORDER_ID = "order-1";
|
|
19
|
+
const EVENT_ID = "event-1";
|
|
20
|
+
|
|
21
|
+
const baseOrder: ITicketOrder = {
|
|
22
|
+
id: "0f9c8b7a-1111-2222-3333-444455556666",
|
|
23
|
+
items: [{ id: "item-1", eventTicketId: "ticket-1", quantity: 2, price: 12.5, title: "General Admission" }],
|
|
24
|
+
firstName: "Ada",
|
|
25
|
+
lastName: "Lovelace",
|
|
26
|
+
email: "ada@example.com",
|
|
27
|
+
totalAmount: 25,
|
|
28
|
+
currency: "USD",
|
|
29
|
+
status: OrderStatus.Paid,
|
|
30
|
+
customerName: "Ada Lovelace",
|
|
31
|
+
customerEmail: "ada@example.com",
|
|
32
|
+
createdAt: "2026-07-01T10:00:00.000Z",
|
|
33
|
+
updatedAt: "2026-07-01T10:00:00.000Z",
|
|
34
|
+
eventTitle: "Midnight Set",
|
|
35
|
+
questionnaire: [],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function render(order: ITicketOrder) {
|
|
39
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
40
|
+
queryClient.setQueryData(["eventTicketOrderStatus", PROFILE_ID, ORDER_ID], order);
|
|
41
|
+
queryClient.setQueryData(["event", EVENT_ID, PROFILE_ID], {
|
|
42
|
+
id: EVENT_ID,
|
|
43
|
+
title: "Midnight Set",
|
|
44
|
+
description: "A set",
|
|
45
|
+
type: "virtual",
|
|
46
|
+
status: "published",
|
|
47
|
+
dateTime: "2026-09-01T20:00:00.000Z",
|
|
48
|
+
endDateTime: "2026-09-01T23:00:00.000Z",
|
|
49
|
+
timezone: "UTC",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return renderToStaticMarkup(
|
|
53
|
+
<QueryClientProvider client={queryClient}>
|
|
54
|
+
<ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
|
|
55
|
+
<PublicAuthContext.Provider value={{ currencies: null, userSelectedCurrency: "USD" } as never}>
|
|
56
|
+
<ForgeThemeProvider
|
|
57
|
+
theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
|
|
58
|
+
>
|
|
59
|
+
<EventConfirmation slug={EVENT_ID} orderId={ORDER_ID} formatAmount={(n) => `$${n.toFixed(2)}`} />
|
|
60
|
+
</ForgeThemeProvider>
|
|
61
|
+
</PublicAuthContext.Provider>
|
|
62
|
+
</ForgeClientProvider>
|
|
63
|
+
</QueryClientProvider>,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The markup carries HTML entities for the curly apostrophes — normalise. */
|
|
68
|
+
const plain = (html: string) => html.replace(/'|'/g, "'").replace(/’/g, "'");
|
|
69
|
+
|
|
70
|
+
describe("EventConfirmation — refunded vs abandoned", () => {
|
|
71
|
+
it("greets a refund-voided order with refunded copy, not 'order not completed'", () => {
|
|
72
|
+
const html = plain(
|
|
73
|
+
render({
|
|
74
|
+
...baseOrder,
|
|
75
|
+
status: OrderStatus.Cancelled,
|
|
76
|
+
refundedAmountCents: "2500",
|
|
77
|
+
refundState: "full",
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(html).toContain("This order was refunded");
|
|
82
|
+
expect(html).toContain("bank or card issuer");
|
|
83
|
+
expect(html).toContain("will not admit entry");
|
|
84
|
+
expect(html).toContain("Refunded");
|
|
85
|
+
|
|
86
|
+
// The three things that were false for this buyer.
|
|
87
|
+
expect(html).not.toContain("Order not completed");
|
|
88
|
+
expect(html).not.toContain("contact support");
|
|
89
|
+
expect(html).not.toContain("If you were charged");
|
|
90
|
+
// ...and it must not congratulate them either.
|
|
91
|
+
expect(html).not.toContain("You're going!");
|
|
92
|
+
|
|
93
|
+
// The money line tells the truth about which direction it went.
|
|
94
|
+
expect(html).toContain("Total refunded");
|
|
95
|
+
expect(html).not.toContain("Total paid");
|
|
96
|
+
|
|
97
|
+
// Nothing that reads as a still-valid ticket: no scannable pass is rendered
|
|
98
|
+
// on this surface at all (the QR only ever lived in the emailed PDF), and
|
|
99
|
+
// the "add to calendar" prompt is paid-only.
|
|
100
|
+
expect(html.toLowerCase()).not.toContain("qr");
|
|
101
|
+
expect(html).not.toContain('data-testid="add-to-calendar"');
|
|
102
|
+
expect(html).not.toContain("E-Ticket");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("still shows the existing 'not completed' copy for an abandoned checkout", () => {
|
|
106
|
+
// Regression guard. This path is unchanged and must stay: for a buyer who
|
|
107
|
+
// never paid, "order not completed / if you were charged, contact support"
|
|
108
|
+
// is the correct thing to say.
|
|
109
|
+
const html = plain(
|
|
110
|
+
render({
|
|
111
|
+
...baseOrder,
|
|
112
|
+
status: OrderStatus.Cancelled,
|
|
113
|
+
refundedAmountCents: 0,
|
|
114
|
+
refundState: "none",
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(html).toContain("Order not completed");
|
|
119
|
+
expect(html).toContain("If you were charged, contact support.");
|
|
120
|
+
expect(html).toContain("Total"); // bare "Total", not "Total paid"
|
|
121
|
+
expect(html).not.toContain("Total paid");
|
|
122
|
+
expect(html).not.toContain("This order was refunded");
|
|
123
|
+
expect(html).not.toContain("Total refunded");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("is unchanged for a paid order", () => {
|
|
127
|
+
const html = plain(render(baseOrder));
|
|
128
|
+
|
|
129
|
+
expect(html).toContain("You're going!");
|
|
130
|
+
expect(html).toContain("E-Ticket");
|
|
131
|
+
expect(html).toContain("We've sent your tickets to ada@example.com.");
|
|
132
|
+
expect(html).toContain("Total paid");
|
|
133
|
+
// Pins the "add to calendar" affordance as really present on the paid path,
|
|
134
|
+
// so its absence in the refunded case above is a meaningful assertion.
|
|
135
|
+
expect(html).toContain('data-testid="add-to-calendar"');
|
|
136
|
+
expect(html).not.toContain("This order was refunded");
|
|
137
|
+
expect(html).not.toContain("Order not completed");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("keeps a partially refunded but still-valid order on the paid copy", () => {
|
|
141
|
+
// A partial refund voids nothing, so the pass still admits entry.
|
|
142
|
+
const html = plain(render({ ...baseOrder, refundedAmountCents: "500", refundState: "partial" }));
|
|
143
|
+
|
|
144
|
+
expect(html).toContain("You're going!");
|
|
145
|
+
expect(html).not.toContain("This order was refunded");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("does not read a refund into a plain payment failure", () => {
|
|
149
|
+
const html = plain(render({ ...baseOrder, status: OrderStatus.PaymentFailed }));
|
|
150
|
+
|
|
151
|
+
expect(html).toContain("Order not completed");
|
|
152
|
+
expect(html).not.toContain("This order was refunded");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { ForgeClientProvider } from "../../../provider/ForgeProvider";
|
|
5
|
+
import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
|
|
6
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
7
|
+
import { WalletPassButtons } from "../WalletPassButtons";
|
|
8
|
+
import type { WalletPassStatus } from "../../../data/queries/useWalletPass";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Events 2.3 — the one behaviour this component exists for: **when wallet
|
|
12
|
+
* passes are unavailable, the buyer sees NOTHING.**
|
|
13
|
+
*
|
|
14
|
+
* That is not a nicety. No signing credentials are configured in production, so
|
|
15
|
+
* the unavailable path is the ONLY one that runs today; if it leaked a greyed
|
|
16
|
+
* button, a "coming soon" or an error, every ticket-holder on the platform
|
|
17
|
+
* would be told about a feature they will never get.
|
|
18
|
+
*
|
|
19
|
+
* Rendered through `react-dom/server`, which needs no DOM — so these run in the
|
|
20
|
+
* package's existing node vitest environment with no jsdom project. Effects
|
|
21
|
+
* never fire under `renderToStaticMarkup`, so the seeded query cache is the
|
|
22
|
+
* only data source and nothing reaches the network.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const PROFILE_ID = "profile-1";
|
|
26
|
+
const ACCOUNT_ID = "account-1";
|
|
27
|
+
const PASS_ID = "TN-1001";
|
|
28
|
+
|
|
29
|
+
/** Matches `walletPassKey` in `useWalletPass`. */
|
|
30
|
+
const key = (passId: string) => ["wallet-pass", passId, ACCOUNT_ID, PROFILE_ID];
|
|
31
|
+
|
|
32
|
+
const status = (overrides: Partial<WalletPassStatus> = {}): WalletPassStatus => ({
|
|
33
|
+
passId: PASS_ID,
|
|
34
|
+
available: true,
|
|
35
|
+
reason: null,
|
|
36
|
+
apple: { available: true, downloadUrl: `/public/events/passes/${PASS_ID}/wallet/apple?profileId=${PROFILE_ID}` },
|
|
37
|
+
google: { available: true, saveUrl: "https://pay.google.com/gp/v/save/a.b.c" },
|
|
38
|
+
...overrides,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function render(
|
|
42
|
+
seed: Record<string, WalletPassStatus> | null,
|
|
43
|
+
props: { passId?: string; passIds?: string[] } = { passId: PASS_ID },
|
|
44
|
+
auth: { user?: { id: string } } = { user: { id: ACCOUNT_ID } },
|
|
45
|
+
) {
|
|
46
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
47
|
+
for (const [passId, value] of Object.entries(seed ?? {})) {
|
|
48
|
+
queryClient.setQueryData(key(passId), value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return renderToStaticMarkup(
|
|
52
|
+
<QueryClientProvider client={queryClient}>
|
|
53
|
+
<ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
|
|
54
|
+
<PublicAuthContext.Provider value={auth as never}>
|
|
55
|
+
<ForgeThemeProvider
|
|
56
|
+
theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
|
|
57
|
+
>
|
|
58
|
+
<WalletPassButtons {...props} />
|
|
59
|
+
</ForgeThemeProvider>
|
|
60
|
+
</PublicAuthContext.Provider>
|
|
61
|
+
</ForgeClientProvider>
|
|
62
|
+
</QueryClientProvider>,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Nothing at all — not an empty wrapper, not a hidden node, not a class name.
|
|
68
|
+
*
|
|
69
|
+
* `ForgeThemeProvider` emits its own `<style>` block of CSS variables, which is
|
|
70
|
+
* the harness rather than anything this component drew, so it is stripped
|
|
71
|
+
* first. What remains must be the empty string: an exact equality, not a
|
|
72
|
+
* "does not contain Wallet", because the point is that the component
|
|
73
|
+
* contributes NO node a curious buyer could find in the DOM.
|
|
74
|
+
*/
|
|
75
|
+
const rendersNothing = (html: string) => {
|
|
76
|
+
expect(html.replace(/<style>.*?<\/style>/gs, "")).toBe("");
|
|
77
|
+
expect(html).not.toContain("wallet");
|
|
78
|
+
expect(html).not.toContain("Wallet");
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
describe("WalletPassButtons — invisible when unavailable", () => {
|
|
82
|
+
it("renders NOTHING in the state production is in today (no signing credentials)", () => {
|
|
83
|
+
// The literal response the API gives right now, for every pass.
|
|
84
|
+
rendersNothing(
|
|
85
|
+
render({
|
|
86
|
+
[PASS_ID]: status({
|
|
87
|
+
available: false,
|
|
88
|
+
reason: "not_configured",
|
|
89
|
+
apple: { available: false, downloadUrl: null },
|
|
90
|
+
google: { available: false, saveUrl: null },
|
|
91
|
+
}),
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("renders NOTHING for a pass on signed rotating QR", () => {
|
|
97
|
+
rendersNothing(
|
|
98
|
+
render({
|
|
99
|
+
[PASS_ID]: status({
|
|
100
|
+
available: false,
|
|
101
|
+
reason: "rotating_qr",
|
|
102
|
+
apple: { available: false, downloadUrl: null },
|
|
103
|
+
google: { available: false, saveUrl: null },
|
|
104
|
+
}),
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("renders NOTHING for a cancelled or refunded order", () => {
|
|
110
|
+
rendersNothing(
|
|
111
|
+
render({
|
|
112
|
+
[PASS_ID]: status({
|
|
113
|
+
available: false,
|
|
114
|
+
reason: "order_not_active",
|
|
115
|
+
apple: { available: false, downloadUrl: null },
|
|
116
|
+
google: { available: false, saveUrl: null },
|
|
117
|
+
}),
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("renders NOTHING before the API has answered — no spinner, no skeleton", () => {
|
|
123
|
+
// A placeholder where a button will never appear is the same tell as the
|
|
124
|
+
// button itself, so there is deliberately no loading state anywhere.
|
|
125
|
+
rendersNothing(render(null));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("renders NOTHING for an anonymous visitor", () => {
|
|
129
|
+
// The query is disabled without an account id, so it never even asks.
|
|
130
|
+
rendersNothing(render({ [PASS_ID]: status() }, { passId: PASS_ID }, {}));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("renders NOTHING when given no pass ids at all", () => {
|
|
134
|
+
// What `myTicketPassIds` yields for an order whose payload carries no
|
|
135
|
+
// passes — an older API build, or an order with nothing admitted yet.
|
|
136
|
+
rendersNothing(render({ [PASS_ID]: status() }, { passIds: [] }));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("renders NOTHING for an id that is not a pass id, rather than requesting a certain 404", () => {
|
|
140
|
+
rendersNothing(render({ [PASS_ID]: status() }, { passId: "0f9c8b7a-1111-2222-3333-444455556666" }));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("renders NOTHING when the API says available but both providers came back empty", () => {
|
|
144
|
+
rendersNothing(
|
|
145
|
+
render({
|
|
146
|
+
[PASS_ID]: status({
|
|
147
|
+
apple: { available: false, downloadUrl: null },
|
|
148
|
+
google: { available: false, saveUrl: null },
|
|
149
|
+
}),
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("hides an unavailable pass while still drawing an available sibling", () => {
|
|
155
|
+
// The per-pass gate, not an all-or-nothing one: a rotating-QR ticket in the
|
|
156
|
+
// same order must vanish without taking its neighbour with it.
|
|
157
|
+
const html = render(
|
|
158
|
+
{
|
|
159
|
+
"TN-1": status({ passId: "TN-1" }),
|
|
160
|
+
"TN-2": status({
|
|
161
|
+
passId: "TN-2",
|
|
162
|
+
available: false,
|
|
163
|
+
reason: "rotating_qr",
|
|
164
|
+
apple: { available: false, downloadUrl: null },
|
|
165
|
+
google: { available: false, saveUrl: null },
|
|
166
|
+
}),
|
|
167
|
+
},
|
|
168
|
+
{ passIds: ["TN-1", "TN-2"] },
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(html).toContain("TN-1");
|
|
172
|
+
expect(html).not.toContain("TN-2");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("WalletPassButtons — what appears once a provider is configured", () => {
|
|
177
|
+
it("draws both badges when both providers answer", () => {
|
|
178
|
+
const html = render({ [PASS_ID]: status() });
|
|
179
|
+
|
|
180
|
+
expect(html).toContain('data-testid="wallet-pass-buttons"');
|
|
181
|
+
expect(html).toContain("Add to Apple Wallet");
|
|
182
|
+
expect(html).toContain("Save to Google Wallet");
|
|
183
|
+
// The Google save link is the vendor's own URL, used verbatim.
|
|
184
|
+
expect(html).toContain("https://pay.google.com/gp/v/save/a.b.c");
|
|
185
|
+
expect(html).toContain('rel="noopener noreferrer"');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("degrades to Apple alone when Google is not configured", () => {
|
|
189
|
+
// The two providers are approved independently, so this is a state that
|
|
190
|
+
// will really happen — not a theoretical one.
|
|
191
|
+
const html = render({ [PASS_ID]: status({ google: { available: false, saveUrl: null } }) });
|
|
192
|
+
|
|
193
|
+
expect(html).toContain("Add to Apple Wallet");
|
|
194
|
+
expect(html).not.toContain("Save to Google Wallet");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("degrades to Google alone when Apple is not configured", () => {
|
|
198
|
+
const html = render({ [PASS_ID]: status({ apple: { available: false, downloadUrl: null } }) });
|
|
199
|
+
|
|
200
|
+
expect(html).toContain("Save to Google Wallet");
|
|
201
|
+
expect(html).not.toContain("Add to Apple Wallet");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("fetches the .pkpass through the client rather than linking at the API", () => {
|
|
205
|
+
// A bearer-authenticated binary: an `<a href>` straight at the endpoint
|
|
206
|
+
// would carry no Authorization header and 401.
|
|
207
|
+
const html = render({ [PASS_ID]: status({ google: { available: false, saveUrl: null } }) });
|
|
208
|
+
|
|
209
|
+
expect(html).toContain("<button");
|
|
210
|
+
expect(html).not.toContain("wallet/apple");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("draws every colour from the theme", () => {
|
|
214
|
+
const html = render({ [PASS_ID]: status() });
|
|
215
|
+
|
|
216
|
+
// The badge inverts the theme rather than hardcoding the vendors' black.
|
|
217
|
+
expect(html).toContain("background:#111111");
|
|
218
|
+
expect(html).toContain("color:#ffffff");
|
|
219
|
+
// The only fixed colours are inside the Google mark itself, which is a
|
|
220
|
+
// brandmark and not a styling choice.
|
|
221
|
+
expect(html).toContain("#4285F4");
|
|
222
|
+
});
|
|
223
|
+
});
|