@tribe-nest/forge 3.26.0 → 3.29.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/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/_tests/CartContext.spec.tsx +210 -0
- package/src/data/queries/useCheckouts.ts +83 -0
- 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 +960 -0
- package/src/i18n/en.json +960 -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/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/index.ts +18 -0
- package/src/ui/index.ts +2 -0
- package/src/ui/shell/TribeNestApp.tsx +36 -27
- package/src/ui/styled/AccountDashboard.tsx +197 -93
- 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/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 +12 -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/Loading.tsx +7 -1
- package/src/ui/styled/LoginForm.tsx +22 -14
- package/src/ui/styled/MembershipCheckout.tsx +32 -13
- package/src/ui/styled/MembershipTiers.tsx +27 -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 +27 -15
- package/src/ui/styled/ResumeCart.tsx +289 -0
- package/src/ui/styled/ReviewForm.tsx +52 -58
- package/src/ui/styled/SignupForm.tsx +29 -16
- package/src/ui/styled/TicketTransfer.tsx +55 -54
- package/src/ui/styled/UserMenu.tsx +16 -14
- package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Forge i18n. Bundled strings only: the en/de JSON in this folder ships inside
|
|
2
|
+
// the package and is read synchronously, so there is no network call, no
|
|
3
|
+
// loading state and no way for a site to render an untranslated first paint.
|
|
4
|
+
// See docs/proposals/i18n-initiative.md, "Why bundle-only is safe".
|
|
5
|
+
//
|
|
6
|
+
// Key naming is fixed by ./CONVENTION.md. Every key starts with "forge." so a
|
|
7
|
+
// tenant's own copy can never collide with the package's.
|
|
8
|
+
|
|
9
|
+
import { createContext, createElement, useContext, useMemo, type ReactNode } from "react";
|
|
10
|
+
import enMessages from "./en.json";
|
|
11
|
+
import deMessages from "./de.json";
|
|
12
|
+
|
|
13
|
+
/** The locales Forge ships copy for. en + de is the whole proof of concept. */
|
|
14
|
+
export const FORGE_LOCALES = ["en", "de"] as const;
|
|
15
|
+
|
|
16
|
+
export type ForgeLocale = (typeof FORGE_LOCALES)[number];
|
|
17
|
+
|
|
18
|
+
/** The locale every fallback ends at. English is the source language. */
|
|
19
|
+
export const FORGE_DEFAULT_LOCALE: ForgeLocale = "en";
|
|
20
|
+
|
|
21
|
+
/** A flat map of dotted key to copy. Not nested: see ./CONVENTION.md. */
|
|
22
|
+
export type ForgeMessages = Record<string, string>;
|
|
23
|
+
|
|
24
|
+
/** Values substituted into `{placeholders}`. */
|
|
25
|
+
export type ForgeTVars = Record<string, string | number>;
|
|
26
|
+
|
|
27
|
+
/** The translate function `useForgeT()` hands back. */
|
|
28
|
+
export type ForgeT = (key: string, vars?: ForgeTVars) => string;
|
|
29
|
+
|
|
30
|
+
const BUNDLES: Record<ForgeLocale, ForgeMessages> = {
|
|
31
|
+
en: enMessages as ForgeMessages,
|
|
32
|
+
de: deMessages as ForgeMessages,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Narrow anything a site might pass ("de", "de-DE", "DE", undefined) to a
|
|
37
|
+
* locale Forge actually has copy for. Anything unrecognised becomes English
|
|
38
|
+
* rather than an error, because a wrong language is a defect and a crashed
|
|
39
|
+
* page is an outage.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveForgeLocale(locale?: string | null): ForgeLocale {
|
|
42
|
+
if (!locale) return FORGE_DEFAULT_LOCALE;
|
|
43
|
+
const base = locale.toLowerCase().split(/[-_]/)[0];
|
|
44
|
+
return (FORGE_LOCALES as readonly string[]).includes(base) ? (base as ForgeLocale) : FORGE_DEFAULT_LOCALE;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Substitute `{name}` placeholders. A placeholder with no matching var is left
|
|
49
|
+
* standing rather than blanked, so a missing value shows up as `{name}` in
|
|
50
|
+
* review instead of disappearing silently into a half-finished sentence.
|
|
51
|
+
*/
|
|
52
|
+
function interpolate(template: string, vars?: ForgeTVars): string {
|
|
53
|
+
if (!vars) return template;
|
|
54
|
+
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
|
55
|
+
Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Look a key up with the full fallback chain: requested locale, then English,
|
|
61
|
+
* then the key itself. The last step is what guarantees a component never
|
|
62
|
+
* renders blank, and it makes an untranslated string visible in the UI rather
|
|
63
|
+
* than invisible.
|
|
64
|
+
*/
|
|
65
|
+
export function translateForge(locale: ForgeLocale, key: string, vars?: ForgeTVars): string {
|
|
66
|
+
const template = BUNDLES[locale]?.[key] ?? BUNDLES[FORGE_DEFAULT_LOCALE][key] ?? key;
|
|
67
|
+
return interpolate(template, vars);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Read-only access to a bundle, for parity specs and tooling. */
|
|
71
|
+
export function getForgeMessages(locale: ForgeLocale): ForgeMessages {
|
|
72
|
+
return BUNDLES[locale] ?? BUNDLES[FORGE_DEFAULT_LOCALE];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// `null` means "no provider above me", which is a supported state, not a bug.
|
|
76
|
+
const ForgeI18nContext = createContext<ForgeLocale | null>(null);
|
|
77
|
+
|
|
78
|
+
export interface ForgeI18nProviderProps {
|
|
79
|
+
/**
|
|
80
|
+
* The site's language. Accepts any BCP-47-ish string and narrows it; absent
|
|
81
|
+
* or unsupported means English.
|
|
82
|
+
*/
|
|
83
|
+
locale?: string | null;
|
|
84
|
+
children: ReactNode;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Puts the site's language in context. Mounted by `<TribeNestApp>`, so a site
|
|
89
|
+
* gains i18n from a Forge upgrade without editing its `__root.tsx`. Mount it
|
|
90
|
+
* yourself only if you hand-compose the provider tree instead of using
|
|
91
|
+
* `TribeNestApp`.
|
|
92
|
+
*/
|
|
93
|
+
export function ForgeI18nProvider({ locale, children }: ForgeI18nProviderProps) {
|
|
94
|
+
const resolved = resolveForgeLocale(locale);
|
|
95
|
+
return createElement(ForgeI18nContext.Provider, { value: resolved }, children);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The locale in effect. Returns the default ("en") outside a provider, so this
|
|
100
|
+
* is safe to call anywhere.
|
|
101
|
+
*/
|
|
102
|
+
export function useForgeLocale(): ForgeLocale {
|
|
103
|
+
return useContext(ForgeI18nContext) ?? FORGE_DEFAULT_LOCALE;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The translate function.
|
|
108
|
+
*
|
|
109
|
+
* const t = useForgeT();
|
|
110
|
+
* return <button>{t("forge.cart.checkout")}</button>;
|
|
111
|
+
* return <p>{t("forge.cart.item_count", { count: items.length })}</p>;
|
|
112
|
+
*
|
|
113
|
+
* Fallback chain: the active locale, then bundled English, then the key itself.
|
|
114
|
+
* It never returns an empty string, so a missing key degrades to something a
|
|
115
|
+
* reviewer can see rather than to a blank space in the page.
|
|
116
|
+
*
|
|
117
|
+
* Outside a `<ForgeI18nProvider>` it returns a working English translator
|
|
118
|
+
* rather than throwing, so a component still renders standalone. This mirrors
|
|
119
|
+
* `useFunnelStep`, which returns a working no-op outside a `<Funnel>` for the
|
|
120
|
+
* same reason.
|
|
121
|
+
*/
|
|
122
|
+
export function useForgeT(): ForgeT {
|
|
123
|
+
const locale = useForgeLocale();
|
|
124
|
+
return useMemo<ForgeT>(() => (key, vars) => translateForge(locale, key, vars), [locale]);
|
|
125
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,27 @@ export {
|
|
|
19
19
|
} from "./provider/ForgeProvider";
|
|
20
20
|
export { SiteConfigProvider, useInitialSiteConfig } from "./provider/SiteConfigProvider";
|
|
21
21
|
|
|
22
|
+
// i18n. Strings are bundled in the package (en + de), never fetched, so there
|
|
23
|
+
// is no loading state and no untranslated first paint. `TribeNestApp` mounts
|
|
24
|
+
// the provider, so a site gets this from a Forge upgrade alone. Outside a
|
|
25
|
+
// provider `useForgeT()` still returns a working English translator.
|
|
26
|
+
// Key naming is fixed by src/i18n/CONVENTION.md.
|
|
27
|
+
export {
|
|
28
|
+
useForgeT,
|
|
29
|
+
useForgeLocale,
|
|
30
|
+
ForgeI18nProvider,
|
|
31
|
+
resolveForgeLocale,
|
|
32
|
+
translateForge,
|
|
33
|
+
getForgeMessages,
|
|
34
|
+
FORGE_LOCALES,
|
|
35
|
+
FORGE_DEFAULT_LOCALE,
|
|
36
|
+
type ForgeT,
|
|
37
|
+
type ForgeTVars,
|
|
38
|
+
type ForgeLocale,
|
|
39
|
+
type ForgeMessages,
|
|
40
|
+
type ForgeI18nProviderProps,
|
|
41
|
+
} from "./i18n";
|
|
42
|
+
|
|
22
43
|
// All domain types + enums/consts (models) and WebPage.
|
|
23
44
|
export * from "./types";
|
|
24
45
|
|
|
@@ -73,7 +73,8 @@ export interface ForgeProviderProps {
|
|
|
73
73
|
initialToken?: string | null;
|
|
74
74
|
/** Editing affordances (the in-place content editor). Off for a live site. */
|
|
75
75
|
editable?: boolean;
|
|
76
|
-
/** Content locale.
|
|
76
|
+
/** Content locale. Also the language `useForgeT()` renders Forge's own copy
|
|
77
|
+
* in when the tree is wrapped by `TribeNestApp`. Absent means "en". */
|
|
77
78
|
locale?: string;
|
|
78
79
|
/** SSR content document (from `fetchContentDocument`). Defaults to empty. */
|
|
79
80
|
initialContentDoc?: ContentDocument;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createContext, useContext, type ReactNode } from "react";
|
|
2
2
|
import type { SiteConfig } from "../data/queries/useWebsite";
|
|
3
|
+
import { setSiteLocale } from "../client/activeLocale";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Holds an SSR-fetched site config so `useSiteConfig()` can seed React Query
|
|
@@ -18,6 +19,16 @@ export function SiteConfigProvider({
|
|
|
18
19
|
initialConfig?: SiteConfig | null;
|
|
19
20
|
children: ReactNode;
|
|
20
21
|
}) {
|
|
22
|
+
// Publish the tenant's language to the axios client, which sends it as
|
|
23
|
+
// `x-locale` on every request so API messages come back in the language the
|
|
24
|
+
// page is written in. Done here rather than in each site's `__root.tsx` so an
|
|
25
|
+
// existing site gets it from a Forge upgrade alone.
|
|
26
|
+
//
|
|
27
|
+
// During render, not in an effect: an effect runs after the first paint, by
|
|
28
|
+
// which time the queries on the first screen have already gone out without the
|
|
29
|
+
// header. The write is an idempotent assignment of a value that is constant for
|
|
30
|
+
// the deploy, so repeating it (StrictMode, every SSR request) changes nothing.
|
|
31
|
+
setSiteLocale(initialConfig?.locale);
|
|
21
32
|
return <SiteConfigContext.Provider value={initialConfig ?? undefined}>{children}</SiteConfigContext.Provider>;
|
|
22
33
|
}
|
|
23
34
|
|
|
@@ -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
|
+
});
|