@tribe-nest/forge 3.26.0 → 3.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/client/_tests/localeHeader.spec.tsx +98 -0
- package/src/client/activeLocale.ts +34 -0
- package/src/client/createForgeClient.ts +13 -0
- package/src/contexts/CartContext.tsx +76 -0
- package/src/contexts/_tests/CartContext.spec.tsx +210 -0
- package/src/data/queries/useCheckouts.ts +83 -0
- package/src/data/queries/useWebsite.ts +7 -0
- package/src/i18n/CONVENTION.md +184 -0
- package/src/i18n/_tests/translationKeys.spec.ts +153 -0
- package/src/i18n/de.json +960 -0
- package/src/i18n/en.json +960 -0
- package/src/i18n/index.ts +125 -0
- package/src/index.ts +21 -0
- package/src/provider/ForgeAppProvider.tsx +2 -1
- package/src/provider/SiteConfigProvider.tsx +11 -0
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
- package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
- package/src/ui/headless/checkout/useCartResume.ts +340 -0
- package/src/ui/headless/checkout/useCheckout.ts +9 -2
- package/src/ui/headless/index.ts +18 -0
- package/src/ui/index.ts +2 -0
- package/src/ui/shell/TribeNestApp.tsx +36 -27
- package/src/ui/styled/AccountDashboard.tsx +197 -93
- package/src/ui/styled/AiAgentWidget.tsx +10 -6
- package/src/ui/styled/AudioPlayer.tsx +14 -6
- package/src/ui/styled/BlogCategory.tsx +4 -2
- package/src/ui/styled/BlogComments.tsx +27 -20
- package/src/ui/styled/BlogList.tsx +5 -3
- package/src/ui/styled/BlogPost.tsx +14 -11
- package/src/ui/styled/BundleConfirmation.tsx +32 -23
- package/src/ui/styled/CancellationTerms.tsx +3 -1
- package/src/ui/styled/Cart.tsx +32 -14
- package/src/ui/styled/ChatRoom.tsx +65 -20
- package/src/ui/styled/Checkout.tsx +151 -89
- package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
- package/src/ui/styled/CoachingBooking.tsx +71 -44
- package/src/ui/styled/CoachingConfirmation.tsx +56 -19
- package/src/ui/styled/CoachingDetail.tsx +15 -11
- package/src/ui/styled/CohortPage.tsx +10 -8
- package/src/ui/styled/ConfirmSubscription.tsx +14 -8
- package/src/ui/styled/ContactForm.tsx +31 -12
- package/src/ui/styled/CookieConsent.tsx +19 -15
- package/src/ui/styled/CourseAccess.tsx +54 -47
- package/src/ui/styled/CourseCheckout.tsx +56 -38
- package/src/ui/styled/CourseConfirmation.tsx +39 -17
- package/src/ui/styled/CourseDetail.tsx +21 -15
- package/src/ui/styled/CoursesGrid.tsx +4 -2
- package/src/ui/styled/CurrencySwitcher.tsx +4 -1
- package/src/ui/styled/DiscountCode.tsx +22 -18
- package/src/ui/styled/DonationButton.tsx +52 -17
- package/src/ui/styled/DonationPage.tsx +4 -4
- package/src/ui/styled/EmailListForm.tsx +22 -9
- package/src/ui/styled/EventConfirmation.tsx +37 -14
- package/src/ui/styled/EventCountdown.tsx +10 -6
- package/src/ui/styled/EventDetail.tsx +8 -6
- package/src/ui/styled/EventSeriesDetail.tsx +18 -13
- package/src/ui/styled/EventTickets.tsx +232 -108
- package/src/ui/styled/EventWaitlist.tsx +42 -33
- package/src/ui/styled/EventsList.tsx +15 -13
- package/src/ui/styled/ForgotPasswordForm.tsx +12 -12
- package/src/ui/styled/FormRenderer.tsx +12 -7
- package/src/ui/styled/InstallBanner.tsx +21 -18
- package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
- package/src/ui/styled/InvoicePayment.tsx +37 -16
- package/src/ui/styled/LeadMagnet.tsx +9 -7
- package/src/ui/styled/Loading.tsx +7 -1
- package/src/ui/styled/LoginForm.tsx +22 -14
- package/src/ui/styled/MembershipCheckout.tsx +32 -13
- package/src/ui/styled/MembershipTiers.tsx +27 -18
- package/src/ui/styled/OfferButton.tsx +33 -13
- package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
- package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
- package/src/ui/styled/PodcastEpisode.tsx +20 -9
- package/src/ui/styled/PodcastList.tsx +9 -3
- package/src/ui/styled/PodcastShow.tsx +29 -7
- package/src/ui/styled/PostsFeed.tsx +10 -8
- package/src/ui/styled/PresaleCode.tsx +8 -11
- package/src/ui/styled/ProductBrowseNav.tsx +21 -11
- package/src/ui/styled/ProductDetail.tsx +58 -28
- package/src/ui/styled/ReactionBar.tsx +4 -2
- package/src/ui/styled/ReplayList.tsx +24 -13
- package/src/ui/styled/ResetPasswordForm.tsx +27 -15
- package/src/ui/styled/ResumeCart.tsx +289 -0
- package/src/ui/styled/ReviewForm.tsx +52 -58
- package/src/ui/styled/SignupForm.tsx +29 -16
- package/src/ui/styled/TicketTransfer.tsx +55 -54
- package/src/ui/styled/UserMenu.tsx +16 -14
- package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
|
@@ -0,0 +1,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 =
|
|
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:
|
|
54
|
-
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(
|
|
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={
|
|
79
|
-
<h1 style={h1Style(t)}
|
|
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
|
-
|
|
82
|
-
|
|
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={
|
|
96
|
-
<h1 style={h1Style(t)}
|
|
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
|
-
|
|
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={
|
|
117
|
-
<h1 style={h1Style(t)}
|
|
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:
|
|
142
|
-
color:
|
|
143
|
-
background:
|
|
144
|
-
border: `1px solid ${
|
|
145
|
-
borderRadius:
|
|
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 ${
|
|
160
|
-
borderRadius:
|
|
161
|
-
background:
|
|
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(
|
|
166
|
-
{existing ? "
|
|
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
|
-
|
|
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}>
|
|
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="
|
|
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={
|
|
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 ?
|
|
203
|
-
color={star <= displayStars ?
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
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="
|
|
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}>
|
|
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 }}>
|
|
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:
|
|
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 ? "
|
|
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(
|
|
270
|
-
return { fontSize: 24, fontWeight: 800, fontFamily:
|
|
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
|
|
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 ${
|
|
285
|
-
borderRadius:
|
|
286
|
-
background:
|
|
278
|
+
border: `1px solid ${tokens.border}`,
|
|
279
|
+
borderRadius: tokens.cornerRadius,
|
|
280
|
+
background: tokens.surface,
|
|
287
281
|
}}
|
|
288
282
|
>
|
|
289
283
|
{children}
|