@tribe-nest/forge 3.26.0 → 3.31.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 +6 -3
- package/src/_tests/publishedResolvability.spec.ts +184 -0
- package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
- package/src/_tests/workspaceAliases.ts +40 -0
- package/src/client/_tests/localeHeader.spec.tsx +98 -0
- package/src/client/activeLocale.ts +34 -0
- package/src/client/createForgeClient.ts +13 -0
- package/src/contexts/CartContext.tsx +76 -0
- package/src/contexts/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/CartContext.spec.tsx +210 -0
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useCheckouts.ts +83 -0
- package/src/data/queries/useMyBookings.ts +9 -1
- package/src/data/queries/useWebsite.ts +7 -0
- package/src/i18n/CONVENTION.md +184 -0
- package/src/i18n/_tests/translationKeys.spec.ts +153 -0
- package/src/i18n/de.json +1019 -0
- package/src/i18n/en.json +1019 -0
- package/src/i18n/index.ts +125 -0
- package/src/index.ts +21 -0
- package/src/provider/ForgeAppProvider.tsx +2 -1
- package/src/provider/SiteConfigProvider.tsx +11 -0
- package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
- package/src/ui/format/_tests/pwyw.spec.ts +65 -8
- package/src/ui/format/membershipPwyw.ts +164 -0
- package/src/ui/format/pwyw.ts +37 -0
- package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
- package/src/ui/headless/broadcast/broadcastState.ts +158 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
- package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
- package/src/ui/headless/checkout/useCartResume.ts +340 -0
- package/src/ui/headless/checkout/useCheckout.ts +9 -2
- package/src/ui/headless/event/useEventCheckout.ts +8 -13
- package/src/ui/headless/index.ts +32 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
- package/src/ui/index.ts +38 -0
- package/src/ui/media/CallHelpHint.tsx +87 -0
- package/src/ui/media/CallStage.tsx +542 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
- package/src/ui/media/_tests/callState.spec.ts +452 -0
- package/src/ui/media/_tests/fakeNode.ts +178 -0
- package/src/ui/media/bookingSession.tsx +194 -0
- package/src/ui/media/callState.ts +341 -0
- package/src/ui/media/index.ts +135 -0
- package/src/ui/shell/TribeNestApp.tsx +36 -27
- package/src/ui/styled/AccountDashboard.tsx +289 -96
- package/src/ui/styled/AiAgentWidget.tsx +10 -6
- package/src/ui/styled/AudioPlayer.tsx +14 -6
- package/src/ui/styled/BlogCategory.tsx +4 -2
- package/src/ui/styled/BlogComments.tsx +27 -20
- package/src/ui/styled/BlogList.tsx +5 -3
- package/src/ui/styled/BlogPost.tsx +14 -11
- package/src/ui/styled/BroadcastWatch.tsx +107 -0
- package/src/ui/styled/BundleConfirmation.tsx +32 -23
- package/src/ui/styled/CancellationTerms.tsx +3 -1
- package/src/ui/styled/Cart.tsx +32 -14
- package/src/ui/styled/ChatRoom.tsx +65 -20
- package/src/ui/styled/Checkout.tsx +151 -89
- package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
- package/src/ui/styled/CoachingBooking.tsx +71 -44
- package/src/ui/styled/CoachingConfirmation.tsx +56 -19
- package/src/ui/styled/CoachingDetail.tsx +15 -11
- package/src/ui/styled/CohortPage.tsx +10 -8
- package/src/ui/styled/ConfirmSubscription.tsx +14 -8
- package/src/ui/styled/ContactForm.tsx +31 -12
- package/src/ui/styled/CookieConsent.tsx +19 -15
- package/src/ui/styled/CourseAccess.tsx +54 -47
- package/src/ui/styled/CourseCheckout.tsx +56 -38
- package/src/ui/styled/CourseConfirmation.tsx +39 -17
- package/src/ui/styled/CourseDetail.tsx +21 -15
- package/src/ui/styled/CoursesGrid.tsx +4 -2
- package/src/ui/styled/CurrencySwitcher.tsx +4 -1
- package/src/ui/styled/DiscountCode.tsx +22 -18
- package/src/ui/styled/DonationButton.tsx +52 -17
- package/src/ui/styled/DonationPage.tsx +4 -4
- package/src/ui/styled/EmailListForm.tsx +22 -9
- package/src/ui/styled/EventConfirmation.tsx +37 -14
- package/src/ui/styled/EventCountdown.tsx +10 -6
- package/src/ui/styled/EventDetail.tsx +8 -6
- package/src/ui/styled/EventSeriesDetail.tsx +18 -13
- package/src/ui/styled/EventTickets.tsx +232 -108
- package/src/ui/styled/EventWaitlist.tsx +42 -33
- package/src/ui/styled/EventsList.tsx +15 -13
- package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
- package/src/ui/styled/FormRenderer.tsx +12 -7
- package/src/ui/styled/InstallBanner.tsx +21 -18
- package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
- package/src/ui/styled/InvoicePayment.tsx +37 -16
- package/src/ui/styled/LeadMagnet.tsx +9 -7
- package/src/ui/styled/LiveBroadcastList.tsx +171 -0
- package/src/ui/styled/Loading.tsx +7 -1
- package/src/ui/styled/LoginForm.tsx +32 -14
- package/src/ui/styled/MembershipCheckout.tsx +341 -49
- package/src/ui/styled/MembershipTiers.tsx +34 -18
- package/src/ui/styled/OfferButton.tsx +33 -13
- package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
- package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
- package/src/ui/styled/PodcastEpisode.tsx +20 -9
- package/src/ui/styled/PodcastList.tsx +9 -3
- package/src/ui/styled/PodcastShow.tsx +29 -7
- package/src/ui/styled/PostsFeed.tsx +10 -8
- package/src/ui/styled/PresaleCode.tsx +8 -11
- package/src/ui/styled/ProductBrowseNav.tsx +21 -11
- package/src/ui/styled/ProductDetail.tsx +58 -28
- package/src/ui/styled/ReactionBar.tsx +4 -2
- package/src/ui/styled/ReplayList.tsx +24 -13
- package/src/ui/styled/ResetPasswordForm.tsx +32 -15
- package/src/ui/styled/ResumeCart.tsx +289 -0
- package/src/ui/styled/ReviewForm.tsx +52 -58
- package/src/ui/styled/SignupForm.tsx +34 -16
- package/src/ui/styled/TicketTransfer.tsx +55 -54
- package/src/ui/styled/UserMenu.tsx +16 -14
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
- package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
- package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
- package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
- package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
- package/src/ui/styled/community/CommunityComposer.tsx +182 -3
- package/src/ui/styled/community/CommunityFeed.tsx +36 -51
- package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
partitionResumeLines,
|
|
4
|
+
resumeLineToCartItem,
|
|
5
|
+
resumeLinesToTicketItems,
|
|
6
|
+
effectiveUnitCents,
|
|
7
|
+
type ResumeLine,
|
|
8
|
+
} from "../useCartResume";
|
|
9
|
+
import { rememberResumedCoupon, takeResumedCoupon } from "../resumedCoupon";
|
|
10
|
+
import { ProductDeliveryType } from "../../../../types/models";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The translation from a resumed line to a cart line, which is where a recovery
|
|
14
|
+
* link either restores the basket the buyer left or a subtly different one.
|
|
15
|
+
*
|
|
16
|
+
* Pure functions, so these run in the package's existing node environment with
|
|
17
|
+
* no jsdom and no network.
|
|
18
|
+
*
|
|
19
|
+
* The hook AROUND them lives in `useCartResume.spec.tsx`, in jsdom, where the
|
|
20
|
+
* exchange and the cart are both stubbed: which lines reach `restoreCart`, and
|
|
21
|
+
* when, is a question effects answer and a pure function cannot. What the two
|
|
22
|
+
* storefronts then DRAW is `ResumeCart.spec.tsx` here and `resume.spec.tsx` in
|
|
23
|
+
* `apps/client`. (This docblock previously claimed the hook was covered "end to
|
|
24
|
+
* end by the backend spec plus the two storefront pages", and none of those
|
|
25
|
+
* three existed.)
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const productLine = (overrides: Partial<ResumeLine> = {}): ResumeLine => ({
|
|
29
|
+
type: "product",
|
|
30
|
+
quantity: 2,
|
|
31
|
+
title: "Tour Tee",
|
|
32
|
+
coverImage: "https://cdn.test/tee.jpg",
|
|
33
|
+
price: 25,
|
|
34
|
+
unitPriceCents: 2500,
|
|
35
|
+
quotedUnitPriceCents: 2000,
|
|
36
|
+
priceChanged: true,
|
|
37
|
+
available: true,
|
|
38
|
+
unavailableReason: null,
|
|
39
|
+
productId: "prod-1",
|
|
40
|
+
productVariantId: "var-1",
|
|
41
|
+
deliveryType: ProductDeliveryType.Physical,
|
|
42
|
+
payWhatYouWant: false,
|
|
43
|
+
options: [{ axis: "Size", value: "L", swatchHex: null }],
|
|
44
|
+
isGift: false,
|
|
45
|
+
...overrides,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const ticketLine = (overrides: Partial<ResumeLine> = {}): ResumeLine => ({
|
|
49
|
+
type: "event_ticket",
|
|
50
|
+
quantity: 3,
|
|
51
|
+
title: "General Admission",
|
|
52
|
+
coverImage: null,
|
|
53
|
+
price: 25,
|
|
54
|
+
unitPriceCents: 2500,
|
|
55
|
+
quotedUnitPriceCents: 2500,
|
|
56
|
+
priceChanged: false,
|
|
57
|
+
available: true,
|
|
58
|
+
unavailableReason: null,
|
|
59
|
+
eventId: "event-1",
|
|
60
|
+
ticketId: "tier-1",
|
|
61
|
+
eventSlug: "spring-tour",
|
|
62
|
+
eventTitle: "Spring Tour",
|
|
63
|
+
pwywAmount: null,
|
|
64
|
+
bookingFee: null,
|
|
65
|
+
...overrides,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("resumeLineToCartItem", () => {
|
|
69
|
+
it("should carry the delivery type through rather than defaulting it", () => {
|
|
70
|
+
// The whole reason the payload has the field: a physical line restored as
|
|
71
|
+
// non-physical means checkout never asks where to send the record, and a
|
|
72
|
+
// missing address is not recoverable after payment.
|
|
73
|
+
const item = resumeLineToCartItem(productLine());
|
|
74
|
+
expect(item?.deliveryType).toBe(ProductDeliveryType.Physical);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("should restore the quantity the buyer chose, not one", () => {
|
|
78
|
+
expect(resumeLineToCartItem(productLine({ quantity: 4 }))?.quantity).toBe(4);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should quote today's price, never the one the buyer was shown", () => {
|
|
82
|
+
const item = resumeLineToCartItem(productLine({ price: 25, quotedUnitPriceCents: 2000 }));
|
|
83
|
+
expect(item?.price).toBe(25);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("should keep a gift a gift, with its recipient", () => {
|
|
87
|
+
const item = resumeLineToCartItem(
|
|
88
|
+
productLine({
|
|
89
|
+
isGift: true,
|
|
90
|
+
recipientName: "Ada",
|
|
91
|
+
recipientEmail: "ada@test.com",
|
|
92
|
+
recipientMessage: "Happy birthday",
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
expect(item).toMatchObject({
|
|
96
|
+
isGift: true,
|
|
97
|
+
recipientName: "Ada",
|
|
98
|
+
recipientEmail: "ada@test.com",
|
|
99
|
+
recipientMessage: "Happy birthday",
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should keep an add-on bound to the event it hangs off", () => {
|
|
104
|
+
// Without this the rebuilt bundle has an add-on with no base and the server
|
|
105
|
+
// refuses the whole checkout, with nothing on screen explaining why.
|
|
106
|
+
const item = resumeLineToCartItem(
|
|
107
|
+
productLine({ attachedTo: { type: "event", entityId: "event-1" } }),
|
|
108
|
+
);
|
|
109
|
+
expect(item?.attachedTo).toEqual({ type: "event", entityId: "event-1" });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should carry the chosen variant's options so the cart can name it", () => {
|
|
113
|
+
expect(resumeLineToCartItem(productLine())?.options).toEqual([
|
|
114
|
+
{ axis: "Size", value: "L", swatchHex: null },
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("should refuse a line with no variant rather than invent one", () => {
|
|
119
|
+
expect(resumeLineToCartItem(productLine({ productVariantId: undefined }))).toBeNull();
|
|
120
|
+
expect(resumeLineToCartItem(productLine({ productId: undefined }))).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("resumeLinesToTicketItems", () => {
|
|
125
|
+
it("should group tiers of one event into a single selection", () => {
|
|
126
|
+
const items = resumeLinesToTicketItems([
|
|
127
|
+
ticketLine({ ticketId: "tier-1", quantity: 2, title: "GA" }),
|
|
128
|
+
ticketLine({ ticketId: "tier-2", quantity: 1, title: "VIP", price: 80, unitPriceCents: 8000 }),
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
expect(items).toHaveLength(1);
|
|
132
|
+
expect(items[0]).toMatchObject({
|
|
133
|
+
eventId: "event-1",
|
|
134
|
+
eventSlug: "spring-tour",
|
|
135
|
+
eventTitle: "Spring Tour",
|
|
136
|
+
tickets: { "tier-1": 2, "tier-2": 1 },
|
|
137
|
+
});
|
|
138
|
+
expect(items[0].ticketMeta["tier-2"]).toMatchObject({ title: "VIP", price: 80 });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("should keep two events apart", () => {
|
|
142
|
+
const items = resumeLinesToTicketItems([
|
|
143
|
+
ticketLine({ eventId: "event-1" }),
|
|
144
|
+
ticketLine({ eventId: "event-2", eventSlug: "autumn", eventTitle: "Autumn" }),
|
|
145
|
+
]);
|
|
146
|
+
expect(items.map((item) => item.eventId)).toEqual(["event-1", "event-2"]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("should show a pay-what-you-want tier the buyer's own amount, and send it", () => {
|
|
150
|
+
// The bundle endpoint treats `price` as display-only and charges
|
|
151
|
+
// `pwywAmount`. Dropping the second field would silently undercharge to the
|
|
152
|
+
// tier's floor and still answer 200.
|
|
153
|
+
const items = resumeLinesToTicketItems([ticketLine({ price: 10, pwywAmount: 42 })]);
|
|
154
|
+
expect(items[0].ticketMeta["tier-1"]).toMatchObject({ price: 42, pwywAmount: 42 });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("should carry the event's booking fee so the estimate matches the charge", () => {
|
|
158
|
+
const fee = { feeAmount: 1.5, feeBps: 250, currency: "USD", minorUnitFactor: 100 };
|
|
159
|
+
expect(resumeLinesToTicketItems([ticketLine({ bookingFee: fee })])[0].bookingFee).toEqual(fee);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("should skip a line with no tier id", () => {
|
|
163
|
+
expect(resumeLinesToTicketItems([ticketLine({ ticketId: undefined })])).toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("partitionResumeLines", () => {
|
|
168
|
+
const courseLine = (): ResumeLine => ({
|
|
169
|
+
...productLine(),
|
|
170
|
+
type: "course",
|
|
171
|
+
title: "Songwriting",
|
|
172
|
+
courseSlug: "songwriting",
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("sends a course onward rather than into the basket", () => {
|
|
176
|
+
const parts = partitionResumeLines([productLine(), courseLine()]);
|
|
177
|
+
expect(parts.restoredLines.map((line) => line.title)).toEqual(["Tour Tee"]);
|
|
178
|
+
expect(parts.continueLines.map((line) => line.title)).toEqual(["Songwriting"]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("keeps every line the server refused, with its reason intact", () => {
|
|
182
|
+
const gone = productLine({ available: false, unavailableReason: "removed", title: "Vinyl" });
|
|
183
|
+
const parts = partitionResumeLines([productLine(), gone]);
|
|
184
|
+
expect(parts.unavailableLines).toEqual([gone]);
|
|
185
|
+
expect(parts.restoredLines.map((line) => line.title)).toEqual(["Tour Tee"]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("REGRESSION: drops an add-on whose base ticket is not being restored", () => {
|
|
189
|
+
// The server refuses a bundle whose add-on has no base, so restoring it
|
|
190
|
+
// alone builds a basket that can only fail at the payment screen.
|
|
191
|
+
const poster = productLine({
|
|
192
|
+
title: "Tour poster",
|
|
193
|
+
attachedTo: { type: "event", entityId: "event-1" },
|
|
194
|
+
});
|
|
195
|
+
const parts = partitionResumeLines([
|
|
196
|
+
ticketLine({ available: false, unavailableReason: "sold_out" }),
|
|
197
|
+
poster,
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
expect(parts.restoredLines).toEqual([]);
|
|
201
|
+
expect(parts.droppedLines).toEqual([poster]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("keeps the add-on when its base ticket is restored", () => {
|
|
205
|
+
const poster = productLine({ attachedTo: { type: "event", entityId: "event-1" } });
|
|
206
|
+
const parts = partitionResumeLines([ticketLine(), poster]);
|
|
207
|
+
expect(parts.droppedLines).toEqual([]);
|
|
208
|
+
expect(parts.restoredLines).toContain(poster);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("counts only the event the add-on names as its base", () => {
|
|
212
|
+
const poster = productLine({ attachedTo: { type: "event", entityId: "event-2" } });
|
|
213
|
+
const parts = partitionResumeLines([ticketLine({ eventId: "event-1" }), poster]);
|
|
214
|
+
expect(parts.droppedLines).toEqual([poster]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("does not accept a ticket line the cart would skip as a base", () => {
|
|
218
|
+
// `resumeLinesToTicketItems` needs both ids and silently skips a line
|
|
219
|
+
// missing either, so such a line is not a base that will exist.
|
|
220
|
+
const poster = productLine({ attachedTo: { type: "event", entityId: "event-1" } });
|
|
221
|
+
const parts = partitionResumeLines([ticketLine({ ticketId: undefined }), poster]);
|
|
222
|
+
expect(parts.droppedLines).toEqual([poster]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("leaves a product attached to nothing alone", () => {
|
|
226
|
+
// Attachment describes how a line was ADDED, not what the product is: the
|
|
227
|
+
// same poster bought from the shop sells on its own as always.
|
|
228
|
+
const parts = partitionResumeLines([productLine({ attachedTo: null })]);
|
|
229
|
+
expect(parts.droppedLines).toEqual([]);
|
|
230
|
+
expect(parts.restoredLines).toHaveLength(1);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("effectiveUnitCents", () => {
|
|
235
|
+
it("should use the catalogue price when nothing was chosen", () => {
|
|
236
|
+
expect(effectiveUnitCents(ticketLine())).toBe(2500);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("should use the chosen amount on a pay-what-you-want tier", () => {
|
|
240
|
+
expect(effectiveUnitCents(ticketLine({ pwywAmount: 42 }))).toBe(4200);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("should treat a chosen zero as a choice, not as absent", () => {
|
|
244
|
+
expect(effectiveUnitCents(ticketLine({ pwywAmount: 0 }))).toBe(0);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("the resumed coupon stash", () => {
|
|
249
|
+
const store = new Map<string, string>();
|
|
250
|
+
|
|
251
|
+
beforeEach(() => {
|
|
252
|
+
store.clear();
|
|
253
|
+
(globalThis as { window?: unknown }).window = {
|
|
254
|
+
sessionStorage: {
|
|
255
|
+
getItem: (key: string) => store.get(key) ?? null,
|
|
256
|
+
setItem: (key: string, value: string) => void store.set(key, value),
|
|
257
|
+
removeItem: (key: string) => void store.delete(key),
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
rememberResumedCoupon(null);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
afterEach(() => {
|
|
264
|
+
delete (globalThis as { window?: unknown }).window;
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("should hand the code to the checkout page once", () => {
|
|
268
|
+
rememberResumedCoupon("SUMMER10");
|
|
269
|
+
expect(takeResumedCoupon()).toBe("SUMMER10");
|
|
270
|
+
expect(store.has("tribenest-resumed-coupon")).toBe(false);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("should survive a second read in the same page load", () => {
|
|
274
|
+
// React StrictMode invokes a lazy `useState` initialiser twice in
|
|
275
|
+
// development. Without the memo the field would come up blank on exactly
|
|
276
|
+
// the builds a developer tests against.
|
|
277
|
+
rememberResumedCoupon("SUMMER10");
|
|
278
|
+
expect(takeResumedCoupon()).toBe("SUMMER10");
|
|
279
|
+
expect(takeResumedCoupon()).toBe("SUMMER10");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("should answer empty when no link was followed", () => {
|
|
283
|
+
expect(takeResumedCoupon()).toBe("");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("should forget a code when the next resume carries none", () => {
|
|
287
|
+
rememberResumedCoupon("SUMMER10");
|
|
288
|
+
rememberResumedCoupon(null);
|
|
289
|
+
expect(takeResumedCoupon()).toBe("");
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
3
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import { useSyncExternalStore } from "react";
|
|
5
|
+
import type { ResumeLine, ResumedCheckout } from "../../../../data/queries/useCheckouts";
|
|
6
|
+
import { ProductDeliveryType } from "../../../../types/models";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `useCartResume` end to end: what a recovery link actually puts in the basket.
|
|
10
|
+
*
|
|
11
|
+
* The pure translation of a line is asserted in `cartResume.spec.ts`. What is
|
|
12
|
+
* asserted HERE is the part no pure function can show: which lines reach
|
|
13
|
+
* `restoreCart`, and WHEN. Both storefronts render this hook, so a defect here
|
|
14
|
+
* is a defect on every artist's shop at once.
|
|
15
|
+
*
|
|
16
|
+
* Two seams, both mocked at the module boundary so nothing touches the network:
|
|
17
|
+
*
|
|
18
|
+
* - `useResumeCheckout`, whose `mutateAsync` is the exchange.
|
|
19
|
+
* - `useCart`, so the spec can hold `isReady` at `false` and release it. A real
|
|
20
|
+
* `CartProvider` hydrates inside the first commit, which is exactly the case
|
|
21
|
+
* the hook's `isReady` guard is NOT written for.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const cartStub = vi.hoisted(() => {
|
|
25
|
+
let ready = false;
|
|
26
|
+
const listeners = new Set<() => void>();
|
|
27
|
+
return {
|
|
28
|
+
restoreCart: vi.fn(),
|
|
29
|
+
subscribe(listener: () => void) {
|
|
30
|
+
listeners.add(listener);
|
|
31
|
+
return () => void listeners.delete(listener);
|
|
32
|
+
},
|
|
33
|
+
read: () => ready,
|
|
34
|
+
setReady(next: boolean) {
|
|
35
|
+
ready = next;
|
|
36
|
+
listeners.forEach((listener) => listener());
|
|
37
|
+
},
|
|
38
|
+
reset() {
|
|
39
|
+
ready = false;
|
|
40
|
+
listeners.clear();
|
|
41
|
+
cartStub.restoreCart.mockClear();
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const exchange = vi.hoisted(() => ({ mutateAsync: vi.fn() }));
|
|
47
|
+
|
|
48
|
+
vi.mock("../../../../contexts/CartContext", async (importOriginal) => ({
|
|
49
|
+
...(await importOriginal<typeof import("../../../../contexts/CartContext")>()),
|
|
50
|
+
useCart: () => ({
|
|
51
|
+
restoreCart: cartStub.restoreCart,
|
|
52
|
+
isReady: useSyncExternalStore(cartStub.subscribe, cartStub.read, cartStub.read),
|
|
53
|
+
}),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
vi.mock("../../../../data/queries/useCheckouts", async (importOriginal) => ({
|
|
57
|
+
...(await importOriginal<typeof import("../../../../data/queries/useCheckouts")>()),
|
|
58
|
+
useResumeCheckout: () => exchange,
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
import { useCartResume } from "../useCartResume";
|
|
62
|
+
|
|
63
|
+
const productLine = (overrides: Partial<ResumeLine> = {}): ResumeLine => ({
|
|
64
|
+
type: "product",
|
|
65
|
+
quantity: 1,
|
|
66
|
+
title: "Tour Tee",
|
|
67
|
+
coverImage: null,
|
|
68
|
+
price: 25,
|
|
69
|
+
unitPriceCents: 2500,
|
|
70
|
+
quotedUnitPriceCents: 2500,
|
|
71
|
+
priceChanged: false,
|
|
72
|
+
available: true,
|
|
73
|
+
unavailableReason: null,
|
|
74
|
+
productId: "prod-1",
|
|
75
|
+
productVariantId: "var-1",
|
|
76
|
+
deliveryType: ProductDeliveryType.Physical,
|
|
77
|
+
isGift: false,
|
|
78
|
+
...overrides,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const ticketLine = (overrides: Partial<ResumeLine> = {}): ResumeLine => ({
|
|
82
|
+
type: "event_ticket",
|
|
83
|
+
quantity: 2,
|
|
84
|
+
title: "General Admission",
|
|
85
|
+
coverImage: null,
|
|
86
|
+
price: 25,
|
|
87
|
+
unitPriceCents: 2500,
|
|
88
|
+
quotedUnitPriceCents: 2500,
|
|
89
|
+
priceChanged: false,
|
|
90
|
+
available: true,
|
|
91
|
+
unavailableReason: null,
|
|
92
|
+
eventId: "event-1",
|
|
93
|
+
ticketId: "tier-ga",
|
|
94
|
+
eventSlug: "spring-tour",
|
|
95
|
+
eventTitle: "Spring Tour",
|
|
96
|
+
pwywAmount: null,
|
|
97
|
+
bookingFee: null,
|
|
98
|
+
...overrides,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const payload = (lines: ResumeLine[]): ResumedCheckout => ({
|
|
102
|
+
checkoutId: "checkout-1",
|
|
103
|
+
profileId: "profile-1",
|
|
104
|
+
source: "checkout",
|
|
105
|
+
currency: "USD",
|
|
106
|
+
buyer: { firstName: null, lastName: null, email: null },
|
|
107
|
+
couponCode: null,
|
|
108
|
+
lines,
|
|
109
|
+
itemCount: lines.length,
|
|
110
|
+
subtotalCents: 0,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const addon = productLine({
|
|
114
|
+
productId: "prod-addon",
|
|
115
|
+
productVariantId: "var-addon",
|
|
116
|
+
title: "Tour poster",
|
|
117
|
+
attachedTo: { type: "event", entityId: "event-1" },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/** Mount the hook, with the exchange already answering `lines`. */
|
|
121
|
+
const resume = async (lines: ResumeLine[], { ready = true } = {}) => {
|
|
122
|
+
exchange.mutateAsync.mockResolvedValue(payload(lines));
|
|
123
|
+
cartStub.setReady(ready);
|
|
124
|
+
const hook = renderHook(() => useCartResume({ token: "tok-1" }));
|
|
125
|
+
await waitFor(() => expect(hook.result.current.status).not.toBe("loading"));
|
|
126
|
+
return hook;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
beforeEach(() => {
|
|
130
|
+
cartStub.reset();
|
|
131
|
+
exchange.mutateAsync.mockReset();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
afterEach(() => {
|
|
135
|
+
vi.clearAllMocks();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("useCartResume: an add-on never lands without its ticket", () => {
|
|
139
|
+
it("REGRESSION: does not restore an add-on whose event ticket is sold out", async () => {
|
|
140
|
+
// The basket this used to build is one the server refuses outright: an
|
|
141
|
+
// add-on with no base. The buyer reached the payment screen, was turned
|
|
142
|
+
// away, and nothing on the page ever named the poster as the reason.
|
|
143
|
+
const { result } = await resume([
|
|
144
|
+
ticketLine({ available: false, unavailableReason: "sold_out" }),
|
|
145
|
+
addon,
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
// Nothing survived, so nothing is written to the basket at all. Before the
|
|
149
|
+
// fix this was called with the poster as its only item.
|
|
150
|
+
expect(cartStub.restoreCart).not.toHaveBeenCalled();
|
|
151
|
+
expect(result.current.restoredLines).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("tells the buyer the add-on was left out rather than dropping it silently", async () => {
|
|
155
|
+
const { result } = await resume([
|
|
156
|
+
ticketLine({ available: false, unavailableReason: "sold_out" }),
|
|
157
|
+
addon,
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
expect(result.current.droppedLines.map((line) => line.title)).toEqual(["Tour poster"]);
|
|
161
|
+
// Not smuggled into the server's own list: nothing about it is unavailable.
|
|
162
|
+
expect(result.current.unavailableLines.map((line) => line.title)).toEqual([
|
|
163
|
+
"General Admission",
|
|
164
|
+
]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("leaves an orphaned add-on out of the count and the subtotal", async () => {
|
|
168
|
+
// Otherwise the page promises a basket that is one line and $25 heavier
|
|
169
|
+
// than the one checkout will show.
|
|
170
|
+
const { result } = await resume([
|
|
171
|
+
ticketLine({ available: false, unavailableReason: "sold_out" }),
|
|
172
|
+
addon,
|
|
173
|
+
productLine({ productId: "prod-9", productVariantId: "var-9", quantity: 2 }),
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
expect(result.current.itemCount).toBe(2);
|
|
177
|
+
expect(result.current.subtotalCents).toBe(5000);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("restores the add-on when its ticket is restored alongside it", async () => {
|
|
181
|
+
const { result } = await resume([ticketLine(), addon]);
|
|
182
|
+
|
|
183
|
+
const [call] = cartStub.restoreCart.mock.calls;
|
|
184
|
+
expect(call[0].items).toHaveLength(1);
|
|
185
|
+
expect(call[0].items[0]).toMatchObject({
|
|
186
|
+
productId: "prod-addon",
|
|
187
|
+
attachedTo: { type: "event", entityId: "event-1" },
|
|
188
|
+
});
|
|
189
|
+
expect(call[0].tickets).toHaveLength(1);
|
|
190
|
+
expect(call[0].tickets[0].tickets).toEqual({ "tier-ga": 2 });
|
|
191
|
+
expect(result.current.droppedLines).toEqual([]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("drops an add-on attached to an event whose ticket is not in the payload at all", async () => {
|
|
195
|
+
const { result } = await resume([addon]);
|
|
196
|
+
|
|
197
|
+
expect(result.current.droppedLines.map((line) => line.title)).toEqual(["Tour poster"]);
|
|
198
|
+
expect(cartStub.restoreCart).not.toHaveBeenCalled();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("leaves an ordinary product alone, attached to nothing", async () => {
|
|
202
|
+
// Attachment describes how a line was ADDED, never what the product is. The
|
|
203
|
+
// same poster bought from the shop sells on its own.
|
|
204
|
+
const { result } = await resume([productLine({ title: "Tour poster" })]);
|
|
205
|
+
|
|
206
|
+
expect(result.current.droppedLines).toEqual([]);
|
|
207
|
+
expect(result.current.restoredLines.map((line) => line.title)).toEqual(["Tour poster"]);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("useCartResume: what goes back, and when", () => {
|
|
212
|
+
it("REGRESSION: restores once the cart finishes hydrating, not never", async () => {
|
|
213
|
+
// The hook waits for `isReady` because `CartProvider` hydrates in a mount
|
|
214
|
+
// effect that REPLACES the whole list. While `isReady` was missing from the
|
|
215
|
+
// callback's dependencies, React handed back the same stale closure for
|
|
216
|
+
// ever and the wait became a permanent refusal: on a slow localStorage read
|
|
217
|
+
// the buyer landed on "your basket is back" with an empty basket.
|
|
218
|
+
const { result } = await resume([productLine()], { ready: false });
|
|
219
|
+
|
|
220
|
+
expect(result.current.status).toBe("restored");
|
|
221
|
+
expect(cartStub.restoreCart).not.toHaveBeenCalled();
|
|
222
|
+
|
|
223
|
+
act(() => cartStub.setReady(true));
|
|
224
|
+
|
|
225
|
+
await waitFor(() => expect(cartStub.restoreCart).toHaveBeenCalledTimes(1));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("restores once, however many times the page re-renders", async () => {
|
|
229
|
+
// The endpoint stamps the arrival that cart recovery attributes on, and a
|
|
230
|
+
// second restore would re-inflate a quantity the buyer had just lowered.
|
|
231
|
+
const { rerender } = await resume([productLine()]);
|
|
232
|
+
|
|
233
|
+
rerender();
|
|
234
|
+
act(() => cartStub.setReady(true));
|
|
235
|
+
rerender();
|
|
236
|
+
|
|
237
|
+
expect(cartStub.restoreCart).toHaveBeenCalledTimes(1);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("asks the exchange once per token, because the request is also an arrival stamp", async () => {
|
|
241
|
+
const { rerender } = await resume([productLine()]);
|
|
242
|
+
rerender();
|
|
243
|
+
expect(exchange.mutateAsync).toHaveBeenCalledTimes(1);
|
|
244
|
+
expect(exchange.mutateAsync).toHaveBeenCalledWith({ token: "tok-1" });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("sends nothing and reports the link as broken when the token is blank", async () => {
|
|
248
|
+
cartStub.setReady(true);
|
|
249
|
+
const { result } = renderHook(() => useCartResume({ token: " " }));
|
|
250
|
+
|
|
251
|
+
await waitFor(() => expect(result.current.status).toBe("error"));
|
|
252
|
+
expect(result.current.error).toBe("missing_token");
|
|
253
|
+
expect(exchange.mutateAsync).not.toHaveBeenCalled();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("surfaces the server's refusal without touching the basket", async () => {
|
|
257
|
+
exchange.mutateAsync.mockRejectedValue({ response: { data: { message: "expired" } } });
|
|
258
|
+
cartStub.setReady(true);
|
|
259
|
+
const { result } = renderHook(() => useCartResume({ token: "tok-1" }));
|
|
260
|
+
|
|
261
|
+
await waitFor(() => expect(result.current.status).toBe("error"));
|
|
262
|
+
expect(result.current.error).toBe("expired");
|
|
263
|
+
expect(cartStub.restoreCart).not.toHaveBeenCalled();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("holds the basket back when the caller draws its own button", async () => {
|
|
267
|
+
exchange.mutateAsync.mockResolvedValue(payload([productLine()]));
|
|
268
|
+
cartStub.setReady(true);
|
|
269
|
+
const { result } = renderHook(() => useCartResume({ token: "tok-1", autoRestore: false }));
|
|
270
|
+
|
|
271
|
+
await waitFor(() => expect(result.current.status).toBe("restored"));
|
|
272
|
+
expect(cartStub.restoreCart).not.toHaveBeenCalled();
|
|
273
|
+
|
|
274
|
+
act(() => result.current.restore());
|
|
275
|
+
expect(cartStub.restoreCart).toHaveBeenCalledTimes(1);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("keeps a course out of the basket and hands it back as a link", async () => {
|
|
279
|
+
const { result } = await resume([
|
|
280
|
+
{ ...productLine(), type: "course", title: "Songwriting", courseSlug: "songwriting" },
|
|
281
|
+
]);
|
|
282
|
+
|
|
283
|
+
expect(result.current.continueLines.map((line) => line.title)).toEqual(["Songwriting"]);
|
|
284
|
+
expect(cartStub.restoreCart).not.toHaveBeenCalled();
|
|
285
|
+
expect(result.current.status).toBe("restored");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("reads as empty when nothing at all survived", async () => {
|
|
289
|
+
const { result } = await resume([productLine({ available: false, unavailableReason: "removed" })]);
|
|
290
|
+
|
|
291
|
+
expect(result.current.status).toBe("empty");
|
|
292
|
+
expect(cartStub.restoreCart).not.toHaveBeenCalled();
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The discount code a recovery link carried, handed from the resume page to
|
|
3
|
+
* the checkout page.
|
|
4
|
+
*
|
|
5
|
+
* ## Why a stash and not a prop
|
|
6
|
+
*
|
|
7
|
+
* The two pages are separate route entries on both rendering stacks and the
|
|
8
|
+
* buyer walks between them through the ordinary cart, sometimes via the store
|
|
9
|
+
* in between. Threading the code through would mean a query parameter on every
|
|
10
|
+
* link the resume page can produce, which puts a discount code in browser
|
|
11
|
+
* history and in any `Referer` the storefront leaks. `sessionStorage` is
|
|
12
|
+
* per-tab and dies with the tab.
|
|
13
|
+
*
|
|
14
|
+
* ## What it does NOT do
|
|
15
|
+
*
|
|
16
|
+
* It does not apply anything. The code is only PREFILLED into the discount
|
|
17
|
+
* field, so the buyer still presses Apply and the server re-evaluates every
|
|
18
|
+
* condition (window, caps, membership gate, minimum spend) against today. A
|
|
19
|
+
* code that has since been switched off is already withheld by the resume
|
|
20
|
+
* endpoint, so the common failure never reaches here at all.
|
|
21
|
+
*
|
|
22
|
+
* Consumed on first read, so a reload of the checkout page a week later does
|
|
23
|
+
* not prefill a code the buyer has long forgotten. It is remembered for the
|
|
24
|
+
* rest of the PAGE LOAD in a module variable, and that part is load-bearing
|
|
25
|
+
* rather than an optimisation: `useCheckout` reads it from a lazy `useState`
|
|
26
|
+
* initialiser, which React StrictMode deliberately invokes twice in
|
|
27
|
+
* development. Without the memo the second invocation would find the stash
|
|
28
|
+
* already emptied and the field would come up blank on exactly the builds a
|
|
29
|
+
* developer tests against.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const KEY = "tribenest-resumed-coupon";
|
|
33
|
+
|
|
34
|
+
/** Undefined until the first read of this page load. */
|
|
35
|
+
let taken: string | undefined;
|
|
36
|
+
|
|
37
|
+
export function rememberResumedCoupon(code: string | null | undefined): void {
|
|
38
|
+
taken = undefined;
|
|
39
|
+
if (typeof window === "undefined") return;
|
|
40
|
+
try {
|
|
41
|
+
if (code && code.trim()) window.sessionStorage.setItem(KEY, code.trim());
|
|
42
|
+
else window.sessionStorage.removeItem(KEY);
|
|
43
|
+
} catch {
|
|
44
|
+
// Private-mode Safari and locked-down embeds throw here. A missing prefill
|
|
45
|
+
// is a smaller problem than a checkout page that fails to mount.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Read and clear. Returns "" when there is nothing waiting. */
|
|
50
|
+
export function takeResumedCoupon(): string {
|
|
51
|
+
if (taken !== undefined) return taken;
|
|
52
|
+
if (typeof window === "undefined") return "";
|
|
53
|
+
try {
|
|
54
|
+
const value = window.sessionStorage.getItem(KEY) ?? "";
|
|
55
|
+
if (value) window.sessionStorage.removeItem(KEY);
|
|
56
|
+
taken = value;
|
|
57
|
+
return value;
|
|
58
|
+
} catch {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
}
|