@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useForgeT } from "../../i18n";
|
|
2
3
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
3
4
|
import { readableTextOn } from "../theme/contrast";
|
|
4
5
|
import { useAiAgent } from "../headless/agent/useAiAgent";
|
|
@@ -24,6 +25,7 @@ export interface AiAgentWidgetProps {
|
|
|
24
25
|
* behavior. For custom UI, use the `useAiAgent()` hook instead.
|
|
25
26
|
*/
|
|
26
27
|
export function AiAgentWidget({ position = "bottom-right", accentColor, title, zIndex = 9999 }: AiAgentWidgetProps) {
|
|
28
|
+
const t = useForgeT();
|
|
27
29
|
const theme = useForgeTheme();
|
|
28
30
|
const { isEnabled, config, messages, sending, error, send } = useAiAgent();
|
|
29
31
|
const [open, setOpen] = useState(false);
|
|
@@ -33,7 +35,7 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
|
|
|
33
35
|
const accent = accentColor || theme.colors.primary;
|
|
34
36
|
const onAccent = readableTextOn(accent);
|
|
35
37
|
const side = position === "bottom-left" ? { left: 20 } : { right: 20 };
|
|
36
|
-
const heading = title || config?.displayName || "
|
|
38
|
+
const heading = title || config?.displayName || t("forge.ai_agent_widget.default_title");
|
|
37
39
|
|
|
38
40
|
useEffect(() => {
|
|
39
41
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
|
|
@@ -67,14 +69,16 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
|
|
|
67
69
|
>
|
|
68
70
|
<div style={{ background: accent, color: onAccent, padding: "12px 16px", fontWeight: 600, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
69
71
|
<span>{heading}</span>
|
|
70
|
-
<button onClick={() => setOpen(false)} style={{ background: "transparent", border: "none", color: onAccent, cursor: "pointer", fontSize: 20, lineHeight: 1 }} aria-label="
|
|
72
|
+
<button onClick={() => setOpen(false)} style={{ background: "transparent", border: "none", color: onAccent, cursor: "pointer", fontSize: 20, lineHeight: 1 }} aria-label={t("forge.ai_agent_widget.aria_close")}>
|
|
71
73
|
×
|
|
72
74
|
</button>
|
|
73
75
|
</div>
|
|
74
76
|
|
|
75
77
|
<div ref={scrollRef} style={{ flex: 1, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 10 }}>
|
|
76
78
|
{messages.length === 0 && (
|
|
77
|
-
<div style={{ opacity: 0.7, fontSize: 14 }}>
|
|
79
|
+
<div style={{ opacity: 0.7, fontSize: 14 }}>
|
|
80
|
+
{config?.greeting || t("forge.ai_agent_widget.default_greeting")}
|
|
81
|
+
</div>
|
|
78
82
|
)}
|
|
79
83
|
{messages.map((m, i) => (
|
|
80
84
|
<div
|
|
@@ -105,7 +109,7 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
|
|
|
105
109
|
value={input}
|
|
106
110
|
onChange={(e) => setInput(e.target.value)}
|
|
107
111
|
onKeyDown={(e) => e.key === "Enter" && submit()}
|
|
108
|
-
placeholder="
|
|
112
|
+
placeholder={t("forge.ai_agent_widget.input_placeholder")}
|
|
109
113
|
disabled={sending}
|
|
110
114
|
style={{
|
|
111
115
|
flex: 1,
|
|
@@ -123,7 +127,7 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
|
|
|
123
127
|
disabled={sending || !input.trim()}
|
|
124
128
|
style={{ background: accent, color: onAccent, border: "none", borderRadius: theme.cornerRadius, padding: "0 16px", cursor: "pointer", fontWeight: 600 }}
|
|
125
129
|
>
|
|
126
|
-
|
|
130
|
+
{t("forge.ai_agent_widget.send")}
|
|
127
131
|
</button>
|
|
128
132
|
</div>
|
|
129
133
|
</div>
|
|
@@ -131,7 +135,7 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
|
|
|
131
135
|
|
|
132
136
|
<button
|
|
133
137
|
onClick={() => setOpen((o) => !o)}
|
|
134
|
-
aria-label="
|
|
138
|
+
aria-label={t("forge.ai_agent_widget.aria_open")}
|
|
135
139
|
style={{
|
|
136
140
|
width: 56,
|
|
137
141
|
height: 56,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SkipBack, SkipForward, Play, Pause, X } from "lucide-react";
|
|
2
2
|
import { useAudioPlayer } from "../../contexts/AudioPlayerContext";
|
|
3
|
+
import { useForgeT } from "../../i18n";
|
|
3
4
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
5
|
|
|
5
6
|
function fmtTime(s: number): string {
|
|
@@ -26,6 +27,7 @@ export interface AudioPlayerProps {
|
|
|
26
27
|
* playback and dismisses the bar.
|
|
27
28
|
*/
|
|
28
29
|
export function AudioPlayer({ hideWhenEmpty = true, fixed = true }: AudioPlayerProps) {
|
|
30
|
+
const t = useForgeT();
|
|
29
31
|
const theme = useForgeTheme();
|
|
30
32
|
const a = useAudioPlayer();
|
|
31
33
|
|
|
@@ -64,19 +66,25 @@ export function AudioPlayer({ hideWhenEmpty = true, fixed = true }: AudioPlayerP
|
|
|
64
66
|
style={{ width: 44, height: 44, borderRadius: theme.cornerRadius, objectFit: "cover", flexShrink: 0 }}
|
|
65
67
|
/>
|
|
66
68
|
)}
|
|
67
|
-
<button onClick={a.playPrevious} style={ctrlBtn} aria-label="
|
|
69
|
+
<button onClick={a.playPrevious} style={ctrlBtn} aria-label={t("forge.audio_player.previous")}>
|
|
68
70
|
<SkipBack size={18} />
|
|
69
71
|
</button>
|
|
70
|
-
<button
|
|
72
|
+
<button
|
|
73
|
+
onClick={() => (a.isPlaying ? a.pause() : a.play())}
|
|
74
|
+
style={ctrlBtn}
|
|
75
|
+
aria-label={a.isPlaying ? t("forge.audio_player.pause") : t("forge.audio_player.play")}
|
|
76
|
+
>
|
|
71
77
|
{a.isPlaying ? <Pause size={20} /> : <Play size={20} />}
|
|
72
78
|
</button>
|
|
73
|
-
<button onClick={a.playNext} style={ctrlBtn} aria-label="
|
|
79
|
+
<button onClick={a.playNext} style={ctrlBtn} aria-label={t("forge.audio_player.next")}>
|
|
74
80
|
<SkipForward size={18} />
|
|
75
81
|
</button>
|
|
76
82
|
|
|
77
83
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
78
|
-
<div
|
|
79
|
-
{
|
|
84
|
+
<div
|
|
85
|
+
style={{ fontWeight: 600, fontSize: 14, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
|
86
|
+
>
|
|
87
|
+
{a.currentTrack?.title ?? t("forge.audio_player.nothing_playing")}
|
|
80
88
|
{a.currentTrack?.artist ? ` · ${a.currentTrack.artist}` : ""}
|
|
81
89
|
</div>
|
|
82
90
|
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12, opacity: 0.8 }}>
|
|
@@ -93,7 +101,7 @@ export function AudioPlayer({ hideWhenEmpty = true, fixed = true }: AudioPlayerP
|
|
|
93
101
|
</div>
|
|
94
102
|
</div>
|
|
95
103
|
|
|
96
|
-
<button onClick={a.stop} style={{ ...ctrlBtn, opacity: 0.7 }} aria-label="
|
|
104
|
+
<button onClick={a.stop} style={{ ...ctrlBtn, opacity: 0.7 }} aria-label={t("forge.audio_player.aria_close")}>
|
|
97
105
|
<X size={18} />
|
|
98
106
|
</button>
|
|
99
107
|
</div>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useBlogCategory, useBlogCategoryPosts } from "../../data/queries/useBlog";
|
|
2
2
|
import type { BlogPost, BlogCategory as BlogCategoryModel } from "../../types/models";
|
|
3
|
+
import { useForgeT } from "../../i18n";
|
|
3
4
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
5
|
import { Loading } from "./Loading";
|
|
5
6
|
|
|
@@ -19,11 +20,12 @@ function excerpt(html: string, len: number) {
|
|
|
19
20
|
/** Blog category page — grid of posts in a category. Built on `useBlogCategory` + `useBlogCategoryPosts`. */
|
|
20
21
|
export function BlogCategory({ slug, blogPostHref = (s) => `/i/blog/${s}`, initialCategory }: BlogCategoryProps) {
|
|
21
22
|
const theme = useForgeTheme();
|
|
23
|
+
const t = useForgeT();
|
|
22
24
|
const { data: category, isLoading: catLoading } = useBlogCategory(slug, { initialData: initialCategory });
|
|
23
25
|
const { data: posts, isLoading: postsLoading } = useBlogCategoryPosts(slug, { page: 1, limit: 12 });
|
|
24
26
|
|
|
25
27
|
if (catLoading || postsLoading) return <Loading fullPage />;
|
|
26
|
-
if (!category) return <p>
|
|
28
|
+
if (!category) return <p>{t("forge.blog_category.not_found")}</p>;
|
|
27
29
|
|
|
28
30
|
const list = posts?.data ?? [];
|
|
29
31
|
|
|
@@ -72,7 +74,7 @@ export function BlogCategory({ slug, blogPostHref = (s) => `/i/blog/${s}`, initi
|
|
|
72
74
|
})}
|
|
73
75
|
</div>
|
|
74
76
|
) : (
|
|
75
|
-
<p style={{ opacity: 0.7 }}>
|
|
77
|
+
<p style={{ opacity: 0.7 }}>{t("forge.blog_category.empty_title")}</p>
|
|
76
78
|
)}
|
|
77
79
|
</div>
|
|
78
80
|
);
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type BlogComment,
|
|
10
10
|
} from "../../data/queries/useBlogComments";
|
|
11
11
|
import { BotProtection, EMPTY_BOT_FIELDS, type BotFields } from "./BotProtection";
|
|
12
|
+
import { useForgeT } from "../../i18n";
|
|
12
13
|
|
|
13
14
|
export interface BlogCommentsProps {
|
|
14
15
|
blogPostId: string;
|
|
@@ -31,7 +32,8 @@ function formatDate(iso: string): string {
|
|
|
31
32
|
* (apps/client, site-starter, AI-code-built sites). Renders nothing when the
|
|
32
33
|
* profile has comments disabled.
|
|
33
34
|
*/
|
|
34
|
-
export function BlogComments({ blogPostId, pageSize = 10, title
|
|
35
|
+
export function BlogComments({ blogPostId, pageSize = 10, title, signInHref, className, style }: BlogCommentsProps) {
|
|
36
|
+
const t = useForgeT();
|
|
35
37
|
const theme = useForgeTheme();
|
|
36
38
|
const { token } = useForge();
|
|
37
39
|
const isSignedIn = !!token;
|
|
@@ -53,6 +55,7 @@ export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", si
|
|
|
53
55
|
[commentsQuery.data],
|
|
54
56
|
);
|
|
55
57
|
const total = commentsQuery.data?.pages?.[0]?.total ?? 0;
|
|
58
|
+
const heading = title ?? t("forge.blog_comments.title");
|
|
56
59
|
|
|
57
60
|
const muted = theme.colors.muted ?? `${theme.colors.text}99`;
|
|
58
61
|
const border = theme.colors.border ?? `${theme.colors.primary}33`;
|
|
@@ -74,11 +77,11 @@ export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", si
|
|
|
74
77
|
setError(null);
|
|
75
78
|
const trimmed = content.trim();
|
|
76
79
|
if (!trimmed) {
|
|
77
|
-
setError("
|
|
80
|
+
setError(t("forge.blog_comments.error_empty"));
|
|
78
81
|
return;
|
|
79
82
|
}
|
|
80
83
|
if (!isSignedIn && (!name.trim() || !email.trim())) {
|
|
81
|
-
setError("
|
|
84
|
+
setError(t("forge.blog_comments.error_identity_required"));
|
|
82
85
|
return;
|
|
83
86
|
}
|
|
84
87
|
try {
|
|
@@ -96,7 +99,7 @@ export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", si
|
|
|
96
99
|
setBotResetKey((k) => k + 1);
|
|
97
100
|
} catch (e) {
|
|
98
101
|
const message =
|
|
99
|
-
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ?? "
|
|
102
|
+
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ?? t("forge.blog_comments.error_generic");
|
|
100
103
|
setError(message);
|
|
101
104
|
}
|
|
102
105
|
};
|
|
@@ -109,8 +112,7 @@ export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", si
|
|
|
109
112
|
style={{ color: theme.colors.text, fontFamily: theme.fontFamily, textAlign: "left", ...style }}
|
|
110
113
|
>
|
|
111
114
|
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 16 }}>
|
|
112
|
-
{title}
|
|
113
|
-
{total > 0 ? ` (${total})` : ""}
|
|
115
|
+
{total > 0 ? t("forge.blog_comments.title_with_count", { title: heading, count: total }) : heading}
|
|
114
116
|
</h2>
|
|
115
117
|
|
|
116
118
|
{/* Form */}
|
|
@@ -118,33 +120,38 @@ export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", si
|
|
|
118
120
|
<p style={{ color: muted, marginBottom: 24 }}>
|
|
119
121
|
{signInHref ? (
|
|
120
122
|
<a href={signInHref} style={{ color: theme.colors.primary }}>
|
|
121
|
-
|
|
123
|
+
{t("forge.blog_comments.sign_in")}
|
|
122
124
|
</a>
|
|
123
125
|
) : (
|
|
124
|
-
"
|
|
126
|
+
t("forge.blog_comments.sign_in")
|
|
125
127
|
)}{" "}
|
|
126
|
-
|
|
128
|
+
{t("forge.blog_comments.sign_in_suffix")}
|
|
127
129
|
</p>
|
|
128
130
|
) : pendingNotice ? (
|
|
129
131
|
<div
|
|
130
132
|
style={{ padding: 12, borderRadius: theme.cornerRadius, background: `${theme.colors.primary}14`, marginBottom: 24 }}
|
|
131
133
|
>
|
|
132
|
-
|
|
134
|
+
{t("forge.blog_comments.pending_notice")}
|
|
133
135
|
<button
|
|
134
136
|
onClick={() => setPendingNotice(false)}
|
|
135
137
|
style={{ marginLeft: 8, background: "none", border: "none", color: theme.colors.primary, cursor: "pointer" }}
|
|
136
138
|
>
|
|
137
|
-
|
|
139
|
+
{t("forge.blog_comments.write_another")}
|
|
138
140
|
</button>
|
|
139
141
|
</div>
|
|
140
142
|
) : (
|
|
141
143
|
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 28 }}>
|
|
142
144
|
{!isSignedIn && (
|
|
143
145
|
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
|
|
144
|
-
<input
|
|
146
|
+
<input
|
|
147
|
+
placeholder={t("forge.blog_comments.name_placeholder")}
|
|
148
|
+
value={name}
|
|
149
|
+
onChange={(e) => setName(e.target.value)}
|
|
150
|
+
style={{ ...inputStyle, flex: 1, minWidth: 160 }}
|
|
151
|
+
/>
|
|
145
152
|
<input
|
|
146
153
|
type="email"
|
|
147
|
-
placeholder="
|
|
154
|
+
placeholder={t("forge.blog_comments.email_placeholder")}
|
|
148
155
|
value={email}
|
|
149
156
|
onChange={(e) => setEmail(e.target.value)}
|
|
150
157
|
style={{ ...inputStyle, flex: 1, minWidth: 160 }}
|
|
@@ -152,7 +159,7 @@ export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", si
|
|
|
152
159
|
</div>
|
|
153
160
|
)}
|
|
154
161
|
<textarea
|
|
155
|
-
placeholder="
|
|
162
|
+
placeholder={t("forge.blog_comments.comment_placeholder")}
|
|
156
163
|
value={content}
|
|
157
164
|
onChange={(e) => setContent(e.target.value)}
|
|
158
165
|
rows={3}
|
|
@@ -175,7 +182,7 @@ export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", si
|
|
|
175
182
|
opacity: submit.isPending ? 0.7 : 1,
|
|
176
183
|
}}
|
|
177
184
|
>
|
|
178
|
-
{submit.isPending ? "
|
|
185
|
+
{submit.isPending ? t("forge.blog_comments.submitting") : t("forge.blog_comments.submit")}
|
|
179
186
|
</button>
|
|
180
187
|
</div>
|
|
181
188
|
</div>
|
|
@@ -183,15 +190,15 @@ export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", si
|
|
|
183
190
|
|
|
184
191
|
{/* List */}
|
|
185
192
|
{commentsQuery.isLoading ? (
|
|
186
|
-
<p style={{ color: muted }}>
|
|
193
|
+
<p style={{ color: muted }}>{t("forge.blog_comments.loading")}</p>
|
|
187
194
|
) : comments.length === 0 ? (
|
|
188
|
-
<p style={{ color: muted }}>
|
|
195
|
+
<p style={{ color: muted }}>{t("forge.blog_comments.empty_title")}</p>
|
|
189
196
|
) : (
|
|
190
197
|
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
|
191
198
|
{comments.map((c) => (
|
|
192
199
|
<div key={c.id}>
|
|
193
200
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
|
|
194
|
-
<strong>{c.authorName || "
|
|
201
|
+
<strong>{c.authorName || t("forge.blog_comments.anonymous")}</strong>
|
|
195
202
|
<span style={{ color: muted, fontSize: 13 }}>{formatDate(c.createdAt)}</span>
|
|
196
203
|
</div>
|
|
197
204
|
<p style={{ margin: "4px 0", whiteSpace: "pre-wrap" }}>{c.content}</p>
|
|
@@ -200,7 +207,7 @@ export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", si
|
|
|
200
207
|
{c.replies.map((r) => (
|
|
201
208
|
<div key={r.id}>
|
|
202
209
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
|
|
203
|
-
<strong style={{ color: theme.colors.primary }}>{r.authorName || "
|
|
210
|
+
<strong style={{ color: theme.colors.primary }}>{r.authorName || t("forge.blog_comments.author")}</strong>
|
|
204
211
|
<span style={{ color: muted, fontSize: 13 }}>{formatDate(r.createdAt)}</span>
|
|
205
212
|
</div>
|
|
206
213
|
<p style={{ margin: "4px 0", whiteSpace: "pre-wrap" }}>{r.content}</p>
|
|
@@ -227,7 +234,7 @@ export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", si
|
|
|
227
234
|
cursor: "pointer",
|
|
228
235
|
}}
|
|
229
236
|
>
|
|
230
|
-
{commentsQuery.isFetchingNextPage ? "
|
|
237
|
+
{commentsQuery.isFetchingNextPage ? t("forge.blog_comments.loading_more") : t("forge.blog_comments.load_more")}
|
|
231
238
|
</button>
|
|
232
239
|
</div>
|
|
233
240
|
)}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useFeaturedBlogPost, useBlogPosts } from "../../data/queries/useBlog";
|
|
2
2
|
import type { BlogPost } from "../../types/models";
|
|
3
|
+
import { useForgeT } from "../../i18n";
|
|
3
4
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
5
|
import { Loading } from "./Loading";
|
|
5
6
|
|
|
@@ -19,6 +20,7 @@ function excerpt(html: string, len: number) {
|
|
|
19
20
|
|
|
20
21
|
/** Blog index — featured post + grid of remaining posts. Built on `useFeaturedBlogPost` + `useBlogPosts`. */
|
|
21
22
|
export function BlogList({ blogPostHref = (slug) => `/i/blog/${slug}` }: BlogListProps = {}) {
|
|
23
|
+
const t = useForgeT();
|
|
22
24
|
const theme = useForgeTheme();
|
|
23
25
|
const { data: featured, isLoading: loadingFeatured } = useFeaturedBlogPost();
|
|
24
26
|
const { data, isLoading: loadingPosts } = useBlogPosts({ page: 1, limit: 12 });
|
|
@@ -39,7 +41,7 @@ export function BlogList({ blogPostHref = (slug) => `/i/blog/${slug}` }: BlogLis
|
|
|
39
41
|
|
|
40
42
|
return (
|
|
41
43
|
<div style={{ maxWidth: 900, margin: "0 auto", color: theme.colors.text }}>
|
|
42
|
-
<h1 style={{ fontSize: 32, fontWeight: 800, marginBottom: 24 }}>
|
|
44
|
+
<h1 style={{ fontSize: 32, fontWeight: 800, marginBottom: 24 }}>{t("forge.blog_list.title")}</h1>
|
|
43
45
|
|
|
44
46
|
{featured && (
|
|
45
47
|
<a href={blogPostHref(featured.slug)} style={{ ...card, marginBottom: 32 }}>
|
|
@@ -61,7 +63,7 @@ export function BlogList({ blogPostHref = (slug) => `/i/blog/${slug}` }: BlogLis
|
|
|
61
63
|
color: theme.colors.background,
|
|
62
64
|
}}
|
|
63
65
|
>
|
|
64
|
-
|
|
66
|
+
{t("forge.blog_list.featured_badge")}
|
|
65
67
|
</span>
|
|
66
68
|
{featured.publishedAt && (
|
|
67
69
|
<span style={{ fontSize: 13, opacity: 0.6 }}>{formatDate(featured.publishedAt)}</span>
|
|
@@ -94,7 +96,7 @@ export function BlogList({ blogPostHref = (slug) => `/i/blog/${slug}` }: BlogLis
|
|
|
94
96
|
})}
|
|
95
97
|
</div>
|
|
96
98
|
) : (
|
|
97
|
-
!featured && <p style={{ opacity: 0.7 }}>
|
|
99
|
+
!featured && <p style={{ opacity: 0.7 }}>{t("forge.blog_list.empty_body")}</p>
|
|
98
100
|
)}
|
|
99
101
|
</div>
|
|
100
102
|
);
|
|
@@ -3,6 +3,7 @@ import { useBlogPost } from "../../data/queries/useBlog";
|
|
|
3
3
|
import { useAudioPlayer } from "../../contexts/AudioPlayerContext";
|
|
4
4
|
import type { BlogPost as BlogPostModel } from "../../types/models";
|
|
5
5
|
import { useForgeTheme, useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
6
|
+
import { useForgeT } from "../../i18n";
|
|
6
7
|
import { Loading } from "./Loading";
|
|
7
8
|
// Baseline `.rich-text` content styles ship with Forge (WYSIWYG with the admin
|
|
8
9
|
// editor) — no host-loaded stylesheet needed.
|
|
@@ -46,7 +47,8 @@ function AudioAttachmentBar({
|
|
|
46
47
|
audio: BlogPostModel["media"][number];
|
|
47
48
|
coverImage?: string;
|
|
48
49
|
}) {
|
|
49
|
-
const
|
|
50
|
+
const tokens = useThemeTokens();
|
|
51
|
+
const t = useForgeT();
|
|
50
52
|
const player = useAudioPlayer();
|
|
51
53
|
|
|
52
54
|
const isCurrent = player.currentTrack?.id === post.id;
|
|
@@ -71,15 +73,15 @@ function AudioAttachmentBar({
|
|
|
71
73
|
gap: 16,
|
|
72
74
|
padding: 16,
|
|
73
75
|
marginBottom: 24,
|
|
74
|
-
border: `1px solid ${
|
|
75
|
-
borderRadius:
|
|
76
|
-
background:
|
|
76
|
+
border: `1px solid ${tokens.border}`,
|
|
77
|
+
borderRadius: tokens.cornerRadius,
|
|
78
|
+
background: tokens.surface,
|
|
77
79
|
}}
|
|
78
80
|
>
|
|
79
81
|
<button
|
|
80
82
|
type="button"
|
|
81
83
|
onClick={onClick}
|
|
82
|
-
aria-label={isPlaying ? "
|
|
84
|
+
aria-label={isPlaying ? t("forge.blog_post.pause") : t("forge.blog_post.play")}
|
|
83
85
|
style={{
|
|
84
86
|
display: "inline-flex",
|
|
85
87
|
alignItems: "center",
|
|
@@ -89,12 +91,12 @@ function AudioAttachmentBar({
|
|
|
89
91
|
border: "none",
|
|
90
92
|
cursor: "pointer",
|
|
91
93
|
fontWeight: 600,
|
|
92
|
-
background:
|
|
93
|
-
color:
|
|
94
|
+
background: tokens.primary,
|
|
95
|
+
color: tokens.textPrimary,
|
|
94
96
|
}}
|
|
95
97
|
>
|
|
96
98
|
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
|
|
97
|
-
{isPlaying ? "
|
|
99
|
+
{isPlaying ? t("forge.blog_post.pause") : t("forge.blog_post.play")}
|
|
98
100
|
</button>
|
|
99
101
|
{duration && <span style={{ fontSize: 14, opacity: 0.6, fontVariantNumeric: "tabular-nums" }}>{duration}</span>}
|
|
100
102
|
</div>
|
|
@@ -104,11 +106,12 @@ function AudioAttachmentBar({
|
|
|
104
106
|
/** Blog post detail, resolved by slug. Built on `useBlogPost`. */
|
|
105
107
|
export function BlogPost({ slug, blogHref = "/i/blog", initialPost }: BlogPostProps) {
|
|
106
108
|
const theme = useForgeTheme();
|
|
109
|
+
const t = useForgeT();
|
|
107
110
|
// Detail is resolved by slug (the public API looks posts up by slug).
|
|
108
111
|
const { data: post, isLoading } = useBlogPost(slug, { initialData: initialPost });
|
|
109
112
|
|
|
110
113
|
if (isLoading) return <Loading fullPage />;
|
|
111
|
-
if (!post) return <p>
|
|
114
|
+
if (!post) return <p>{t("forge.blog_post.not_found")}</p>;
|
|
112
115
|
|
|
113
116
|
// Cover is the first image media — audio posts put the audio file first in
|
|
114
117
|
// `media`, which must not be rendered as an <img>. `type` may be a bare kind
|
|
@@ -120,7 +123,7 @@ export function BlogPost({ slug, blogHref = "/i/blog", initialPost }: BlogPostPr
|
|
|
120
123
|
return (
|
|
121
124
|
<article style={{ maxWidth: 720, margin: "0 auto", color: theme.colors.text }}>
|
|
122
125
|
<a href={blogHref} style={{ color: theme.colors.primary, fontSize: 14 }}>
|
|
123
|
-
|
|
126
|
+
{t("forge.blog_post.back_to_blog")}
|
|
124
127
|
</a>
|
|
125
128
|
|
|
126
129
|
{cover && (
|
|
@@ -143,7 +146,7 @@ export function BlogPost({ slug, blogHref = "/i/blog", initialPost }: BlogPostPr
|
|
|
143
146
|
color: theme.colors.background,
|
|
144
147
|
}}
|
|
145
148
|
>
|
|
146
|
-
|
|
149
|
+
{t("forge.blog_post.featured")}
|
|
147
150
|
</span>
|
|
148
151
|
)}
|
|
149
152
|
</div>
|
|
@@ -6,6 +6,7 @@ import { ConfirmationStage, ConfirmationCard, CheckSeal, WarnSeal, alpha } from
|
|
|
6
6
|
import { useCheckoutFinalize } from "../../data/queries/useFinalize";
|
|
7
7
|
import { useCart } from "../../contexts/CartContext";
|
|
8
8
|
import { usePublicAuth } from "../../contexts/PublicAuthContext";
|
|
9
|
+
import { useForgeT } from "../../i18n";
|
|
9
10
|
|
|
10
11
|
/** The card's padded, centered interior — the shell itself has neither, and
|
|
11
12
|
content dropped in bare sits left-aligned against the card edge. */
|
|
@@ -38,6 +39,7 @@ export function BundleConfirmation({
|
|
|
38
39
|
checkoutPath = "/i/checkout",
|
|
39
40
|
}: BundleConfirmationProps) {
|
|
40
41
|
const theme = useForgeTheme();
|
|
42
|
+
const t = useForgeT();
|
|
41
43
|
const { clearCart } = useCart();
|
|
42
44
|
// A guest has no account screen behind "View your orders": the link would
|
|
43
45
|
// land them on a login wall for an order their EMAIL is the receipt for.
|
|
@@ -85,11 +87,11 @@ export function BundleConfirmation({
|
|
|
85
87
|
<ConfirmationCard>
|
|
86
88
|
<Body>
|
|
87
89
|
<WarnSeal icon={<XCircle size={28} />} />
|
|
88
|
-
<h1 style={{ fontSize: 22, fontWeight: 800, margin: "12px 0 6px" }}>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
</p>
|
|
92
|
-
{link(checkoutPath, "
|
|
90
|
+
<h1 style={{ fontSize: 22, fontWeight: 800, margin: "12px 0 6px" }}>
|
|
91
|
+
{t("forge.bundle_confirmation.failed_title")}
|
|
92
|
+
</h1>
|
|
93
|
+
<p style={{ opacity: 0.75, marginBottom: 20 }}>{t("forge.bundle_confirmation.failed_body")}</p>
|
|
94
|
+
{link(checkoutPath, t("forge.bundle_confirmation.return_to_checkout"), true)}
|
|
93
95
|
</Body>
|
|
94
96
|
</ConfirmationCard>
|
|
95
97
|
</ConfirmationStage>
|
|
@@ -103,7 +105,7 @@ export function BundleConfirmation({
|
|
|
103
105
|
<Body>
|
|
104
106
|
<Loader2 size={28} style={{ animation: "cc-spin 1s linear infinite" }} />
|
|
105
107
|
<style>{"@keyframes cc-spin{to{transform:rotate(360deg)}}"}</style>
|
|
106
|
-
<h1 style={{ fontSize: 20, fontWeight: 700, marginTop: 12 }}>
|
|
108
|
+
<h1 style={{ fontSize: 20, fontWeight: 700, marginTop: 12 }}>{t("forge.bundle_confirmation.loading")}</h1>
|
|
107
109
|
</Body>
|
|
108
110
|
</ConfirmationCard>
|
|
109
111
|
</ConfirmationStage>
|
|
@@ -119,14 +121,13 @@ export function BundleConfirmation({
|
|
|
119
121
|
<ConfirmationCard>
|
|
120
122
|
<Body>
|
|
121
123
|
<WarnSeal icon={<Clock size={28} />} />
|
|
122
|
-
<h1 style={{ fontSize: 22, fontWeight: 800, margin: "12px 0 6px" }}>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
</p>
|
|
124
|
+
<h1 style={{ fontSize: 22, fontWeight: 800, margin: "12px 0 6px" }}>
|
|
125
|
+
{t("forge.bundle_confirmation.partial_title")}
|
|
126
|
+
</h1>
|
|
127
|
+
<p style={{ opacity: 0.75, marginBottom: 20 }}>{t("forge.bundle_confirmation.partial_body")}</p>
|
|
127
128
|
<div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
|
|
128
|
-
{user && link(accountPath, "
|
|
129
|
-
{link(explorePath, "
|
|
129
|
+
{user && link(accountPath, t("forge.bundle_confirmation.view_orders"), true)}
|
|
130
|
+
{link(explorePath, t("forge.bundle_confirmation.continue_shopping"), !user)}
|
|
130
131
|
</div>
|
|
131
132
|
</Body>
|
|
132
133
|
</ConfirmationCard>
|
|
@@ -137,6 +138,18 @@ export function BundleConfirmation({
|
|
|
137
138
|
const ticketChildren = (data?.children ?? []).filter((c) => c.sourceType === "event_ticket_order").length;
|
|
138
139
|
const orderChildren = (data?.children ?? []).filter((c) => c.sourceType === "order").length;
|
|
139
140
|
|
|
141
|
+
// One key per combination rather than three clauses glued together: German
|
|
142
|
+
// orders the halves differently, so a sentence assembled from fragments can
|
|
143
|
+
// only ever be right in English.
|
|
144
|
+
const summaryKey =
|
|
145
|
+
ticketChildren > 0 && orderChildren > 0
|
|
146
|
+
? "forge.bundle_confirmation.summary_both"
|
|
147
|
+
: ticketChildren > 0
|
|
148
|
+
? "forge.bundle_confirmation.summary_tickets"
|
|
149
|
+
: orderChildren > 0
|
|
150
|
+
? "forge.bundle_confirmation.summary_order"
|
|
151
|
+
: "forge.bundle_confirmation.summary_paid";
|
|
152
|
+
|
|
140
153
|
return (
|
|
141
154
|
<ConfirmationStage>
|
|
142
155
|
<ConfirmationCard>
|
|
@@ -152,14 +165,10 @@ export function BundleConfirmation({
|
|
|
152
165
|
color: theme.colors.primary,
|
|
153
166
|
}}
|
|
154
167
|
>
|
|
155
|
-
|
|
156
|
-
</p>
|
|
157
|
-
<h1 style={{ fontSize: 24, fontWeight: 800, margin: "0 0 8px" }}>You're all set</h1>
|
|
158
|
-
<p style={{ opacity: 0.75, margin: "0 auto 16px", maxWidth: 360, lineHeight: 1.55 }}>
|
|
159
|
-
Paid in one go. {ticketChildren > 0 && "Your tickets are on their way by email"}
|
|
160
|
-
{ticketChildren > 0 && orderChildren > 0 && ", and "}
|
|
161
|
-
{orderChildren > 0 && `${ticketChildren > 0 ? "your order is confirmed" : "Your order is confirmed"}`}.
|
|
168
|
+
{t("forge.bundle_confirmation.eyebrow")}
|
|
162
169
|
</p>
|
|
170
|
+
<h1 style={{ fontSize: 24, fontWeight: 800, margin: "0 0 8px" }}>{t("forge.bundle_confirmation.title")}</h1>
|
|
171
|
+
<p style={{ opacity: 0.75, margin: "0 auto 16px", maxWidth: 360, lineHeight: 1.55 }}>{t(summaryKey)}</p>
|
|
163
172
|
|
|
164
173
|
{ticketChildren > 0 && (
|
|
165
174
|
<div
|
|
@@ -177,13 +186,13 @@ export function BundleConfirmation({
|
|
|
177
186
|
}}
|
|
178
187
|
>
|
|
179
188
|
<Ticket size={16} />
|
|
180
|
-
|
|
189
|
+
{t("forge.bundle_confirmation.tickets_emailed")}
|
|
181
190
|
</div>
|
|
182
191
|
)}
|
|
183
192
|
|
|
184
193
|
<div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
|
|
185
|
-
{user && link(accountPath, "
|
|
186
|
-
{link(explorePath, "
|
|
194
|
+
{user && link(accountPath, t("forge.bundle_confirmation.view_orders"), true)}
|
|
195
|
+
{link(explorePath, t("forge.bundle_confirmation.continue_shopping"), !user)}
|
|
187
196
|
</div>
|
|
188
197
|
</Body>
|
|
189
198
|
</ConfirmationCard>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type CSSProperties } from "react";
|
|
2
2
|
import { CalendarX2 } from "lucide-react";
|
|
3
3
|
import type { CancellationTermsView } from "../../types/models";
|
|
4
|
+
import { useForgeT } from "../../i18n";
|
|
4
5
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
5
6
|
|
|
6
7
|
export interface CancellationTermsProps {
|
|
@@ -38,6 +39,7 @@ export interface CancellationTermsProps {
|
|
|
38
39
|
*/
|
|
39
40
|
export function CancellationTerms({ terms, variant = "card", className, style }: CancellationTermsProps) {
|
|
40
41
|
const t = useThemeTokens();
|
|
42
|
+
const tr = useForgeT();
|
|
41
43
|
if (!terms?.description) return null;
|
|
42
44
|
|
|
43
45
|
const muted = t.muted;
|
|
@@ -58,7 +60,7 @@ export function CancellationTerms({ terms, variant = "card", className, style }:
|
|
|
58
60
|
>
|
|
59
61
|
<CalendarX2 size={15} style={{ flexShrink: 0, marginTop: 2, color: muted }} aria-hidden />
|
|
60
62
|
<div style={{ minWidth: 0 }}>
|
|
61
|
-
<p style={{ margin: 0, fontWeight: 600 }}>
|
|
63
|
+
<p style={{ margin: 0, fontWeight: 600 }}>{tr("forge.cancellation_terms.title")}</p>
|
|
62
64
|
<p style={{ margin: "2px 0 0", color: muted }}>{terms.description}</p>
|
|
63
65
|
{terms.terms && (
|
|
64
66
|
// The seller's own words, preserved as written — line breaks included.
|