@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.
Files changed (129) hide show
  1. package/package.json +6 -3
  2. package/src/_tests/publishedResolvability.spec.ts +184 -0
  3. package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
  4. package/src/_tests/workspaceAliases.ts +40 -0
  5. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  6. package/src/client/activeLocale.ts +34 -0
  7. package/src/client/createForgeClient.ts +13 -0
  8. package/src/contexts/CartContext.tsx +76 -0
  9. package/src/contexts/PublicAuthContext.tsx +34 -5
  10. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  11. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  12. package/src/data/queries/useBroadcasts.ts +151 -0
  13. package/src/data/queries/useCheckouts.ts +83 -0
  14. package/src/data/queries/useMyBookings.ts +9 -1
  15. package/src/data/queries/useWebsite.ts +7 -0
  16. package/src/i18n/CONVENTION.md +184 -0
  17. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  18. package/src/i18n/de.json +1019 -0
  19. package/src/i18n/en.json +1019 -0
  20. package/src/i18n/index.ts +125 -0
  21. package/src/index.ts +21 -0
  22. package/src/provider/ForgeAppProvider.tsx +2 -1
  23. package/src/provider/SiteConfigProvider.tsx +11 -0
  24. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  25. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  26. package/src/ui/format/membershipPwyw.ts +164 -0
  27. package/src/ui/format/pwyw.ts +37 -0
  28. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  29. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  30. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  31. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  32. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  33. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  34. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  35. package/src/ui/headless/checkout/useCheckout.ts +9 -2
  36. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  37. package/src/ui/headless/index.ts +32 -0
  38. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  39. package/src/ui/index.ts +38 -0
  40. package/src/ui/media/CallHelpHint.tsx +87 -0
  41. package/src/ui/media/CallStage.tsx +542 -0
  42. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  43. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  44. package/src/ui/media/_tests/callState.spec.ts +452 -0
  45. package/src/ui/media/_tests/fakeNode.ts +178 -0
  46. package/src/ui/media/bookingSession.tsx +194 -0
  47. package/src/ui/media/callState.ts +341 -0
  48. package/src/ui/media/index.ts +135 -0
  49. package/src/ui/shell/TribeNestApp.tsx +36 -27
  50. package/src/ui/styled/AccountDashboard.tsx +289 -96
  51. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  52. package/src/ui/styled/AudioPlayer.tsx +14 -6
  53. package/src/ui/styled/BlogCategory.tsx +4 -2
  54. package/src/ui/styled/BlogComments.tsx +27 -20
  55. package/src/ui/styled/BlogList.tsx +5 -3
  56. package/src/ui/styled/BlogPost.tsx +14 -11
  57. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  58. package/src/ui/styled/BundleConfirmation.tsx +32 -23
  59. package/src/ui/styled/CancellationTerms.tsx +3 -1
  60. package/src/ui/styled/Cart.tsx +32 -14
  61. package/src/ui/styled/ChatRoom.tsx +65 -20
  62. package/src/ui/styled/Checkout.tsx +151 -89
  63. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  64. package/src/ui/styled/CoachingBooking.tsx +71 -44
  65. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  66. package/src/ui/styled/CoachingDetail.tsx +15 -11
  67. package/src/ui/styled/CohortPage.tsx +10 -8
  68. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  69. package/src/ui/styled/ContactForm.tsx +31 -12
  70. package/src/ui/styled/CookieConsent.tsx +19 -15
  71. package/src/ui/styled/CourseAccess.tsx +54 -47
  72. package/src/ui/styled/CourseCheckout.tsx +56 -38
  73. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  74. package/src/ui/styled/CourseDetail.tsx +21 -15
  75. package/src/ui/styled/CoursesGrid.tsx +4 -2
  76. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  77. package/src/ui/styled/DiscountCode.tsx +22 -18
  78. package/src/ui/styled/DonationButton.tsx +52 -17
  79. package/src/ui/styled/DonationPage.tsx +4 -4
  80. package/src/ui/styled/EmailListForm.tsx +22 -9
  81. package/src/ui/styled/EventConfirmation.tsx +37 -14
  82. package/src/ui/styled/EventCountdown.tsx +10 -6
  83. package/src/ui/styled/EventDetail.tsx +8 -6
  84. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  85. package/src/ui/styled/EventTickets.tsx +232 -108
  86. package/src/ui/styled/EventWaitlist.tsx +42 -33
  87. package/src/ui/styled/EventsList.tsx +15 -13
  88. package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
  89. package/src/ui/styled/FormRenderer.tsx +12 -7
  90. package/src/ui/styled/InstallBanner.tsx +21 -18
  91. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  92. package/src/ui/styled/InvoicePayment.tsx +37 -16
  93. package/src/ui/styled/LeadMagnet.tsx +9 -7
  94. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  95. package/src/ui/styled/Loading.tsx +7 -1
  96. package/src/ui/styled/LoginForm.tsx +32 -14
  97. package/src/ui/styled/MembershipCheckout.tsx +341 -49
  98. package/src/ui/styled/MembershipTiers.tsx +34 -18
  99. package/src/ui/styled/OfferButton.tsx +33 -13
  100. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  101. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  102. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  103. package/src/ui/styled/PodcastList.tsx +9 -3
  104. package/src/ui/styled/PodcastShow.tsx +29 -7
  105. package/src/ui/styled/PostsFeed.tsx +10 -8
  106. package/src/ui/styled/PresaleCode.tsx +8 -11
  107. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  108. package/src/ui/styled/ProductDetail.tsx +58 -28
  109. package/src/ui/styled/ReactionBar.tsx +4 -2
  110. package/src/ui/styled/ReplayList.tsx +24 -13
  111. package/src/ui/styled/ResetPasswordForm.tsx +32 -15
  112. package/src/ui/styled/ResumeCart.tsx +289 -0
  113. package/src/ui/styled/ReviewForm.tsx +52 -58
  114. package/src/ui/styled/SignupForm.tsx +34 -16
  115. package/src/ui/styled/TicketTransfer.tsx +55 -54
  116. package/src/ui/styled/UserMenu.tsx +16 -14
  117. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  118. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  119. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  120. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
  121. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  122. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  123. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  124. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  125. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  126. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  127. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  128. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  129. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
package/package.json CHANGED
@@ -1,15 +1,16 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.26.0",
3
+ "version": "3.31.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "description": "Forge \u2014 the headless React SDK for building custom TribeNest creator sites (the Hydrogen of TribeNest). Exposes the backend (memberships, commerce, gated content, ticketing, courses, booking, auth) as data + behavior primitives.",
7
+ "description": "Forge: the headless React SDK for building custom TribeNest creator sites (the Hydrogen of TribeNest). Exposes the backend (memberships, commerce, gated content, ticketing, courses, booking, auth) as data + behavior primitives.",
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
10
  "./ui": "./src/ui/index.ts",
11
11
  "./ui/headless": "./src/ui/headless/index.ts",
12
- "./server": "./src/server/index.ts"
12
+ "./server": "./src/server/index.ts",
13
+ "./media": "./src/ui/media/index.ts"
13
14
  },
14
15
  "files": [
15
16
  "src"
@@ -26,6 +27,8 @@
26
27
  "@stripe/react-stripe-js": "^3.8.0",
27
28
  "@stripe/stripe-js": "^7.6.1",
28
29
  "@tanstack/react-query": "^5.62.7",
30
+ "@tribe-nest/media-client": "^0.1.0",
31
+ "@tribe-nest/media-protocol": "^1.0.0",
29
32
  "axios": "^1.7.9",
30
33
  "lucide-react": "^0.510.0",
31
34
  "react-hook-form": "^7.60.0",
@@ -0,0 +1,184 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "fs";
3
+ import { join } from "path";
4
+
5
+ /**
6
+ * Can a creator site actually INSTALL what this package imports?
7
+ *
8
+ * Forge is unusual twice over. It publishes RAW SOURCE (`"files": ["src"]`, an
9
+ * exports map pointing at `./src/...`, no build step), so every import in this
10
+ * tree is resolved by the CONSUMER'S bundler against the CONSUMER'S
11
+ * `node_modules` - not by anything in this repo. And a creator site is a
12
+ * site-starter fork whose `package.json` lists `@tribe-nest/forge` and nothing
13
+ * else, so the only packages present are Forge's own declared dependencies.
14
+ *
15
+ * Inside the monorepo none of that is visible: npm workspaces symlink every
16
+ * package in `packages/` into `node_modules`, so an import resolves whether or
17
+ * not it was ever declared, and whether or not the package it names is
18
+ * publishable at all. Every spec in this package, and every `check-types` run,
19
+ * passes under those symlinks.
20
+ *
21
+ * The failure this exists for actually shipped. `./media` - the entry point that
22
+ * holds `BookingCallProvider`, the ONLY surface that can enter a coaching video
23
+ * room - imports the two media packages, both of which were `"private": true`
24
+ * (so npm would never publish them) and declared only as OPTIONAL peer
25
+ * dependencies (so npm would never install them). `./media` is registered in
26
+ * `ENTRY_SUBPATHS` in
27
+ * `apps/backend/src/utils/forgeManifest.ts` precisely so the code agent learns
28
+ * it exists and writes `import { BookingCallProvider } from
29
+ * "@tribe-nest/forge/media"` - an import that failed at install on every real
30
+ * creator site, while the whole suite stayed green and the starter's release
31
+ * notes announced the feature.
32
+ *
33
+ * So there are two properties here and they are separate:
34
+ *
35
+ * 1. every bare import in the published tree names a package the consumer's
36
+ * install will actually produce (a dependency, or a peer that npm installs
37
+ * because it is NOT marked optional), and
38
+ * 2. every workspace package Forge depends on is one npm will publish.
39
+ *
40
+ * Specs are excluded: they never ship (`files` is `["src"]`, but a consumer
41
+ * never imports a `.spec` file), and they legitimately reach for vitest, jsdom
42
+ * and node builtins.
43
+ */
44
+
45
+ const FORGE_ROOT = join(__dirname, "..", "..");
46
+ const PACKAGES_ROOT = join(FORGE_ROOT, "..");
47
+
48
+ type PackageJson = {
49
+ name: string;
50
+ private?: boolean;
51
+ files?: string[];
52
+ exports?: Record<string, unknown>;
53
+ dependencies?: Record<string, string>;
54
+ peerDependencies?: Record<string, string>;
55
+ peerDependenciesMeta?: Record<string, { optional?: boolean }>;
56
+ };
57
+
58
+ const readPackage = (dir: string): PackageJson =>
59
+ JSON.parse(readFileSync(join(dir, "package.json"), "utf-8")) as PackageJson;
60
+
61
+ const forge = readPackage(FORGE_ROOT);
62
+
63
+ /**
64
+ * Provided by the runtime, never installed: `node:` builtins, and the
65
+ * `cloudflare:` virtual modules workerd resolves itself (Forge's server half
66
+ * runs on Workers).
67
+ */
68
+ const isBuiltin = (specifier: string) => specifier.startsWith("node:") || specifier.startsWith("cloudflare:");
69
+
70
+ const isRelative = (specifier: string) => specifier.startsWith(".") || specifier.startsWith("/");
71
+
72
+ /** `@scope/name/deep/path` and `name/deep/path` both resolve to the PACKAGE. */
73
+ const packageNameOf = (specifier: string): string => {
74
+ const parts = specifier.split("/");
75
+ return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]!;
76
+ };
77
+
78
+ const isSpecFile = (path: string) => path.includes(`${"/"}_tests${"/"}`) || /\.(spec|test)\.[cm]?[jt]sx?$/.test(path);
79
+
80
+ const sourceFiles = (dir: string, found: string[] = []): string[] => {
81
+ for (const entry of readdirSync(dir)) {
82
+ const full = join(dir, entry);
83
+ if (statSync(full).isDirectory()) {
84
+ sourceFiles(full, found);
85
+ continue;
86
+ }
87
+ if (!/\.[cm]?[jt]sx?$/.test(full)) continue;
88
+ if (isSpecFile(full)) continue;
89
+ found.push(full);
90
+ }
91
+ return found;
92
+ };
93
+
94
+ /**
95
+ * The module specifiers, and only those.
96
+ *
97
+ * Anchored at a statement start and unable to cross a `;`, because a bare
98
+ * `/from ["']/` also matches ordinary prose in a string literal - this package
99
+ * has several ("gone for the next few minutes"), and each one would arrive here
100
+ * looking like an undeclared dependency.
101
+ */
102
+ const IMPORT_PATTERNS = [
103
+ // `import … from "x"` / `export … from "x"`, including the multi-line form.
104
+ /(?:^|\n)[ \t]*(?:import|export)\s[^;]*?\bfrom\s*["']([^"']+)["']/g,
105
+ // Side-effect import: `import "x";`
106
+ /(?:^|\n)[ \t]*import\s*["']([^"']+)["']/g,
107
+ // `import("x")` and `require("x")`.
108
+ /\b(?:import|require)\s*\(\s*["']([^"']+)["']\s*\)/g,
109
+ ];
110
+
111
+ const specifiersIn = (contents: string): string[] =>
112
+ IMPORT_PATTERNS.flatMap((pattern) => [...contents.matchAll(pattern)].map((match) => match[1]!));
113
+
114
+ const importedPackages = (): Map<string, string[]> => {
115
+ const byPackage = new Map<string, string[]>();
116
+ for (const file of sourceFiles(join(FORGE_ROOT, "src"))) {
117
+ const contents = readFileSync(file, "utf-8");
118
+ for (const specifier of specifiersIn(contents)) {
119
+ if (isRelative(specifier) || isBuiltin(specifier)) continue;
120
+ const name = packageNameOf(specifier);
121
+ // `@tribe-nest/forge` importing itself only ever happens in a spec, but
122
+ // guard it anyway: a self-reference resolves through the exports map.
123
+ if (name === forge.name) continue;
124
+ const files = byPackage.get(name) ?? [];
125
+ files.push(file.slice(FORGE_ROOT.length + 1));
126
+ byPackage.set(name, files);
127
+ }
128
+ }
129
+ return byPackage;
130
+ };
131
+
132
+ describe("what a creator site's install has to produce", () => {
133
+ it("declares every package the published tree imports", () => {
134
+ const declared = new Set([
135
+ ...Object.keys(forge.dependencies ?? {}),
136
+ // A peer npm INSTALLS. An optional one is exactly the hole this closes:
137
+ // npm skips it, and the import it serves is unresolved on every site.
138
+ ...Object.keys(forge.peerDependencies ?? {}).filter((name) => !forge.peerDependenciesMeta?.[name]?.optional),
139
+ ]);
140
+
141
+ const undeclared = [...importedPackages()]
142
+ .filter(([name]) => !declared.has(name))
143
+ .map(([name, files]) => `${name} (imported by ${files[0]})`);
144
+
145
+ expect(undeclared).toEqual([]);
146
+ });
147
+
148
+ it("depends only on workspace packages npm will publish", () => {
149
+ const workspaceDeps = Object.keys(forge.dependencies ?? {}).filter((name) => name.startsWith("@tribe-nest/"));
150
+ // Not a vacuous pass: `./media` puts two of these in the tree, and a
151
+ // regression that removed them would be caught by the test above instead.
152
+ expect(workspaceDeps.length).toBeGreaterThan(0);
153
+
154
+ for (const name of workspaceDeps) {
155
+ const dir = join(PACKAGES_ROOT, name.replace("@tribe-nest/", ""));
156
+ expect(existsSync(dir), `${name} is not a package in packages/`).toBe(true);
157
+
158
+ const dependency = readPackage(dir);
159
+ // `"private": true` makes `npm publish` refuse outright, so the consumer's
160
+ // install 404s on a package that looks perfectly declared here.
161
+ expect(dependency.private, `${name} is private and can never be installed`).not.toBe(true);
162
+ // Its published artifact has to carry whatever its exports map points at.
163
+ expect(dependency.files ?? [], `${name} publishes no files`).not.toEqual([]);
164
+ }
165
+ });
166
+
167
+ it("points every entry subpath at a file that exists", () => {
168
+ for (const [subpath, target] of Object.entries(forge.exports ?? {})) {
169
+ expect(typeof target, `${subpath} has a non-string target`).toBe("string");
170
+ expect(existsSync(join(FORGE_ROOT, target as string)), `${subpath} -> ${target}`).toBe(true);
171
+ }
172
+ });
173
+
174
+ /**
175
+ * `./media` is the entry the code agent is TOLD about: it is registered in
176
+ * `ENTRY_SUBPATHS` in `apps/backend/src/utils/forgeManifest.ts`, so the agent
177
+ * writes imports against it. Dropping it from the exports map here would make
178
+ * every one of those imports unresolvable while the manifest went on
179
+ * advertising them.
180
+ */
181
+ it("still exports the media entry the Forge manifest advertises", () => {
182
+ expect(forge.exports?.["./media"]).toBe("./src/ui/media/index.ts");
183
+ });
184
+ });
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { join } from "path";
4
+
5
+ import { WORKSPACE_SOURCE_ALIASES } from "./workspaceAliases";
6
+
7
+ /**
8
+ * Which ARTIFACT do these specs run?
9
+ *
10
+ * `@tribe-nest/media-client` and `@tribe-nest/media-protocol` publish an
11
+ * exports map whose conditions are `{ types, module, default }`, and under
12
+ * vitest the `default` branch wins: every spec in this package was importing
13
+ * `media-client/build/react/index.js`, the previous COMPILATION of the very
14
+ * file a change to the call UI is editing. `build/` is gitignored, so the code
15
+ * under test was whatever that machine last compiled.
16
+ *
17
+ * It is not theoretical. Reintroducing the historical
18
+ * `[...room.localPublications]` selector into `src/react/index.tsx` - the exact
19
+ * mutation `ui/media/_tests/CallStage.spec.tsx`'s anti-loop guard exists to
20
+ * catch - left all 14 of its tests green. Applying it to `build/react/index.js`
21
+ * turned all 14 red. The guard the whole file is built around was guarding a
22
+ * stale copy.
23
+ *
24
+ * A root `turbo run test` builds first (`dependsOn: ["^build"]`), so CI never
25
+ * saw it. The per-package `npm run test` and the watch loop - where the
26
+ * iteration actually happens - did.
27
+ *
28
+ * So: every workspace package this one depends on whose exports point at
29
+ * `build/` must be aliased to its SOURCE in `vitest.config.mts`. This test is
30
+ * what makes adding the next such dependency fail loudly rather than silently
31
+ * testing yesterday's compilation of it.
32
+ */
33
+
34
+ const FORGE_ROOT = join(__dirname, "..", "..");
35
+ const PACKAGES_ROOT = join(FORGE_ROOT, "..");
36
+
37
+ type ExportTarget = string | Record<string, string>;
38
+ type PackageJson = {
39
+ name: string;
40
+ exports?: Record<string, ExportTarget>;
41
+ dependencies?: Record<string, string>;
42
+ };
43
+
44
+ const readPackage = (dir: string): PackageJson =>
45
+ JSON.parse(readFileSync(join(dir, "package.json"), "utf-8")) as PackageJson;
46
+
47
+ const forge = readPackage(FORGE_ROOT);
48
+
49
+ /** The array `vitest.config.mts` hands to `resolve.alias`, not a copy of it. */
50
+ const aliases = () => WORKSPACE_SOURCE_ALIASES;
51
+
52
+ const matches = (find: RegExp | string, specifier: string): boolean =>
53
+ typeof find === "string" ? specifier.startsWith(find) : find.test(specifier);
54
+
55
+ /** Every subpath a package publishes, as the specifier a spec would import. */
56
+ const subpathSpecifiers = (pkg: PackageJson): { specifier: string; target: ExportTarget }[] =>
57
+ Object.entries(pkg.exports ?? {}).map(([subpath, target]) => ({
58
+ specifier: subpath === "." ? pkg.name : `${pkg.name}/${subpath.replace(/^\.\//, "")}`,
59
+ target,
60
+ }));
61
+
62
+ /** Does resolving this entry land in a compiled artifact rather than in `src`? */
63
+ const resolvesToBuild = (target: ExportTarget): boolean => {
64
+ const candidate = typeof target === "string" ? target : target.default;
65
+ return typeof candidate === "string" && candidate.includes("build/");
66
+ };
67
+
68
+ describe("the specs in this package run the source, not a stale build", () => {
69
+ const workspaceDeps = Object.keys(forge.dependencies ?? {}).filter((name) => name.startsWith("@tribe-nest/"));
70
+
71
+ it("has workspace dependencies to check, so this is not vacuous", () => {
72
+ expect(workspaceDeps.length).toBeGreaterThan(0);
73
+ });
74
+
75
+ it("is the list the runner actually uses", () => {
76
+ // Read as TEXT because `tsc` refuses an import path ending in `.mts`. The
77
+ // point is that the array asserted below is the one wired into
78
+ // `resolve.alias`, and not a module the config stopped importing.
79
+ const config = readFileSync(join(FORGE_ROOT, "vitest.config.mts"), "utf-8");
80
+ expect(config).toContain("WORKSPACE_SOURCE_ALIASES");
81
+ expect(config).toMatch(/resolve:\s*{\s*alias:\s*WORKSPACE_SOURCE_ALIASES/);
82
+ });
83
+
84
+ it("aliases every build-first workspace entry point to its source", () => {
85
+ const unaliased: string[] = [];
86
+
87
+ for (const name of workspaceDeps) {
88
+ const dir = join(PACKAGES_ROOT, name.replace("@tribe-nest/", ""));
89
+ if (!existsSync(dir)) continue;
90
+
91
+ for (const { specifier, target } of subpathSpecifiers(readPackage(dir))) {
92
+ if (!resolvesToBuild(target)) continue;
93
+ if (!aliases().some((alias) => matches(alias.find, specifier))) unaliased.push(specifier);
94
+ }
95
+ }
96
+
97
+ expect(unaliased).toEqual([]);
98
+ });
99
+
100
+ it("points every alias at a file that exists", () => {
101
+ for (const alias of aliases()) {
102
+ expect(existsSync(alias.replacement), `${String(alias.find)} -> ${alias.replacement}`).toBe(true);
103
+ expect(alias.replacement, `${String(alias.find)} is aliased to a build artifact`).not.toContain("/build/");
104
+ }
105
+ });
106
+
107
+ it("does not let the bare package alias swallow its own subpaths", () => {
108
+ // `{ find: "@tribe-nest/media-client" }` as a plain string is a PREFIX
109
+ // match, so it would rewrite `@tribe-nest/media-client/react` to
110
+ // `.../src/index.ts/react`. Anchored patterns are what keep the subpaths
111
+ // reachable, and this is the mistake that silently un-does the fix above.
112
+ const bare = aliases().find((alias) => matches(alias.find, "@tribe-nest/media-client"));
113
+ expect(bare, "media-client has no alias at all").toBeDefined();
114
+ expect(matches(bare!.find, "@tribe-nest/media-client/react")).toBe(false);
115
+ });
116
+ });
@@ -0,0 +1,40 @@
1
+ import { fileURLToPath } from "node:url";
2
+
3
+ /**
4
+ * Which ARTIFACT the specs in this package run.
5
+ *
6
+ * `@tribe-nest/media-client` and `@tribe-nest/media-protocol` publish an
7
+ * exports map whose conditions are `{ types, module, default }`, and under
8
+ * vitest the `default` branch wins: every spec here was importing
9
+ * `media-client/build/react/index.js`, the previous COMPILATION of the very
10
+ * file a change to the call UI edits. `build/` is gitignored, so the code under
11
+ * test was whatever that machine last compiled.
12
+ *
13
+ * It is not theoretical. Reintroducing the historical
14
+ * `[...room.localPublications]` selector into `media-client/src/react/index.tsx`
15
+ * - the exact mutation `ui/media/_tests/CallStage.spec.tsx`'s anti-loop guard
16
+ * exists to catch - left all 14 of its tests green. Applying the same edit to
17
+ * `build/react/index.js` turned all 14 red.
18
+ *
19
+ * A root `turbo run test` builds first (`dependsOn: ["^build"]`), so CI never
20
+ * saw it; the per-package `npm run test` and the watch loop, where the
21
+ * iteration actually happens, did.
22
+ *
23
+ * Lives in `src/` rather than beside the config because `vitest.config.mts`
24
+ * cannot be imported from a spec (`tsc` refuses a `.mts` import path), and
25
+ * `specsRunWorkspaceSource.spec.ts` has to read the same list the runner uses
26
+ * or it is checking its own copy.
27
+ */
28
+
29
+ /** Exact-match patterns, so the bare package does not swallow its subpaths. */
30
+ export const WORKSPACE_SOURCE_ALIASES: { find: RegExp; replacement: string }[] = [
31
+ ["@tribe-nest/media-client", "../../../media-client/src/index.ts"],
32
+ ["@tribe-nest/media-client/react", "../../../media-client/src/react/index.tsx"],
33
+ ["@tribe-nest/media-client/core", "../../../media-client/src/core/index.ts"],
34
+ ["@tribe-nest/media-client/protocol", "../../../media-client/src/protocol.ts"],
35
+ ["@tribe-nest/media-protocol", "../../../media-protocol/src/index.ts"],
36
+ ["@tribe-nest/media-protocol/token", "../../../media-protocol/src/token.ts"],
37
+ ].map(([specifier, path]) => ({
38
+ find: new RegExp(`^${specifier!.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&")}$`),
39
+ replacement: fileURLToPath(new URL(path!, import.meta.url)),
40
+ }));
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import type { AxiosRequestConfig } from "axios";
4
+ import { createForgeClient } from "../createForgeClient";
5
+ import { getSiteLocale, setSiteLocale } from "../activeLocale";
6
+ import { SiteConfigProvider } from "../../provider/SiteConfigProvider";
7
+ import type { SiteConfig } from "../../data/queries/useWebsite";
8
+
9
+ /**
10
+ * The last link in the chain that lets a German site be German all the way down:
11
+ * the tenant's locale rides every API request as `x-locale`, which the backend
12
+ * reads into its request store and answers in.
13
+ *
14
+ * The failure this guards against is quiet. Nothing errors when the header is
15
+ * missing: the page renders German, the API answers English, and the only
16
+ * person who notices is the visitor who gets told "Product not found" on an
17
+ * otherwise German checkout.
18
+ */
19
+
20
+ // Captures the outgoing config without a network round trip. The request
21
+ // interceptor has already run by the time the adapter is called, so whatever
22
+ // lands here is exactly what would have gone over the wire.
23
+ function clientCapturingHeaders() {
24
+ const sent: AxiosRequestConfig[] = [];
25
+ const client = createForgeClient({ baseURL: "https://api.example.test" });
26
+ client.defaults.adapter = async (config) => {
27
+ sent.push(config);
28
+ return { data: {}, status: 200, statusText: "OK", headers: {}, config } as never;
29
+ };
30
+ return { client, sent };
31
+ }
32
+
33
+ describe("x-locale on every Forge request", () => {
34
+ beforeEach(() => {
35
+ setSiteLocale(null);
36
+ });
37
+
38
+ it("sends nothing before any site config has been seen", async () => {
39
+ const { client, sent } = clientCapturingHeaders();
40
+ await client.get("/public/products");
41
+ expect(sent[0].headers?.["x-locale"]).toBeUndefined();
42
+ });
43
+
44
+ it("sends the tenant's locale once it is known", async () => {
45
+ setSiteLocale("de");
46
+ const { client, sent } = clientCapturingHeaders();
47
+ await client.get("/public/products");
48
+ expect(sent[0].headers?.["x-locale"]).toBe("de");
49
+ });
50
+
51
+ // The locale is read per request rather than captured when the client is
52
+ // built: the client is created once, before any site config exists.
53
+ it("picks up a locale set after the client was created", async () => {
54
+ const { client, sent } = clientCapturingHeaders();
55
+ await client.get("/public/products");
56
+ setSiteLocale("de");
57
+ await client.get("/public/products");
58
+ expect(sent[0].headers?.["x-locale"]).toBeUndefined();
59
+ expect(sent[1].headers?.["x-locale"]).toBe("de");
60
+ });
61
+
62
+ it("leaves the other tenant headers alone", async () => {
63
+ setSiteLocale("de");
64
+ const { client, sent } = clientCapturingHeaders();
65
+ await client.get("/public/products");
66
+ expect(sent[0].headers?.["x-timezone-offset"]).toBeDefined();
67
+ });
68
+ });
69
+
70
+ describe("the site config publishes the locale to the client", () => {
71
+ beforeEach(() => {
72
+ setSiteLocale(null);
73
+ });
74
+
75
+ // Mounted by every site through TribeNestApp, which is what makes the header
76
+ // arrive on a Forge upgrade alone, with no edit to a tenant's __root.tsx.
77
+ it("SiteConfigProvider sets it during render, before any child can fetch", () => {
78
+ renderToStaticMarkup(
79
+ <SiteConfigProvider initialConfig={{ locale: "de" } as SiteConfig}>
80
+ <span>{getSiteLocale()}</span>
81
+ </SiteConfigProvider>,
82
+ );
83
+ expect(getSiteLocale()).toBe("de");
84
+ });
85
+
86
+ // An older backend returns a config with no `locale` at all, and a failed SSR
87
+ // bootstrap returns no config. Both mean English, which is what an absent
88
+ // header already gets.
89
+ it("clears the locale when the config carries none", () => {
90
+ setSiteLocale("de");
91
+ renderToStaticMarkup(
92
+ <SiteConfigProvider initialConfig={{} as SiteConfig}>
93
+ <span />
94
+ </SiteConfigProvider>,
95
+ );
96
+ expect(getSiteLocale()).toBeNull();
97
+ });
98
+ });
@@ -0,0 +1,34 @@
1
+ // The language the site is being served in, held outside React so the shared
2
+ // axios client can read it.
3
+ //
4
+ // Why this exists: without it a German site renders German and every error the
5
+ // API returns arrives in English, which is the failure the visitor sees rather
6
+ // than the one the creator configured. The backend already reads an `x-locale`
7
+ // header into its request store (`LOCALE_HEADER_KEY`), so the whole fix is
8
+ // getting the tenant's locale onto every request.
9
+ //
10
+ // Why a module singleton rather than a prop threaded to `createForgeClient`:
11
+ // the client is created once, per `apiUrl`, before any site config is known,
12
+ // and the locale arrives later with the site config. The same reasoning as
13
+ // `activeFunnel` applies too: put it in the transport and no call site can
14
+ // forget it.
15
+ //
16
+ // Scope: one locale at a time. One deploy serves one tenant, so on the server
17
+ // every request writes the same value, and a creator changing the setting is
18
+ // picked up on the next SSR render.
19
+
20
+ let siteLocale: string | null = null;
21
+
22
+ /**
23
+ * Set the language every subsequent API request declares. Called with
24
+ * `siteConfig.locale` where the config enters the tree, so an existing site
25
+ * gains the header from a Forge upgrade with no edit to its `__root.tsx`.
26
+ */
27
+ export function setSiteLocale(locale: string | null | undefined): void {
28
+ siteLocale = locale || null;
29
+ }
30
+
31
+ /** The active site locale, or null before any site config has been seen. */
32
+ export function getSiteLocale(): string | null {
33
+ return siteLocale;
34
+ }
@@ -1,5 +1,6 @@
1
1
  import axios, { type AxiosError, type AxiosInstance } from "axios";
2
2
  import { getActiveFunnelId } from "./activeFunnel";
3
+ import { getSiteLocale } from "./activeLocale";
3
4
 
4
5
  /**
5
6
  * Access-token refresh (backend §7 cutover). Access tokens live ~15 minutes;
@@ -69,6 +70,18 @@ export const createForgeClient = ({
69
70
  config.headers["x-funnel-id"] = funnelId;
70
71
  }
71
72
 
73
+ // The tenant's language, so the API answers in the language the page is
74
+ // written in. Without it a German site renders German and every validation
75
+ // message, error and generated string comes back English. The backend reads
76
+ // this header into its request store, so it covers everything a request
77
+ // touches rather than the endpoints someone remembered to translate.
78
+ // Absent until the site config has been seen, and the backend defaults to
79
+ // English on an absent or unrecognised value.
80
+ const locale = getSiteLocale();
81
+ if (locale) {
82
+ config.headers["x-locale"] = locale;
83
+ }
84
+
72
85
  const token = getToken?.();
73
86
  if (token) {
74
87
  config.headers["authorization"] = `Bearer ${token}`;
@@ -106,6 +106,35 @@ interface CartContextType {
106
106
  addToCart: (item: CartItem) => boolean;
107
107
  removeFromCart: (productId: string, isGift: boolean, recipientEmail?: string) => void;
108
108
  clearCart: () => void;
109
+ /**
110
+ * Put a whole basket back at once, MERGING with whatever is already there.
111
+ *
112
+ * Built for the recovery link (`/i/checkout/resume`), where the buyer arrives
113
+ * from an email with lines the server has just re-priced. It exists rather
114
+ * than a loop over `addToCart` for two reasons, both of which produced wrong
115
+ * baskets when tried:
116
+ *
117
+ * - `addToCart` reads `cartItems` from its closure, so a loop decides
118
+ * "already in the cart?" against a snapshot that is one render old. A
119
+ * restored line matching one the visitor already had appended a DUPLICATE
120
+ * instead of replacing it. This merges inside a functional update, so every
121
+ * line is matched against the real current state.
122
+ * - Restoring must not pop the drawer open per line. The resume page IS the
123
+ * surface; a drawer sliding over it is noise.
124
+ *
125
+ * MERGE, not replace: a visitor who put something in their basket between the
126
+ * email and the click keeps it. The restored line wins on a collision,
127
+ * because it is the one that was just re-priced.
128
+ *
129
+ * Tickets merge PER TIER, not per event. An event's selection is a map, and
130
+ * treating it as one value threw away every tier the visitor had picked in
131
+ * this session the moment the resumed basket named the same event. On a tier
132
+ * both sides name, the restored quantity REPLACES the session's rather than
133
+ * adding to it, matching how a colliding product line is replaced whole: both
134
+ * sides describe the same intent at different prices, and the resumed one is
135
+ * the figure the server has just re-checked.
136
+ */
137
+ restoreCart: (input: { items?: CartItem[]; tickets?: TicketCartItem[] }) => void;
109
138
  /** Ticket selections, one entry per event. */
110
139
  ticketItems: TicketCartItem[];
111
140
  /** Add or replace the selection for an event (the modal always sends the whole map). */
@@ -232,6 +261,52 @@ export function CartProvider({ children }: { children: ReactNode }) {
232
261
  );
233
262
  };
234
263
 
264
+ /** The identity of a product line, as `addToCart` already defines it. */
265
+ const lineKey = (item: CartItem) =>
266
+ `${item.productId}|${item.productVariantId}|${item.isGift}|${item.recipientEmail ?? ""}`;
267
+
268
+ const restoreCart = useCallback((input: { items?: CartItem[]; tickets?: TicketCartItem[] }) => {
269
+ const items = input.items ?? [];
270
+ const tickets = input.tickets ?? [];
271
+
272
+ if (items.length > 0) {
273
+ setCartItems((prev) => {
274
+ const incoming = new Map(items.map((item) => [lineKey(item), item]));
275
+ const merged = prev.map((item) => incoming.get(lineKey(item)) ?? item);
276
+ const seen = new Set(prev.map(lineKey));
277
+ return [...merged, ...items.filter((item) => !seen.has(lineKey(item)))];
278
+ });
279
+ }
280
+
281
+ if (tickets.length > 0) {
282
+ setTicketItems((prev) => {
283
+ const byEvent = new Map(prev.map((ticket) => [ticket.eventId, ticket]));
284
+ for (const incoming of tickets) {
285
+ const existing = byEvent.get(incoming.eventId);
286
+ byEvent.set(
287
+ incoming.eventId,
288
+ existing
289
+ ? {
290
+ ...existing,
291
+ ...incoming,
292
+ // Tier by tier, so a tier only the session knows about
293
+ // survives and a tier both name takes the resumed quantity.
294
+ tickets: { ...existing.tickets, ...incoming.tickets },
295
+ ticketMeta: { ...existing.ticketMeta, ...incoming.ticketMeta },
296
+ // A resumed payload predating booking fees carries none, and
297
+ // an absent fee must not erase the one the modal captured.
298
+ bookingFee: incoming.bookingFee ?? existing.bookingFee ?? null,
299
+ }
300
+ : incoming,
301
+ );
302
+ }
303
+ // Insertion order: the events already in the basket keep their place,
304
+ // and events only the recovery link knows about go on the end.
305
+ return [...byEvent.values()];
306
+ });
307
+ }
308
+ }, []);
309
+
235
310
  const setTickets = useCallback((item: TicketCartItem) => {
236
311
  setTicketItems((prev) => [...prev.filter((t) => t.eventId !== item.eventId), item]);
237
312
  setCartOpen(true);
@@ -275,6 +350,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
275
350
  addToCart,
276
351
  removeFromCart,
277
352
  clearCart,
353
+ restoreCart,
278
354
  ticketItems,
279
355
  setTickets,
280
356
  removeTickets,