@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.
Files changed (90) hide show
  1. package/package.json +1 -1
  2. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  3. package/src/client/activeLocale.ts +34 -0
  4. package/src/client/createForgeClient.ts +13 -0
  5. package/src/contexts/CartContext.tsx +76 -0
  6. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  7. package/src/data/queries/useCheckouts.ts +83 -0
  8. package/src/data/queries/useWebsite.ts +7 -0
  9. package/src/i18n/CONVENTION.md +184 -0
  10. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  11. package/src/i18n/de.json +960 -0
  12. package/src/i18n/en.json +960 -0
  13. package/src/i18n/index.ts +125 -0
  14. package/src/index.ts +21 -0
  15. package/src/provider/ForgeAppProvider.tsx +2 -1
  16. package/src/provider/SiteConfigProvider.tsx +11 -0
  17. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  18. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  19. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  20. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  21. package/src/ui/headless/checkout/useCheckout.ts +9 -2
  22. package/src/ui/headless/index.ts +18 -0
  23. package/src/ui/index.ts +2 -0
  24. package/src/ui/shell/TribeNestApp.tsx +36 -27
  25. package/src/ui/styled/AccountDashboard.tsx +197 -93
  26. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  27. package/src/ui/styled/AudioPlayer.tsx +14 -6
  28. package/src/ui/styled/BlogCategory.tsx +4 -2
  29. package/src/ui/styled/BlogComments.tsx +27 -20
  30. package/src/ui/styled/BlogList.tsx +5 -3
  31. package/src/ui/styled/BlogPost.tsx +14 -11
  32. package/src/ui/styled/BundleConfirmation.tsx +32 -23
  33. package/src/ui/styled/CancellationTerms.tsx +3 -1
  34. package/src/ui/styled/Cart.tsx +32 -14
  35. package/src/ui/styled/ChatRoom.tsx +65 -20
  36. package/src/ui/styled/Checkout.tsx +151 -89
  37. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  38. package/src/ui/styled/CoachingBooking.tsx +71 -44
  39. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  40. package/src/ui/styled/CoachingDetail.tsx +15 -11
  41. package/src/ui/styled/CohortPage.tsx +10 -8
  42. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  43. package/src/ui/styled/ContactForm.tsx +31 -12
  44. package/src/ui/styled/CookieConsent.tsx +19 -15
  45. package/src/ui/styled/CourseAccess.tsx +54 -47
  46. package/src/ui/styled/CourseCheckout.tsx +56 -38
  47. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  48. package/src/ui/styled/CourseDetail.tsx +21 -15
  49. package/src/ui/styled/CoursesGrid.tsx +4 -2
  50. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  51. package/src/ui/styled/DiscountCode.tsx +22 -18
  52. package/src/ui/styled/DonationButton.tsx +52 -17
  53. package/src/ui/styled/DonationPage.tsx +4 -4
  54. package/src/ui/styled/EmailListForm.tsx +22 -9
  55. package/src/ui/styled/EventConfirmation.tsx +37 -14
  56. package/src/ui/styled/EventCountdown.tsx +10 -6
  57. package/src/ui/styled/EventDetail.tsx +8 -6
  58. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  59. package/src/ui/styled/EventTickets.tsx +232 -108
  60. package/src/ui/styled/EventWaitlist.tsx +42 -33
  61. package/src/ui/styled/EventsList.tsx +15 -13
  62. package/src/ui/styled/ForgotPasswordForm.tsx +12 -12
  63. package/src/ui/styled/FormRenderer.tsx +12 -7
  64. package/src/ui/styled/InstallBanner.tsx +21 -18
  65. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  66. package/src/ui/styled/InvoicePayment.tsx +37 -16
  67. package/src/ui/styled/LeadMagnet.tsx +9 -7
  68. package/src/ui/styled/Loading.tsx +7 -1
  69. package/src/ui/styled/LoginForm.tsx +22 -14
  70. package/src/ui/styled/MembershipCheckout.tsx +32 -13
  71. package/src/ui/styled/MembershipTiers.tsx +27 -18
  72. package/src/ui/styled/OfferButton.tsx +33 -13
  73. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  74. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  75. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  76. package/src/ui/styled/PodcastList.tsx +9 -3
  77. package/src/ui/styled/PodcastShow.tsx +29 -7
  78. package/src/ui/styled/PostsFeed.tsx +10 -8
  79. package/src/ui/styled/PresaleCode.tsx +8 -11
  80. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  81. package/src/ui/styled/ProductDetail.tsx +58 -28
  82. package/src/ui/styled/ReactionBar.tsx +4 -2
  83. package/src/ui/styled/ReplayList.tsx +24 -13
  84. package/src/ui/styled/ResetPasswordForm.tsx +27 -15
  85. package/src/ui/styled/ResumeCart.tsx +289 -0
  86. package/src/ui/styled/ReviewForm.tsx +52 -58
  87. package/src/ui/styled/SignupForm.tsx +29 -16
  88. package/src/ui/styled/TicketTransfer.tsx +55 -54
  89. package/src/ui/styled/UserMenu.tsx +16 -14
  90. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
@@ -0,0 +1,183 @@
1
+ import { describe, it, expect, vi } 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 type { ResumeLine } from "../../../data/queries/useCheckouts";
8
+ import type { UseCartResumeResult } from "../../headless/checkout/useCartResume";
9
+ import { ProductDeliveryType } from "../../../types/models";
10
+
11
+ /**
12
+ * The page a cart-recovery link lands on, as a Forge code website draws it.
13
+ *
14
+ * `useCartResume` is stubbed at the module boundary: what the hook DECIDES is
15
+ * asserted in `headless/checkout/_tests`, and what is asserted here is the half
16
+ * that can only go wrong in the markup. A bucket the hook fills and the page
17
+ * never draws is invisible to every hook test, and it is the exact shape of the
18
+ * defect this page had: an add-on left out of the basket with nothing on screen
19
+ * saying so.
20
+ *
21
+ * `apps/client` draws the same state in Tailwind and has its own twin of this
22
+ * spec. Both must move together, so both assert the same sentences.
23
+ *
24
+ * Rendered through `react-dom/server`, so no DOM and no effects.
25
+ */
26
+
27
+ const state = vi.hoisted(() => ({ current: null as UseCartResumeResult | null }));
28
+
29
+ vi.mock("../../headless/checkout/useCartResume", async (importOriginal) => ({
30
+ ...(await importOriginal<typeof import("../../headless/checkout/useCartResume")>()),
31
+ useCartResume: () => state.current,
32
+ }));
33
+
34
+ const { ResumeCart } = await import("../ResumeCart");
35
+
36
+ const line = (overrides: Partial<ResumeLine> = {}): ResumeLine => ({
37
+ type: "product",
38
+ quantity: 1,
39
+ title: "Tour Tee",
40
+ coverImage: null,
41
+ price: 25,
42
+ unitPriceCents: 2500,
43
+ quotedUnitPriceCents: 2500,
44
+ priceChanged: false,
45
+ available: true,
46
+ unavailableReason: null,
47
+ productId: "prod-1",
48
+ productVariantId: "var-1",
49
+ deliveryType: ProductDeliveryType.Physical,
50
+ isGift: false,
51
+ ...overrides,
52
+ });
53
+
54
+ const result = (overrides: Partial<UseCartResumeResult> = {}): UseCartResumeResult => ({
55
+ status: "restored",
56
+ resumed: null,
57
+ restoredLines: [],
58
+ unavailableLines: [],
59
+ continueLines: [],
60
+ droppedLines: [],
61
+ repricedLines: [],
62
+ itemCount: 0,
63
+ subtotalCents: 0,
64
+ currency: "USD",
65
+ couponCode: null,
66
+ error: null,
67
+ restore: () => {},
68
+ ...overrides,
69
+ });
70
+
71
+ function render(resume: UseCartResumeResult): string {
72
+ state.current = resume;
73
+ return renderToStaticMarkup(
74
+ <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
75
+ <ForgeClientProvider apiUrl="http://api.test" profileId="profile-1">
76
+ <PublicAuthContext.Provider value={{ currencies: null, userSelectedCurrency: "USD" } as never}>
77
+ <ForgeThemeProvider
78
+ theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
79
+ >
80
+ <ResumeCart token="tok-1" formatAmount={(n) => `$${n.toFixed(2)}`} />
81
+ </ForgeThemeProvider>
82
+ </PublicAuthContext.Provider>
83
+ </ForgeClientProvider>
84
+ </QueryClientProvider>,
85
+ );
86
+ }
87
+
88
+ describe("ResumeCart", () => {
89
+ it("REGRESSION: names the add-on that was left out, and why", () => {
90
+ // Silence here is the worst outcome the whole page exists to prevent: the
91
+ // buyer came back for the poster, the basket is one line lighter, and
92
+ // nothing says so.
93
+ const html = render(
94
+ result({
95
+ droppedLines: [line({ title: "Tour poster" })],
96
+ unavailableLines: [line({ title: "General Admission", available: false, unavailableReason: "sold_out" })],
97
+ }),
98
+ );
99
+
100
+ expect(html).toContain("Tour poster");
101
+ expect(html).toContain("Left out, because the ticket it goes with could not be put back");
102
+ expect(html).toContain("Sold only with its ticket");
103
+ });
104
+
105
+ it("keeps the left-out add-on apart from what the shop refused", () => {
106
+ // Two different sentences because the buyer's next move differs: a sold-out
107
+ // tier may free up, and the add-on is only waiting on that tier.
108
+ const html = render(
109
+ result({
110
+ droppedLines: [line({ title: "Tour poster" })],
111
+ unavailableLines: [line({ title: "Vinyl", available: false, unavailableReason: "removed" })],
112
+ }),
113
+ );
114
+
115
+ expect(html).toContain("No longer sold");
116
+ expect(html).toContain("Sold only with its ticket");
117
+ expect(html.indexOf("Could not be put back")).toBeLessThan(html.indexOf("Left out, because"));
118
+ });
119
+
120
+ it("says nothing about add-ons when none were left out", () => {
121
+ const html = render(result({ restoredLines: [line()], itemCount: 1, subtotalCents: 2500 }));
122
+ expect(html).not.toContain("Left out, because");
123
+ });
124
+
125
+ it("draws the restored lines with today's price and the total", () => {
126
+ const html = render(
127
+ result({ restoredLines: [line({ quantity: 2 })], itemCount: 2, subtotalCents: 5000 }),
128
+ );
129
+ expect(html).toContain("Tour Tee");
130
+ expect(html).toContain("2 item(s) are back in your basket.");
131
+ expect(html).toContain("Subtotal");
132
+ expect(html).toContain("$50.00");
133
+ });
134
+
135
+ it("shows the old price struck through when the shop re-priced a line", () => {
136
+ const html = render(
137
+ result({
138
+ restoredLines: [line({ priceChanged: true, quotedUnitPriceCents: 2000 })],
139
+ repricedLines: [line({ priceChanged: true })],
140
+ itemCount: 1,
141
+ subtotalCents: 2500,
142
+ }),
143
+ );
144
+ expect(html).toContain("line-through");
145
+ expect(html).toContain("$20.00");
146
+ expect(html).toContain("has changed since you were last here");
147
+ });
148
+
149
+ it("offers checkout only when something is actually in the basket", () => {
150
+ const empty = render(
151
+ result({
152
+ status: "empty",
153
+ unavailableLines: [line({ available: false, unavailableReason: "removed" })],
154
+ }),
155
+ );
156
+ expect(empty).not.toContain("Go to checkout");
157
+ expect(empty).toContain("That basket has moved on");
158
+
159
+ const full = render(result({ restoredLines: [line()], itemCount: 1, subtotalCents: 2500 }));
160
+ expect(full).toContain("Go to checkout");
161
+ });
162
+
163
+ it("links a course onward instead of pretending it is in the basket", () => {
164
+ const html = render(
165
+ result({
166
+ continueLines: [line({ type: "course", title: "Songwriting", courseSlug: "songwriting" })],
167
+ }),
168
+ );
169
+ expect(html).toContain("/i/courses/songwriting");
170
+ expect(html).toContain("Pick up where you left off");
171
+ });
172
+
173
+ it("repeats the code the buyer had, since checkout will ask for it again", () => {
174
+ const html = render(result({ couponCode: "SUMMER10", restoredLines: [line()], itemCount: 1 }));
175
+ expect(html).toContain("SUMMER10");
176
+ });
177
+
178
+ it("says the link is dead without hinting at what it might have been", () => {
179
+ const html = render(result({ status: "error", error: "invalid_link" }));
180
+ expect(html).toContain("This link no longer works");
181
+ expect(html).not.toContain("Subtotal");
182
+ });
183
+ });