@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,289 @@
1
+ import { useForgeT } from "../../i18n";
2
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
3
+ import { useAmountFormatter } from "../format/useFormatCurrency";
4
+ import {
5
+ useCartResume,
6
+ effectiveUnitCents,
7
+ type UseCartResumeResult,
8
+ } from "../headless/checkout/useCartResume";
9
+ import type { ResumeLine } from "../../data/queries/useCheckouts";
10
+ import { Button } from "./Button";
11
+
12
+ export interface ResumeCartProps {
13
+ /** The `?token=` from the recovery email. */
14
+ token?: string | null;
15
+ /** Where the primary button goes. Default `/i/checkout`. */
16
+ checkoutPath?: string;
17
+ /** Where "keep browsing" goes, and where a dead link sends the buyer. Default `/i/store`. */
18
+ storePath?: string;
19
+ /** Build a course page link. Default `/i/courses/{slug}`. */
20
+ courseHref?: (line: ResumeLine) => string;
21
+ /** Build a coaching page link. Default `/i/coaching/{slug}`. */
22
+ coachingHref?: (line: ResumeLine) => string;
23
+ /** Format a numeric amount for display. */
24
+ formatAmount?: (amount: number) => string;
25
+ className?: string;
26
+ style?: React.CSSProperties;
27
+ }
28
+
29
+ /**
30
+ * The page a cart-recovery link lands on.
31
+ *
32
+ * Drops in at `/i/checkout/resume`. Everything it decides lives in `useCartResume`,
33
+ * which `apps/client` renders with its own markup, so the two storefronts
34
+ * cannot drift on what a resumed basket does. This file is only how it looks.
35
+ *
36
+ * The one rule the layout enforces: a line that could NOT be restored is drawn
37
+ * as loudly as one that was. It sits above the fold, not in a footnote, because
38
+ * the buyer came back for a specific thing and needs to know whether it is
39
+ * still there before they look at a total.
40
+ */
41
+ export function ResumeCart({
42
+ token,
43
+ checkoutPath = "/i/checkout",
44
+ storePath = "/i/store",
45
+ courseHref,
46
+ coachingHref,
47
+ formatAmount,
48
+ className,
49
+ style,
50
+ }: ResumeCartProps) {
51
+ const t = useThemeTokens();
52
+ const tr = useForgeT();
53
+ const fmt = useAmountFormatter(formatAmount);
54
+ const resume = useCartResume({ token });
55
+
56
+ const shell: React.CSSProperties = {
57
+ maxWidth: 640,
58
+ margin: "0 auto",
59
+ color: t.text,
60
+ fontFamily: t.fontFamily,
61
+ ...style,
62
+ };
63
+
64
+ if (resume.status === "loading") {
65
+ return (
66
+ <div className={className} style={shell} aria-busy="true">
67
+ <p style={{ color: t.muted }}>{tr("forge.resume_cart.loading")}</p>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ if (resume.status === "error") {
73
+ return (
74
+ <div className={className} style={shell}>
75
+ <h1 style={{ fontSize: 24, fontWeight: 700, fontFamily: t.headingFontFamily }}>
76
+ {tr("forge.resume_cart.invalid_title")}
77
+ </h1>
78
+ <p style={{ color: t.muted, marginTop: 8 }}>{tr("forge.resume_cart.invalid_body")}</p>
79
+ <div style={{ marginTop: 24 }}>
80
+ <Button onClick={() => go(storePath)}>{tr("forge.resume_cart.browse_store")}</Button>
81
+ </div>
82
+ </div>
83
+ );
84
+ }
85
+
86
+ const linkFor = (line: ResumeLine) =>
87
+ line.type === "course"
88
+ ? (courseHref?.(line) ?? `/i/courses/${line.courseSlug ?? ""}`)
89
+ : (coachingHref?.(line) ?? `/i/coaching/${line.coachingProductSlug ?? ""}`);
90
+
91
+ return (
92
+ <div className={className} style={shell}>
93
+ <h1 style={{ fontSize: 26, fontWeight: 700, fontFamily: t.headingFontFamily }}>
94
+ {resume.status === "empty"
95
+ ? tr("forge.resume_cart.empty_title")
96
+ : tr("forge.resume_cart.title")}
97
+ </h1>
98
+ <p style={{ color: t.muted, marginTop: 8 }}>
99
+ {resume.status === "empty"
100
+ ? tr("forge.resume_cart.empty_body")
101
+ : tr("forge.resume_cart.subtitle", { count: resume.itemCount })}
102
+ </p>
103
+
104
+ {/*
105
+ State the buyer has to act on, so it is visible and not behind a hint:
106
+ the price of something in their basket is not what it was.
107
+ */}
108
+ {resume.repricedLines.length > 0 && (
109
+ <p role="status" style={notice(t.primary, t.cornerRadius)}>
110
+ {tr("forge.resume_cart.reprice_notice", { count: resume.repricedLines.length })}
111
+ </p>
112
+ )}
113
+
114
+ {resume.restoredLines.length > 0 && (
115
+ <ul style={list}>
116
+ {resume.restoredLines.map((line, index) => (
117
+ <li key={`restored-${index}`} style={row(t.border)}>
118
+ <LineBody line={line} fmt={fmt} muted={t.muted} text={t.text} />
119
+ <div style={{ textAlign: "right", whiteSpace: "nowrap" }}>
120
+ {line.priceChanged && line.quotedUnitPriceCents != null && (
121
+ <div style={{ color: t.muted, fontSize: 12, textDecoration: "line-through" }}>
122
+ {fmt(line.quotedUnitPriceCents / 100)}
123
+ </div>
124
+ )}
125
+ <div style={{ fontWeight: 600 }}>
126
+ {fmt((effectiveUnitCents(line) * line.quantity) / 100)}
127
+ </div>
128
+ </div>
129
+ </li>
130
+ ))}
131
+ </ul>
132
+ )}
133
+
134
+ {resume.restoredLines.length > 0 && (
135
+ <div style={{ ...row(t.border), fontWeight: 700, borderBottom: "none" }}>
136
+ <span>{tr("forge.resume_cart.subtotal")}</span>
137
+ <span>{fmt(resume.subtotalCents / 100)}</span>
138
+ </div>
139
+ )}
140
+
141
+ {resume.unavailableLines.length > 0 && (
142
+ <section style={{ marginTop: 24 }} aria-label={tr("forge.resume_cart.unavailable_title")}>
143
+ <h2 style={{ fontSize: 16, fontWeight: 700, fontFamily: t.headingFontFamily }}>
144
+ {tr("forge.resume_cart.unavailable_title")}
145
+ </h2>
146
+ <ul style={list}>
147
+ {resume.unavailableLines.map((line, index) => (
148
+ <li key={`gone-${index}`} style={{ ...row(t.border), opacity: 0.75 }}>
149
+ <LineBody line={line} fmt={fmt} muted={t.muted} text={t.text} />
150
+ <span style={{ color: t.muted, fontSize: 13, whiteSpace: "nowrap" }}>
151
+ {tr(REASON_KEYS[line.unavailableReason ?? "unavailable"])}
152
+ </span>
153
+ </li>
154
+ ))}
155
+ </ul>
156
+ </section>
157
+ )}
158
+
159
+ {/*
160
+ An add-on whose ticket did not come back. Drawn as loudly as an
161
+ unavailable line, and for the same reason: it was in the basket the
162
+ buyer left, so its absence has to be stated rather than discovered.
163
+ */}
164
+ {resume.droppedLines.length > 0 && (
165
+ <section style={{ marginTop: 24 }} aria-label={tr("forge.resume_cart.dropped_title")}>
166
+ <h2 style={{ fontSize: 16, fontWeight: 700, fontFamily: t.headingFontFamily }}>
167
+ {tr("forge.resume_cart.dropped_title")}
168
+ </h2>
169
+ <ul style={list}>
170
+ {resume.droppedLines.map((line, index) => (
171
+ <li key={`dropped-${index}`} style={{ ...row(t.border), opacity: 0.75 }}>
172
+ <LineBody line={line} fmt={fmt} muted={t.muted} text={t.text} />
173
+ <span style={{ color: t.muted, fontSize: 13, whiteSpace: "nowrap" }}>
174
+ {tr("forge.resume_cart.dropped_reason")}
175
+ </span>
176
+ </li>
177
+ ))}
178
+ </ul>
179
+ </section>
180
+ )}
181
+
182
+ {resume.continueLines.length > 0 && (
183
+ <section style={{ marginTop: 24 }}>
184
+ <h2 style={{ fontSize: 16, fontWeight: 700, fontFamily: t.headingFontFamily }}>
185
+ {tr("forge.resume_cart.continue_title")}
186
+ </h2>
187
+ <ul style={list}>
188
+ {resume.continueLines.map((line, index) => (
189
+ <li key={`continue-${index}`} style={row(t.border)}>
190
+ <LineBody line={line} fmt={fmt} muted={t.muted} text={t.text} />
191
+ <a href={linkFor(line)} style={{ color: t.primary, fontWeight: 600 }}>
192
+ {tr("forge.resume_cart.continue_link")}
193
+ </a>
194
+ </li>
195
+ ))}
196
+ </ul>
197
+ </section>
198
+ )}
199
+
200
+ {resume.couponCode && (
201
+ <p role="status" style={notice(t.secondary, t.cornerRadius)}>
202
+ {tr("forge.resume_cart.coupon_notice", { code: resume.couponCode })}
203
+ </p>
204
+ )}
205
+
206
+ <div style={{ display: "flex", gap: 12, marginTop: 28, flexWrap: "wrap" }}>
207
+ {resume.restoredLines.length > 0 && (
208
+ <Button onClick={() => go(checkoutPath)}>{tr("forge.resume_cart.go_to_checkout")}</Button>
209
+ )}
210
+ <Button variant="secondary" onClick={() => go(storePath)}>
211
+ {tr("forge.resume_cart.browse_store")}
212
+ </Button>
213
+ </div>
214
+ </div>
215
+ );
216
+ }
217
+
218
+ function LineBody({
219
+ line,
220
+ fmt,
221
+ muted,
222
+ text,
223
+ }: {
224
+ line: ResumeLine;
225
+ fmt: (amount: number) => string;
226
+ muted: string;
227
+ text: string;
228
+ }) {
229
+ const options = (line.options ?? []).map((option) => `${option.axis}: ${option.value}`).join(" · ");
230
+ return (
231
+ <div style={{ display: "flex", gap: 12, alignItems: "center", minWidth: 0 }}>
232
+ {line.coverImage && (
233
+ <img
234
+ src={line.coverImage}
235
+ alt=""
236
+ width={48}
237
+ height={48}
238
+ style={{ width: 48, height: 48, objectFit: "cover", borderRadius: 6, flexShrink: 0 }}
239
+ />
240
+ )}
241
+ <div style={{ minWidth: 0 }}>
242
+ <div style={{ fontWeight: 600, color: text, overflowWrap: "anywhere" }}>{line.title}</div>
243
+ <div style={{ color: muted, fontSize: 13 }}>
244
+ {options && <span>{options} · </span>}
245
+ <span>× {line.quantity}</span>
246
+ {line.available && line.type !== "course" && line.type !== "coaching" && (
247
+ <span> · {fmt(effectiveUnitCents(line) / 100)}</span>
248
+ )}
249
+ </div>
250
+ </div>
251
+ </div>
252
+ );
253
+ }
254
+
255
+ /**
256
+ * Three reasons, three sentences. Written out rather than built from the enum
257
+ * so the keys are greppable, and kept distinct because the buyer's next move
258
+ * differs: a sold-out tier may free up, a withdrawn product will not.
259
+ */
260
+ const REASON_KEYS = {
261
+ removed: "forge.resume_cart.reason_removed",
262
+ unavailable: "forge.resume_cart.reason_unavailable",
263
+ sold_out: "forge.resume_cart.reason_sold_out",
264
+ } as const;
265
+
266
+ const list: React.CSSProperties = { listStyle: "none", padding: 0, margin: "16px 0 0" };
267
+
268
+ const row = (border: string): React.CSSProperties => ({
269
+ display: "flex",
270
+ alignItems: "center",
271
+ justifyContent: "space-between",
272
+ gap: 16,
273
+ padding: "12px 0",
274
+ borderBottom: `1px solid ${border}`,
275
+ });
276
+
277
+ const notice = (accent: string, radius: number): React.CSSProperties => ({
278
+ marginTop: 16,
279
+ padding: "10px 12px",
280
+ borderRadius: radius,
281
+ border: `1px solid ${accent}`,
282
+ fontSize: 14,
283
+ });
284
+
285
+ function go(path: string) {
286
+ if (typeof window !== "undefined") window.location.href = path;
287
+ }
288
+
289
+ export type { UseCartResumeResult };
@@ -5,6 +5,7 @@ import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
5
  import { StarRow } from "./ReviewsSection";
6
6
  import { Button } from "./Button";
7
7
  import { Loading } from "./Loading";
8
+ import { useForgeT } from "../../i18n";
8
9
 
9
10
  const a = (hex: string, al: number) => hex + Math.round(al * 255).toString(16).padStart(2, "0");
10
11
 
@@ -24,7 +25,8 @@ export interface ReviewFormProps {
24
25
  * "held for moderation" outcome.
25
26
  */
26
27
  export function ReviewForm({ token, className, style }: ReviewFormProps) {
27
- const t = useThemeTokens();
28
+ const t = useForgeT();
29
+ const tokens = useThemeTokens();
28
30
  const {
29
31
  status,
30
32
  request,
@@ -50,8 +52,8 @@ export function ReviewForm({ token, className, style }: ReviewFormProps) {
50
52
  maxWidth: 560,
51
53
  margin: "0 auto",
52
54
  padding: "24px 16px",
53
- color: t.text,
54
- fontFamily: t.fontFamily,
55
+ color: tokens.text,
56
+ fontFamily: tokens.fontFamily,
55
57
  ...style,
56
58
  };
57
59
 
@@ -61,11 +63,8 @@ export function ReviewForm({ token, className, style }: ReviewFormProps) {
61
63
  return (
62
64
  <div className={className} style={rootStyle}>
63
65
  <Panel>
64
- <h1 style={h1Style(t)}>This review link isn't valid</h1>
65
- <p style={{ opacity: 0.75, lineHeight: 1.6 }}>
66
- The link may have been mistyped or is no longer active. If you received it by email, try opening it again
67
- from the original message.
68
- </p>
66
+ <h1 style={h1Style(tokens)}>{t("forge.review_form.invalid_title")}</h1>
67
+ <p style={{ opacity: 0.75, lineHeight: 1.6 }}>{t("forge.review_form.invalid_body")}</p>
69
68
  </Panel>
70
69
  </div>
71
70
  );
@@ -75,11 +74,12 @@ export function ReviewForm({ token, className, style }: ReviewFormProps) {
75
74
  return (
76
75
  <div className={className} style={rootStyle}>
77
76
  <Panel>
78
- <Clock size={32} color={t.primary} />
79
- <h1 style={h1Style(t)}>This review link has expired</h1>
77
+ <Clock size={32} color={tokens.primary} />
78
+ <h1 style={h1Style(tokens)}>{t("forge.review_form.expired_title")}</h1>
80
79
  <p style={{ opacity: 0.75, lineHeight: 1.6 }}>
81
- Review links are only valid for a limited time after your purchase
82
- {request.entityTitle ? ` of “${request.entityTitle}”` : ""}. Thanks anyway for your support!
80
+ {request.entityTitle
81
+ ? t("forge.review_form.expired_body_entity", { title: request.entityTitle })
82
+ : t("forge.review_form.expired_body")}
83
83
  </p>
84
84
  </Panel>
85
85
  </div>
@@ -92,16 +92,16 @@ export function ReviewForm({ token, className, style }: ReviewFormProps) {
92
92
  return (
93
93
  <div className={className} style={rootStyle}>
94
94
  <Panel>
95
- <CheckCircle2 size={36} color={t.primary} />
96
- <h1 style={h1Style(t)}>Thank you for your review!</h1>
95
+ <CheckCircle2 size={36} color={tokens.primary} />
96
+ <h1 style={h1Style(tokens)}>{t("forge.review_form.thanks_title")}</h1>
97
97
  <StarRow rating={afterSubmit.rating} size={20} />
98
98
  {held ? (
99
- <p style={{ opacity: 0.75, lineHeight: 1.6 }}>
100
- Your review has been received and is awaiting approval. It will appear once it's been checked.
101
- </p>
99
+ <p style={{ opacity: 0.75, lineHeight: 1.6 }}>{t("forge.review_form.held_body")}</p>
102
100
  ) : (
103
101
  <p style={{ opacity: 0.75, lineHeight: 1.6 }}>
104
- Your review is now live{request.entityTitle ? ` on “${request.entityTitle}”` : ""}.
102
+ {request.entityTitle
103
+ ? t("forge.review_form.live_body_entity", { title: request.entityTitle })
104
+ : t("forge.review_form.live_body")}
105
105
  </p>
106
106
  )}
107
107
  </Panel>
@@ -113,15 +113,13 @@ export function ReviewForm({ token, className, style }: ReviewFormProps) {
113
113
  return (
114
114
  <div className={className} style={rootStyle}>
115
115
  <Panel>
116
- <CheckCircle2 size={32} color={t.primary} />
117
- <h1 style={h1Style(t)}>You've already reviewed this</h1>
116
+ <CheckCircle2 size={32} color={tokens.primary} />
117
+ <h1 style={h1Style(tokens)}>{t("forge.review_form.locked_title")}</h1>
118
118
  {request.entityTitle && <p style={{ fontWeight: 600 }}>{request.entityTitle}</p>}
119
119
  <StarRow rating={existing.rating} size={20} />
120
120
  {existing.title && <p style={{ fontWeight: 700, margin: 0 }}>{existing.title}</p>}
121
121
  {existing.body && <p style={{ opacity: 0.85, lineHeight: 1.6, margin: 0 }}>{existing.body}</p>}
122
- <p style={{ opacity: 0.6, fontSize: 13 }}>
123
- Reviews can be edited for 30 days after they're written. This one can no longer be changed.
124
- </p>
122
+ <p style={{ opacity: 0.6, fontSize: 13 }}>{t("forge.review_form.locked_body")}</p>
125
123
  </Panel>
126
124
  </div>
127
125
  );
@@ -138,11 +136,11 @@ export function ReviewForm({ token, className, style }: ReviewFormProps) {
138
136
  width: "100%",
139
137
  padding: "10px 12px",
140
138
  fontSize: 15,
141
- fontFamily: t.fontFamily,
142
- color: t.text,
143
- background: t.surface,
144
- border: `1px solid ${t.border}`,
145
- borderRadius: t.cornerRadius,
139
+ fontFamily: tokens.fontFamily,
140
+ color: tokens.text,
141
+ background: tokens.surface,
142
+ border: `1px solid ${tokens.border}`,
143
+ borderRadius: tokens.cornerRadius,
146
144
  outline: "none",
147
145
  boxSizing: "border-box",
148
146
  };
@@ -156,34 +154,30 @@ export function ReviewForm({ token, className, style }: ReviewFormProps) {
156
154
  flexDirection: "column",
157
155
  gap: 18,
158
156
  padding: 24,
159
- border: `1px solid ${t.border}`,
160
- borderRadius: t.cornerRadius,
161
- background: t.surface,
157
+ border: `1px solid ${tokens.border}`,
158
+ borderRadius: tokens.cornerRadius,
159
+ background: tokens.surface,
162
160
  }}
163
161
  >
164
162
  <div>
165
- <h1 style={{ ...h1Style(t), marginBottom: 4 }}>
166
- {existing ? "Edit your review" : "Leave a review"}
163
+ <h1 style={{ ...h1Style(tokens), marginBottom: 4 }}>
164
+ {existing ? t("forge.review_form.edit_title") : t("forge.review_form.create_title")}
167
165
  </h1>
168
166
  {request.entityTitle && (
169
167
  <p style={{ opacity: 0.75, margin: 0 }}>
170
- How was <span style={{ fontWeight: 700 }}>{request.entityTitle}</span>?
171
- </p>
172
- )}
173
- {existing && (
174
- <p style={{ fontSize: 13, opacity: 0.6, marginTop: 6 }}>
175
- You can edit your review within 30 days of writing it. Edits are re-checked before publishing.
168
+ {t("forge.review_form.how_was")} <span style={{ fontWeight: 700 }}>{request.entityTitle}</span>?
176
169
  </p>
177
170
  )}
171
+ {existing && <p style={{ fontSize: 13, opacity: 0.6, marginTop: 6 }}>{t("forge.review_form.edit_window_note")}</p>}
178
172
  </div>
179
173
 
180
174
  {/* Star picker */}
181
175
  <div>
182
- <label style={labelStyle}>Your rating *</label>
176
+ <label style={labelStyle}>{t("forge.review_form.rating_label")}</label>
183
177
  <div
184
178
  style={{ display: "flex", gap: 6 }}
185
179
  role="radiogroup"
186
- aria-label="Rating"
180
+ aria-label={t("forge.review_form.aria_rating")}
187
181
  onMouseLeave={() => setHoverRating(0)}
188
182
  >
189
183
  {[1, 2, 3, 4, 5].map((star) => (
@@ -192,15 +186,15 @@ export function ReviewForm({ token, className, style }: ReviewFormProps) {
192
186
  type="button"
193
187
  role="radio"
194
188
  aria-checked={rating === star}
195
- aria-label={`${star} star${star === 1 ? "" : "s"}`}
189
+ aria-label={star === 1 ? t("forge.review_form.aria_star_one") : t("forge.review_form.aria_star_other", { count: star })}
196
190
  onClick={() => setRating(star)}
197
191
  onMouseEnter={() => setHoverRating(star)}
198
192
  style={{ background: "transparent", border: "none", padding: 2, cursor: "pointer", lineHeight: 0 }}
199
193
  >
200
194
  <Star
201
195
  size={30}
202
- fill={star <= displayStars ? t.primary : "transparent"}
203
- color={star <= displayStars ? t.primary : a(t.text, 0.3)}
196
+ fill={star <= displayStars ? tokens.primary : "transparent"}
197
+ color={star <= displayStars ? tokens.primary : a(tokens.text, 0.3)}
204
198
  />
205
199
  </button>
206
200
  ))}
@@ -209,7 +203,7 @@ export function ReviewForm({ token, className, style }: ReviewFormProps) {
209
203
 
210
204
  <div>
211
205
  <label htmlFor="review-title" style={labelStyle}>
212
- Title <span style={{ opacity: 0.5, fontWeight: 400 }}>(optional)</span>
206
+ {t("forge.review_form.title_label")} <span style={{ opacity: 0.5, fontWeight: 400 }}>{t("forge.review_form.optional")}</span>
213
207
  </label>
214
208
  <input
215
209
  id="review-title"
@@ -217,14 +211,14 @@ export function ReviewForm({ token, className, style }: ReviewFormProps) {
217
211
  maxLength={200}
218
212
  value={title}
219
213
  onChange={(e) => setTitle(e.target.value)}
220
- placeholder="Sum it up in a few words"
214
+ placeholder={t("forge.review_form.title_placeholder")}
221
215
  style={inputStyle}
222
216
  />
223
217
  </div>
224
218
 
225
219
  <div>
226
220
  <label htmlFor="review-body" style={labelStyle}>
227
- Your review <span style={{ opacity: 0.5, fontWeight: 400 }}>(optional)</span>
221
+ {t("forge.review_form.body_label")} <span style={{ opacity: 0.5, fontWeight: 400 }}>{t("forge.review_form.optional")}</span>
228
222
  </label>
229
223
  <textarea
230
224
  id="review-body"
@@ -232,13 +226,13 @@ export function ReviewForm({ token, className, style }: ReviewFormProps) {
232
226
  maxLength={5000}
233
227
  value={body}
234
228
  onChange={(e) => setBody(e.target.value)}
235
- placeholder="What did you like or dislike?"
229
+ placeholder={t("forge.review_form.body_placeholder")}
236
230
  style={{ ...inputStyle, resize: "vertical" }}
237
231
  />
238
232
  </div>
239
233
 
240
234
  <div>
241
- <label htmlFor="review-name" style={labelStyle}>Display name</label>
235
+ <label htmlFor="review-name" style={labelStyle}>{t("forge.review_form.name_label")}</label>
242
236
  <input
243
237
  id="review-name"
244
238
  type="text"
@@ -247,17 +241,17 @@ export function ReviewForm({ token, className, style }: ReviewFormProps) {
247
241
  onChange={(e) => setReviewerName(e.target.value)}
248
242
  style={inputStyle}
249
243
  />
250
- <p style={{ fontSize: 12, opacity: 0.55, marginTop: 4 }}>Shown publicly next to your review.</p>
244
+ <p style={{ fontSize: 12, opacity: 0.55, marginTop: 4 }}>{t("forge.review_form.name_hint")}</p>
251
245
  </div>
252
246
 
253
247
  {errorMessage && (
254
- <p style={{ fontSize: 14, color: "#b91c1c", background: "#b91c1c14", padding: "8px 12px", borderRadius: t.cornerRadius, margin: 0 }}>
248
+ <p style={{ fontSize: 14, color: "#b91c1c", background: "#b91c1c14", padding: "8px 12px", borderRadius: tokens.cornerRadius, margin: 0 }}>
255
249
  {errorMessage}
256
250
  </p>
257
251
  )}
258
252
 
259
253
  <Button type="submit" disabled={!canSubmit} loading={isSubmitting} fullWidth>
260
- {existing ? "Update review" : "Submit review"}
254
+ {existing ? t("forge.review_form.update_submit") : t("forge.review_form.submit")}
261
255
  </Button>
262
256
  </form>
263
257
  </div>
@@ -266,12 +260,12 @@ export function ReviewForm({ token, className, style }: ReviewFormProps) {
266
260
 
267
261
  const labelStyle: React.CSSProperties = { display: "block", fontSize: 14, fontWeight: 600, marginBottom: 6 };
268
262
 
269
- function h1Style(t: { headingFontFamily?: string }): React.CSSProperties {
270
- return { fontSize: 24, fontWeight: 800, fontFamily: t.headingFontFamily, margin: 0 };
263
+ function h1Style(tokens: { headingFontFamily?: string }): React.CSSProperties {
264
+ return { fontSize: 24, fontWeight: 800, fontFamily: tokens.headingFontFamily, margin: 0 };
271
265
  }
272
266
 
273
267
  function Panel({ children }: { children: React.ReactNode }) {
274
- const t = useThemeTokens();
268
+ const tokens = useThemeTokens();
275
269
  return (
276
270
  <div
277
271
  style={{
@@ -281,9 +275,9 @@ function Panel({ children }: { children: React.ReactNode }) {
281
275
  textAlign: "center",
282
276
  gap: 12,
283
277
  padding: 32,
284
- border: `1px solid ${t.border}`,
285
- borderRadius: t.cornerRadius,
286
- background: t.surface,
278
+ border: `1px solid ${tokens.border}`,
279
+ borderRadius: tokens.cornerRadius,
280
+ background: tokens.surface,
287
281
  }}
288
282
  >
289
283
  {children}