@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,184 @@
1
+ # Forge i18n convention
2
+
3
+ > **`de.json` is machine-authored and has NOT been reviewed by a native speaker.**
4
+ > Treat every German string as provisional. The initiative doc names this as a
5
+ > real risk: a fan reading awkward or wrong copy at checkout is a conversion
6
+ > problem, not a cosmetic one. A native review has to happen before any tenant
7
+ > ships a German storefront.
8
+
9
+ This file is the rule. Eight agents are converting components in parallel, so
10
+ the naming has to be mechanical enough that two people who never spoke produce
11
+ the same key for the same string.
12
+
13
+ Spec: [`docs/proposals/i18n-initiative.md`](../../../../docs/proposals/i18n-initiative.md),
14
+ Workstream 2, P0 and P1.
15
+
16
+ ## The import line
17
+
18
+ Every component under `src/ui/styled/` uses exactly this:
19
+
20
+ ```ts
21
+ import { useForgeT } from "../../i18n";
22
+ ```
23
+
24
+ One directory deeper (`src/ui/styled/chat/`, `styled/work/`, `styled/members/`,
25
+ `styled/community/`) it is `"../../../i18n"`. The rule is: a relative path to
26
+ `src/i18n`, never a package self-import, never the `@/` alias. Sites consume
27
+ this package from source, so a self-import would resolve here and break there.
28
+
29
+ Call it at the top of the component, beside the other hooks:
30
+
31
+ ```tsx
32
+ export function CartSummary() {
33
+ const t = useForgeT();
34
+ return <button>{t("forge.cart.checkout")}</button>;
35
+ }
36
+ ```
37
+
38
+ `t` is a plain function, not an object. Do not destructure anything off it.
39
+
40
+ ## The key shape
41
+
42
+ ```
43
+ forge.<component>.<slug>
44
+ ```
45
+
46
+ Exactly three segments, all lowercase.
47
+
48
+ | Segment | Rule |
49
+ |---|---|
50
+ | `forge.` | Mandatory on every key, no exceptions. It is the namespace that makes a collision with a tenant's own copy impossible. |
51
+ | `<component>` | The component file's name in `snake_case`, minus the extension. `CartSummary.tsx` gives `cart_summary`. `AiAgentWidget.tsx` gives `ai_agent_widget`. |
52
+ | `<slug>` | What the string is for, in `snake_case`, 1 to 4 words. |
53
+
54
+ ```
55
+ forge.cart_summary.checkout
56
+ forge.cart_summary.empty_title
57
+ forge.event_card.sold_out
58
+ forge.booking_form.submit_error
59
+ ```
60
+
61
+ Keys are **flat strings containing dots**, not nested objects. `en.json` is one
62
+ level deep:
63
+
64
+ ```json
65
+ {
66
+ "forge.cart_summary.checkout": "Checkout",
67
+ "forge.cart_summary.empty_title": "Your cart is empty"
68
+ }
69
+ ```
70
+
71
+ Flat is deliberate. Eight agents appending to two shared JSON files produce
72
+ line-level conflicts that git resolves; nested objects produce structural
73
+ conflicts that it does not.
74
+
75
+ ### Naming the slug
76
+
77
+ Name it after the **meaning**, never the rendered English, and never the
78
+ position.
79
+
80
+ ```
81
+ NO forge.cart_summary.your_cart_is_empty (renames when the copy is edited)
82
+ NO forge.cart_summary.text_2 (says nothing)
83
+ NO forge.cart_summary.bottom_button (says where, not what)
84
+ YES forge.cart_summary.empty_title
85
+ YES forge.cart_summary.checkout
86
+ ```
87
+
88
+ Conventional slugs, so the same concept lands on the same word everywhere:
89
+ `title`, `subtitle`, `description`, `empty_title`, `empty_body`, `submit`,
90
+ `cancel`, `retry`, `loading`, `error`, `success`, `confirm_title`,
91
+ `confirm_body`, `aria_close`.
92
+
93
+ ### Shared strings
94
+
95
+ A string used by several components is **not** hoisted to a `forge.common.*`
96
+ bucket by default. Duplicate it under each component's namespace. Shared keys
97
+ look like savings and turn into the thing nobody can safely edit, because the
98
+ edit lands in four screens at once.
99
+
100
+ The single exception is `forge.common.*`, reserved for strings whose wording is
101
+ a product decision rather than a component's copy, and that decision is already
102
+ recorded elsewhere. Do not add to it during the conversion pass without saying
103
+ so in the PR.
104
+
105
+ ## Interpolation
106
+
107
+ `{var}` placeholders, filled from the second argument:
108
+
109
+ ```json
110
+ { "forge.order_summary.item_count": "{count} items" }
111
+ ```
112
+
113
+ ```tsx
114
+ t("forge.order_summary.item_count", { count: items.length })
115
+ ```
116
+
117
+ Rules:
118
+
119
+ - Placeholder names are `snake_case` and describe the value (`{count}`,
120
+ `{artist_name}`, `{date}`), never `{0}` or `{value}`.
121
+ - A placeholder with no matching var is left standing as `{count}`. It is not
122
+ blanked, so an unfilled slot is visible in review.
123
+ - **Never concatenate.** `t("forge.x.hello") + " " + name` cannot be translated,
124
+ because German puts the pieces in a different order. Put the whole sentence in
125
+ one key with a placeholder.
126
+ - Numbers, currency and dates are formatted by the existing Forge formatters,
127
+ then passed in as a var. Do not build them out of translated fragments.
128
+
129
+ ## What does NOT get a key
130
+
131
+ - Machine values: ids, slugs, enum values, URLs, query keys, `data-*`
132
+ attributes, CSS class names, analytics event names.
133
+ - Values that come from the API. A product title is the tenant's content, not
134
+ Forge's copy, and translating it is a different feature.
135
+ - Developer diagnostics no visitor reads (a `console.warn`, a thrown
136
+ programming error).
137
+
138
+ Everything a visitor can read does get a key, including `aria-label`,
139
+ `placeholder`, `title` and `alt` text. Those are the ones the conversion pass
140
+ misses.
141
+
142
+ ## Adding a string
143
+
144
+ 1. Add the key to `en.json` with the exact English the component renders today.
145
+ Do not improve the copy in the same change. A translation pass that also
146
+ rewords is unreviewable.
147
+ 2. Add the same key to `de.json`. Never leave it out: a key present in `en` and
148
+ absent in `de` falls back to English, which reads as a half-translated page
149
+ rather than as an error.
150
+ 3. Replace the literal with `t("...")`.
151
+
152
+ Both files stay sorted by key, so a diff is a diff and not a reshuffle.
153
+
154
+ ## The fallback chain
155
+
156
+ `t(key)` resolves in this order:
157
+
158
+ 1. the active locale's bundle,
159
+ 2. the English bundle,
160
+ 3. the key string itself.
161
+
162
+ It never returns empty. A missing key renders as `forge.cart_summary.checkout`
163
+ on the page, which is ugly on purpose: an untranslated string should be visible,
164
+ not invisible.
165
+
166
+ Outside a `<ForgeI18nProvider>` the hook returns a working English translator
167
+ instead of throwing, so a component still renders standalone. This mirrors
168
+ `useFunnelStep`, which returns a working no-op outside a `<Funnel>` for the same
169
+ reason.
170
+
171
+ ## Locales
172
+
173
+ `en` and `de` only, and `en` is the source language. `resolveForgeLocale`
174
+ narrows anything a site passes ("de-DE", "DE", `undefined`, "fr") to one of the
175
+ two, defaulting to `en`. Adding a third locale means a third JSON file and one
176
+ entry in `FORGE_LOCALES`.
177
+
178
+ ## Bundled, not fetched
179
+
180
+ The JSON ships inside the package and is imported synchronously. There is no
181
+ network call, no loading state and no API surface for translations. A site
182
+ therefore picks up new or corrected copy on its next Forge upgrade. That
183
+ adoption lag is accepted, because the always-latest initiative removes it for
184
+ every package at once. See "Why bundle-only is safe" in the initiative doc.
@@ -0,0 +1,153 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readFileSync, readdirSync, statSync } from "fs";
3
+ import { join } from "path";
4
+
5
+ import en from "../en.json";
6
+ import de from "../de.json";
7
+
8
+ /**
9
+ * Every `forge.*` key a component references must exist in EVERY bundle.
10
+ *
11
+ * `translateForge` resolves a key through the active locale, then English, then
12
+ * the key string itself. That last step is deliberate (see CONVENTION.md, "The
13
+ * fallback chain"): a missing key must be visible rather than blank. But
14
+ * "visible" only helps if somebody looks. Nothing throws, nothing warns, and no
15
+ * component spec fails, so the failure mode of a typo is a fan reading
16
+ * `forge.cart.checkout` on a checkout button.
17
+ *
18
+ * The de-only half is quieter still. A key present in `en.json` and absent from
19
+ * `de.json` silently falls back to English, so a German storefront renders a
20
+ * page that is 90% German with an English button in the middle of it. There is
21
+ * no error anywhere. This spec is the only thing that reports it.
22
+ *
23
+ * ## Why the scanner matches string literals, not `t(` call sites
24
+ *
25
+ * The obvious scanner is `/t\(\s*"(forge\.[^"]+)"/`. It would miss most of the
26
+ * risk in this package, because Forge routinely reaches a key through a
27
+ * variable:
28
+ *
29
+ * const TAB_LABELS = { orders: "forge.account_dashboard.tab_orders", ... };
30
+ * tr(TAB_LABELS[tab])
31
+ *
32
+ * t(isPaid ? "forge.bundle_confirmation.summary_paid" : "...summary_order")
33
+ *
34
+ * A key that reaches `t` through a lookup map or a ternary is exactly the key a
35
+ * rename breaks without tsc noticing, and it is exactly the key a call-site
36
+ * regex cannot see. So the scanner matches any `"forge.…"` string literal in
37
+ * the source, wherever it sits. It also makes the guard indifferent to what the
38
+ * translator is called at the call site: this package binds `useForgeT()` to
39
+ * `t`, `tr` and `translate` in different files, and a call-site regex would
40
+ * have to know all three.
41
+ *
42
+ * The cost is that a `forge.*` literal that is NOT a translation key would be
43
+ * flagged. Machine values never carry the `forge.` prefix (CONVENTION.md, "What
44
+ * does NOT get a key"), so that cost is theoretical, and a false positive here
45
+ * is a one-line fix while a false negative ships broken copy.
46
+ *
47
+ * `src/i18n/` itself is excluded from the walk: the `useForgeT` docblock shows
48
+ * `t("forge.cart.item_count", …)` as an EXAMPLE, and an example is not a usage.
49
+ * The bundles are imported directly rather than scanned for the same reason.
50
+ */
51
+
52
+ /** `src`, two levels up from `src/i18n/_tests`. */
53
+ const SRC = join(__dirname, "..", "..");
54
+
55
+ /** The bundle files are flat `Record<string, string>` by convention. */
56
+ const LOCALES: Record<string, Record<string, string>> = {
57
+ en: en as Record<string, string>,
58
+ de: de as Record<string, string>,
59
+ };
60
+
61
+ /** Any bundle key literal, wherever it appears. See the docblock above. */
62
+ const KEY_LITERAL = /"(forge\.[a-zA-Z0-9_]+\.[a-zA-Z0-9_.]+)"/g;
63
+
64
+ /** Forge interpolates single braces (`{count}`), not the `{{count}}` of i18next. */
65
+ const PLACEHOLDER = /\{\s*([a-zA-Z0-9_]+)\s*\}/g;
66
+
67
+ const walk = (dir: string, out: string[] = []): string[] => {
68
+ for (const entry of readdirSync(dir)) {
69
+ // `i18n` holds the bundles and the docblock example; `_tests` holds specs,
70
+ // which assert on rendered copy rather than on keys.
71
+ if (entry === "node_modules" || entry === "_tests" || entry === "i18n") continue;
72
+ const full = join(dir, entry);
73
+ if (statSync(full).isDirectory()) walk(full, out);
74
+ else if (/\.tsx?$/.test(entry) && !/\.(spec|test|d)\.tsx?$/.test(entry)) out.push(full);
75
+ }
76
+ return out;
77
+ };
78
+
79
+ /** key -> the source files that name it, for a failure message worth reading. */
80
+ const usedKeys = (): Map<string, string[]> => {
81
+ const found = new Map<string, string[]>();
82
+ for (const file of walk(SRC)) {
83
+ const text = readFileSync(file, "utf8");
84
+ for (const match of text.matchAll(KEY_LITERAL)) {
85
+ const key = match[1];
86
+ const where = found.get(key) ?? [];
87
+ where.push(file.replace(`${SRC}/`, ""));
88
+ found.set(key, where);
89
+ }
90
+ }
91
+ return found;
92
+ };
93
+
94
+ const placeholders = (value: unknown): string[] =>
95
+ typeof value === "string" ? [...value.matchAll(PLACEHOLDER)].map((m) => m[1]).sort() : [];
96
+
97
+ describe("Forge i18n: every key a component uses exists in every bundle", () => {
98
+ const used = usedKeys();
99
+
100
+ it("finds a meaningful number of keys, so the scanner itself still works", () => {
101
+ // A regex that quietly stopped matching would make every assertion below
102
+ // pass vacuously. Pin a floor rather than an exact count, so adding copy
103
+ // does not fail the guard.
104
+ expect(used.size).toBeGreaterThan(500);
105
+ });
106
+
107
+ for (const [lang, bundle] of Object.entries(LOCALES)) {
108
+ it(`${lang}: no key used in src is missing from the bundle`, () => {
109
+ const missing = [...used.entries()]
110
+ .filter(([key]) => bundle[key] === undefined)
111
+ .map(([key, files]) => `${key} <- ${[...new Set(files)].join(", ")}`);
112
+
113
+ expect(
114
+ missing,
115
+ `Missing ${lang} copy. translateForge would render the raw key to a visitor:\n${missing.join("\n")}`,
116
+ ).toEqual([]);
117
+ });
118
+ }
119
+
120
+ it("en.json and de.json carry the same key set", () => {
121
+ const enKeys = new Set(Object.keys(LOCALES.en));
122
+ for (const [lang, bundle] of Object.entries(LOCALES)) {
123
+ if (lang === "en") continue;
124
+ const missing = [...enKeys].filter((k) => bundle[k] === undefined);
125
+ const extra = Object.keys(bundle).filter((k) => !enKeys.has(k));
126
+
127
+ // Missing means a silent English fallback inside a translated page.
128
+ expect(missing, `${lang} is missing keys English has:\n${missing.join("\n")}`).toEqual([]);
129
+ // Extra means copy nobody can ever read, usually the residue of a rename
130
+ // applied to en.json alone.
131
+ expect(extra, `${lang} has keys English does not:\n${extra.join("\n")}`).toEqual([]);
132
+ }
133
+ });
134
+
135
+ it("no bundle disagrees with English about interpolation placeholders", () => {
136
+ // A `{count}` present in one locale and absent from another is a value that
137
+ // vanishes from the sentence for that locale only, and it renders as a
138
+ // grammatical sentence with the number missing, which reads as correct.
139
+ const mismatches: string[] = [];
140
+ for (const [lang, bundle] of Object.entries(LOCALES)) {
141
+ if (lang === "en") continue;
142
+ for (const [key, value] of Object.entries(LOCALES.en)) {
143
+ if (bundle[key] === undefined) continue;
144
+ const source = placeholders(value);
145
+ const target = placeholders(bundle[key]);
146
+ if (source.join(",") !== target.join(",")) {
147
+ mismatches.push(`${key}: en={${source.join(",")}} ${lang}={${target.join(",")}}`);
148
+ }
149
+ }
150
+ }
151
+ expect(mismatches, `Interpolation drift:\n${mismatches.join("\n")}`).toEqual([]);
152
+ });
153
+ });