@tribe-nest/forge 3.25.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/activeFunnel.ts +35 -0
- package/src/client/activeLocale.ts +34 -0
- package/src/client/createForgeClient.ts +22 -0
- package/src/contexts/CartContext.tsx +84 -1
- package/src/contexts/_tests/CartContext.spec.tsx +210 -0
- package/src/data/queries/useCheckouts.ts +83 -0
- package/src/data/queries/useForms.ts +48 -3
- package/src/data/queries/usePageActions.ts +2 -2
- 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 +59 -14
- package/src/ui/headless/event/useEventCheckout.ts +3 -0
- package/src/ui/headless/funnel/Funnel.tsx +13 -0
- 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 +86 -46
- 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 +189 -88
- 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 +311 -125
- 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/PageActions.tsx +13 -9
- 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/ProductGrid.tsx +17 -4
- 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>
|
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
import { useEffect } from "react";
|
|
1
|
+
import { useEffect, type ReactNode } from "react";
|
|
2
2
|
import { Clock, Loader2, Ticket, XCircle } from "lucide-react";
|
|
3
3
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
4
|
import { readableTextOn } from "../theme/contrast";
|
|
5
5
|
import { ConfirmationStage, ConfirmationCard, CheckSeal, WarnSeal, alpha } from "./Confirmation";
|
|
6
6
|
import { useCheckoutFinalize } from "../../data/queries/useFinalize";
|
|
7
7
|
import { useCart } from "../../contexts/CartContext";
|
|
8
|
+
import { usePublicAuth } from "../../contexts/PublicAuthContext";
|
|
9
|
+
import { useForgeT } from "../../i18n";
|
|
10
|
+
|
|
11
|
+
/** The card's padded, centered interior — the shell itself has neither, and
|
|
12
|
+
content dropped in bare sits left-aligned against the card edge. */
|
|
13
|
+
function Body({ children }: { children: ReactNode }) {
|
|
14
|
+
return <div style={{ padding: "40px 32px", textAlign: "center" }}>{children}</div>;
|
|
15
|
+
}
|
|
8
16
|
|
|
9
17
|
export interface BundleConfirmationProps {
|
|
10
18
|
checkoutId: string;
|
|
@@ -31,7 +39,11 @@ export function BundleConfirmation({
|
|
|
31
39
|
checkoutPath = "/i/checkout",
|
|
32
40
|
}: BundleConfirmationProps) {
|
|
33
41
|
const theme = useForgeTheme();
|
|
42
|
+
const t = useForgeT();
|
|
34
43
|
const { clearCart } = useCart();
|
|
44
|
+
// A guest has no account screen behind "View your orders": the link would
|
|
45
|
+
// land them on a login wall for an order their EMAIL is the receipt for.
|
|
46
|
+
const { user } = usePublicAuth();
|
|
35
47
|
const failedRedirect = redirectStatus === "failed";
|
|
36
48
|
|
|
37
49
|
const { data, isLoading, isError } = useCheckoutFinalize(
|
|
@@ -73,12 +85,14 @@ export function BundleConfirmation({
|
|
|
73
85
|
return (
|
|
74
86
|
<ConfirmationStage>
|
|
75
87
|
<ConfirmationCard>
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
88
|
+
<Body>
|
|
89
|
+
<WarnSeal icon={<XCircle size={28} />} />
|
|
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)}
|
|
95
|
+
</Body>
|
|
82
96
|
</ConfirmationCard>
|
|
83
97
|
</ConfirmationStage>
|
|
84
98
|
);
|
|
@@ -88,8 +102,11 @@ export function BundleConfirmation({
|
|
|
88
102
|
return (
|
|
89
103
|
<ConfirmationStage>
|
|
90
104
|
<ConfirmationCard>
|
|
91
|
-
<
|
|
92
|
-
|
|
105
|
+
<Body>
|
|
106
|
+
<Loader2 size={28} style={{ animation: "cc-spin 1s linear infinite" }} />
|
|
107
|
+
<style>{"@keyframes cc-spin{to{transform:rotate(360deg)}}"}</style>
|
|
108
|
+
<h1 style={{ fontSize: 20, fontWeight: 700, marginTop: 12 }}>{t("forge.bundle_confirmation.loading")}</h1>
|
|
109
|
+
</Body>
|
|
93
110
|
</ConfirmationCard>
|
|
94
111
|
</ConfirmationStage>
|
|
95
112
|
);
|
|
@@ -102,16 +119,17 @@ export function BundleConfirmation({
|
|
|
102
119
|
return (
|
|
103
120
|
<ConfirmationStage>
|
|
104
121
|
<ConfirmationCard>
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
122
|
+
<Body>
|
|
123
|
+
<WarnSeal icon={<Clock size={28} />} />
|
|
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>
|
|
128
|
+
<div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
|
|
129
|
+
{user && link(accountPath, t("forge.bundle_confirmation.view_orders"), true)}
|
|
130
|
+
{link(explorePath, t("forge.bundle_confirmation.continue_shopping"), !user)}
|
|
131
|
+
</div>
|
|
132
|
+
</Body>
|
|
115
133
|
</ConfirmationCard>
|
|
116
134
|
</ConfirmationStage>
|
|
117
135
|
);
|
|
@@ -120,41 +138,63 @@ export function BundleConfirmation({
|
|
|
120
138
|
const ticketChildren = (data?.children ?? []).filter((c) => c.sourceType === "event_ticket_order").length;
|
|
121
139
|
const orderChildren = (data?.children ?? []).filter((c) => c.sourceType === "order").length;
|
|
122
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
|
+
|
|
123
153
|
return (
|
|
124
154
|
<ConfirmationStage>
|
|
125
155
|
<ConfirmationCard>
|
|
126
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
Paid in one go. {ticketChildren > 0 && "Your tickets are on their way by email"}
|
|
130
|
-
{ticketChildren > 0 && orderChildren > 0 && ", and "}
|
|
131
|
-
{orderChildren > 0 && `${ticketChildren > 0 ? "your order is confirmed" : "Your order is confirmed"}`}.
|
|
132
|
-
</p>
|
|
133
|
-
|
|
134
|
-
{ticketChildren > 0 && (
|
|
135
|
-
<div
|
|
156
|
+
<Body>
|
|
157
|
+
<CheckSeal />
|
|
158
|
+
<p
|
|
136
159
|
style={{
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
background: alpha(theme.colors.primary, 0.08),
|
|
160
|
+
margin: "22px 0 6px",
|
|
161
|
+
fontSize: 12,
|
|
162
|
+
fontWeight: 700,
|
|
163
|
+
letterSpacing: "0.22em",
|
|
164
|
+
textTransform: "uppercase",
|
|
143
165
|
color: theme.colors.primary,
|
|
144
|
-
fontSize: 14,
|
|
145
|
-
fontWeight: 600,
|
|
146
|
-
marginBottom: 20,
|
|
147
166
|
}}
|
|
148
167
|
>
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
</
|
|
152
|
-
|
|
168
|
+
{t("forge.bundle_confirmation.eyebrow")}
|
|
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>
|
|
153
172
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
173
|
+
{ticketChildren > 0 && (
|
|
174
|
+
<div
|
|
175
|
+
style={{
|
|
176
|
+
display: "inline-flex",
|
|
177
|
+
alignItems: "center",
|
|
178
|
+
gap: 8,
|
|
179
|
+
padding: "8px 14px",
|
|
180
|
+
borderRadius: theme.cornerRadius,
|
|
181
|
+
background: alpha(theme.colors.primary, 0.08),
|
|
182
|
+
color: theme.colors.primary,
|
|
183
|
+
fontSize: 14,
|
|
184
|
+
fontWeight: 600,
|
|
185
|
+
marginBottom: 20,
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
<Ticket size={16} />
|
|
189
|
+
{t("forge.bundle_confirmation.tickets_emailed")}
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
|
|
193
|
+
<div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
|
|
194
|
+
{user && link(accountPath, t("forge.bundle_confirmation.view_orders"), true)}
|
|
195
|
+
{link(explorePath, t("forge.bundle_confirmation.continue_shopping"), !user)}
|
|
196
|
+
</div>
|
|
197
|
+
</Body>
|
|
158
198
|
</ConfirmationCard>
|
|
159
199
|
</ConfirmationStage>
|
|
160
200
|
);
|
|
@@ -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.
|