@tribe-nest/forge 3.26.0 → 3.31.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 +6 -3
- package/src/_tests/publishedResolvability.spec.ts +184 -0
- package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
- package/src/_tests/workspaceAliases.ts +40 -0
- 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/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/CartContext.spec.tsx +210 -0
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useCheckouts.ts +83 -0
- package/src/data/queries/useMyBookings.ts +9 -1
- 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 +1019 -0
- package/src/i18n/en.json +1019 -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/format/_tests/membershipPwyw.spec.ts +185 -0
- package/src/ui/format/_tests/pwyw.spec.ts +65 -8
- package/src/ui/format/membershipPwyw.ts +164 -0
- package/src/ui/format/pwyw.ts +37 -0
- package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
- package/src/ui/headless/broadcast/broadcastState.ts +158 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
- 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/event/useEventCheckout.ts +8 -13
- package/src/ui/headless/index.ts +32 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
- package/src/ui/index.ts +38 -0
- package/src/ui/media/CallHelpHint.tsx +87 -0
- package/src/ui/media/CallStage.tsx +542 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
- package/src/ui/media/_tests/callState.spec.ts +452 -0
- package/src/ui/media/_tests/fakeNode.ts +178 -0
- package/src/ui/media/bookingSession.tsx +194 -0
- package/src/ui/media/callState.ts +341 -0
- package/src/ui/media/index.ts +135 -0
- package/src/ui/shell/TribeNestApp.tsx +36 -27
- package/src/ui/styled/AccountDashboard.tsx +289 -96
- 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/BroadcastWatch.tsx +107 -0
- 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 +17 -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/LiveBroadcastList.tsx +171 -0
- package/src/ui/styled/Loading.tsx +7 -1
- package/src/ui/styled/LoginForm.tsx +32 -14
- package/src/ui/styled/MembershipCheckout.tsx +341 -49
- package/src/ui/styled/MembershipTiers.tsx +34 -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 +32 -15
- package/src/ui/styled/ResumeCart.tsx +289 -0
- package/src/ui/styled/ReviewForm.tsx +52 -58
- package/src/ui/styled/SignupForm.tsx +34 -16
- package/src/ui/styled/TicketTransfer.tsx +55 -54
- package/src/ui/styled/UserMenu.tsx +16 -14
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
- package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
- package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
- package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
- package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
- package/src/ui/styled/community/CommunityComposer.tsx +182 -3
- package/src/ui/styled/community/CommunityFeed.tsx +36 -51
- package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
|
@@ -5,6 +5,7 @@ import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
|
5
5
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
6
6
|
import { PriceDisplay } from "../format/PriceDisplay";
|
|
7
7
|
import { usePricesIncludeTax } from "../../data/queries/useWebsite";
|
|
8
|
+
import { useForgeT } from "../../i18n";
|
|
8
9
|
import { Loading } from "./Loading";
|
|
9
10
|
import { CourseCheckout } from "./CourseCheckout";
|
|
10
11
|
import { ReviewsSection } from "./ReviewsSection";
|
|
@@ -41,13 +42,14 @@ export function CourseDetail({
|
|
|
41
42
|
loginPath,
|
|
42
43
|
membershipPath,
|
|
43
44
|
}: CourseDetailProps) {
|
|
44
|
-
const
|
|
45
|
+
const tokens = useThemeTokens();
|
|
46
|
+
const t = useForgeT();
|
|
45
47
|
const fmt = useAmountFormatter(formatAmount);
|
|
46
48
|
const pricesIncludeTax = usePricesIncludeTax();
|
|
47
49
|
const { data: course, isLoading } = useGetCourse(slug, { initialData: initialCourse });
|
|
48
50
|
|
|
49
51
|
if (isLoading) return <Loading fullPage />;
|
|
50
|
-
if (!course) return <p>
|
|
52
|
+
if (!course) return <p>{t("forge.course_detail.not_found")}</p>;
|
|
51
53
|
|
|
52
54
|
/**
|
|
53
55
|
* Members-only course (S.4). Announced by the detail read, which is why the
|
|
@@ -61,27 +63,27 @@ export function CourseDetail({
|
|
|
61
63
|
return (
|
|
62
64
|
<div
|
|
63
65
|
className={`pb-32 md:pb-0${className ? ` ${className}` : ""}`}
|
|
64
|
-
style={{ maxWidth: 1040, margin: "0 auto", color:
|
|
66
|
+
style={{ maxWidth: 1040, margin: "0 auto", color: tokens.text, ...style }}
|
|
65
67
|
>
|
|
66
68
|
{introVideo ? (
|
|
67
69
|
<video
|
|
68
70
|
src={introVideo}
|
|
69
71
|
controls
|
|
70
|
-
style={{ width: "100%", aspectRatio: "16/9", objectFit: "cover", borderRadius:
|
|
72
|
+
style={{ width: "100%", aspectRatio: "16/9", objectFit: "cover", borderRadius: tokens.cornerRadius }}
|
|
71
73
|
/>
|
|
72
74
|
) : (
|
|
73
75
|
cover && (
|
|
74
76
|
<img
|
|
75
77
|
src={cover}
|
|
76
78
|
alt={course.title}
|
|
77
|
-
style={{ width: "100%", aspectRatio: "16/9", objectFit: "cover", borderRadius:
|
|
79
|
+
style={{ width: "100%", aspectRatio: "16/9", objectFit: "cover", borderRadius: tokens.cornerRadius }}
|
|
78
80
|
/>
|
|
79
81
|
)
|
|
80
82
|
)}
|
|
81
83
|
|
|
82
84
|
<div className="flex flex-col md:flex-row md:items-start gap-6 mt-6">
|
|
83
85
|
<div className="flex-1 min-w-0">
|
|
84
|
-
<h1 style={{ fontSize: 32, fontWeight: 800, margin: "0 0 12px", fontFamily:
|
|
86
|
+
<h1 style={{ fontSize: 32, fontWeight: 800, margin: "0 0 12px", fontFamily: tokens.headingFontFamily }}>{course.title}</h1>
|
|
85
87
|
|
|
86
88
|
{course.description && (
|
|
87
89
|
<div dangerouslySetInnerHTML={{ __html: course.description }} style={{ lineHeight: 1.7, opacity: 0.9 }} />
|
|
@@ -89,15 +91,19 @@ export function CourseDetail({
|
|
|
89
91
|
|
|
90
92
|
{modules.length > 0 && (
|
|
91
93
|
<>
|
|
92
|
-
<h2
|
|
94
|
+
<h2
|
|
95
|
+
style={{ fontSize: 20, fontWeight: 700, margin: "24px 0 12px", fontFamily: tokens.headingFontFamily }}
|
|
96
|
+
>
|
|
97
|
+
{t("forge.course_detail.content_title")}
|
|
98
|
+
</h2>
|
|
93
99
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
94
100
|
{modules.map((module) => (
|
|
95
101
|
<div
|
|
96
102
|
key={module.id}
|
|
97
103
|
style={{
|
|
98
104
|
padding: 16,
|
|
99
|
-
border: `1px solid ${
|
|
100
|
-
borderRadius:
|
|
105
|
+
border: `1px solid ${tokens.primary}40`,
|
|
106
|
+
borderRadius: tokens.cornerRadius,
|
|
101
107
|
}}
|
|
102
108
|
>
|
|
103
109
|
<h3 style={{ fontSize: 17, fontWeight: 600, marginBottom: module.description ? 6 : 0 }}>
|
|
@@ -124,21 +130,21 @@ export function CourseDetail({
|
|
|
124
130
|
className="fixed bottom-0 left-0 right-0 z-40 w-full md:sticky md:top-4 md:bottom-auto md:left-auto md:right-auto md:w-[300px] md:shrink-0 flex flex-col gap-3"
|
|
125
131
|
style={{
|
|
126
132
|
padding: 16,
|
|
127
|
-
border: `1px solid ${
|
|
128
|
-
borderRadius:
|
|
129
|
-
background:
|
|
133
|
+
border: `1px solid ${tokens.primary}80`,
|
|
134
|
+
borderRadius: tokens.cornerRadius,
|
|
135
|
+
background: tokens.surface,
|
|
130
136
|
}}
|
|
131
137
|
>
|
|
132
|
-
<span style={{ fontWeight: 700, fontSize: 22, color:
|
|
138
|
+
<span style={{ fontWeight: 700, fontSize: 22, color: tokens.primary }}>
|
|
133
139
|
{course.compareAtPrice != null && Number(course.compareAtPrice) > Number(course.price) && (
|
|
134
140
|
<span style={{ textDecoration: "line-through", opacity: 0.6, marginRight: 8, fontWeight: 400, fontSize: 16 }}>
|
|
135
141
|
{fmt(Number(course.compareAtPrice))}
|
|
136
142
|
</span>
|
|
137
143
|
)}
|
|
138
144
|
{Number(course.price) <= 0 ? (
|
|
139
|
-
"
|
|
145
|
+
t("forge.course_detail.free")
|
|
140
146
|
) : (
|
|
141
|
-
<PriceDisplay amount={Number(course.price)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={
|
|
147
|
+
<PriceDisplay amount={Number(course.price)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={tokens.text} captionStyle={{ opacity: 0.65 }} />
|
|
142
148
|
)}
|
|
143
149
|
</span>
|
|
144
150
|
{/*
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useGetCourses } from "../../data/queries/useCourses";
|
|
2
2
|
import type { PublicCourse } from "../../types/models";
|
|
3
|
+
import { useForgeT } from "../../i18n";
|
|
3
4
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
4
5
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
5
6
|
import { PriceDisplay } from "../format/PriceDisplay";
|
|
@@ -18,13 +19,14 @@ export interface CoursesGridProps {
|
|
|
18
19
|
/** Themed courses grid, built on `useGetCourses`. */
|
|
19
20
|
export function CoursesGrid({ columns = 3, limit, formatAmount, onSelect, className, style }: CoursesGridProps) {
|
|
20
21
|
const t = useThemeTokens();
|
|
22
|
+
const tr = useForgeT();
|
|
21
23
|
const fmt = useAmountFormatter(formatAmount);
|
|
22
24
|
const pricesIncludeTax = usePricesIncludeTax();
|
|
23
25
|
const { data, isLoading } = useGetCourses({ page: 1 });
|
|
24
26
|
const courses = (data?.data ?? []).slice(0, limit ?? 100);
|
|
25
27
|
|
|
26
28
|
if (isLoading) return <Loading />;
|
|
27
|
-
if (!courses.length) return <p style={{ color: t.text, opacity: 0.7 }}>
|
|
29
|
+
if (!courses.length) return <p style={{ color: t.text, opacity: 0.7 }}>{tr("forge.courses_grid.empty_title")}</p>;
|
|
28
30
|
|
|
29
31
|
const gap = 16;
|
|
30
32
|
const track = `minmax(max(150px, calc((100% - ${(columns - 1) * gap}px) / ${columns})), 1fr)`;
|
|
@@ -73,7 +75,7 @@ export function CoursesGrid({ columns = 3, limit, formatAmount, onSelect, classN
|
|
|
73
75
|
{price > 0 ? (
|
|
74
76
|
<PriceDisplay amount={price} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
|
|
75
77
|
) : (
|
|
76
|
-
"
|
|
78
|
+
tr("forge.courses_grid.free")
|
|
77
79
|
)}
|
|
78
80
|
</div>
|
|
79
81
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { usePublicAuth } from "../../contexts/PublicAuthContext";
|
|
2
|
+
import { useForgeT } from "../../i18n";
|
|
2
3
|
import { currencySymbol, type Currency } from "../../types/models";
|
|
3
4
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
4
5
|
|
|
@@ -18,6 +19,8 @@ export interface CurrencySwitcherProps {
|
|
|
18
19
|
*/
|
|
19
20
|
export function CurrencySwitcher({ className, style }: CurrencySwitcherProps) {
|
|
20
21
|
const t = useThemeTokens();
|
|
22
|
+
// Named `translate` rather than the usual `t`: `t` is already the theme tokens here.
|
|
23
|
+
const translate = useForgeT();
|
|
21
24
|
const { currencies, userSelectedCurrency, setUserSelectedCurrency } = usePublicAuth();
|
|
22
25
|
|
|
23
26
|
if (!currencies || !currencies.supportedCurrencies?.length) return null;
|
|
@@ -25,7 +28,7 @@ export function CurrencySwitcher({ className, style }: CurrencySwitcherProps) {
|
|
|
25
28
|
return (
|
|
26
29
|
<select
|
|
27
30
|
className={className}
|
|
28
|
-
aria-label="
|
|
31
|
+
aria-label={translate("forge.currency_switcher.aria_currency")}
|
|
29
32
|
value={userSelectedCurrency ?? currencies.userCurrency ?? ""}
|
|
30
33
|
onChange={(e) => setUserSelectedCurrency(e.target.value as Currency)}
|
|
31
34
|
style={{
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import type { CSSProperties } from "react";
|
|
2
2
|
import type { AppliedDiscountCoupon } from "../../types/models";
|
|
3
3
|
import type { CouponField } from "../headless/coupon/useCouponField";
|
|
4
|
+
import { useForgeT } from "../../i18n";
|
|
4
5
|
import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
|
|
5
6
|
import { buttonStyle } from "./Button";
|
|
6
7
|
|
|
7
8
|
/** Append an alpha to a 6-digit hex color (mirrors frontend-shared addAlphaToHexCode). */
|
|
8
|
-
const a = (hex: string, alpha: number) =>
|
|
9
|
+
const a = (hex: string, alpha: number) =>
|
|
10
|
+
hex +
|
|
11
|
+
Math.round(alpha * 255)
|
|
12
|
+
.toString(16)
|
|
13
|
+
.padStart(2, "0");
|
|
9
14
|
|
|
10
15
|
export interface DiscountCodeFieldProps {
|
|
11
16
|
coupon: CouponField;
|
|
@@ -32,7 +37,7 @@ export interface DiscountCodeFieldProps {
|
|
|
32
37
|
* Two things it deliberately does NOT do:
|
|
33
38
|
*
|
|
34
39
|
* 1. It never writes its own rejection copy. The backend answers a refused code
|
|
35
|
-
* with the actual reason, already translated
|
|
40
|
+
* with the actual reason, already translated: minimum not met, expired,
|
|
36
41
|
* already used, members only. Collapsing all of that into "invalid code"
|
|
37
42
|
* throws away the only thing that tells the buyer whether to fix the cart,
|
|
38
43
|
* fix the code, or give up.
|
|
@@ -40,14 +45,10 @@ export interface DiscountCodeFieldProps {
|
|
|
40
45
|
* server, including automatic (no-code) discounts, which is the ONLY way a
|
|
41
46
|
* buyer learns one applied.
|
|
42
47
|
*/
|
|
43
|
-
export function DiscountCodeField({
|
|
44
|
-
|
|
45
|
-
fmt,
|
|
46
|
-
disabled,
|
|
47
|
-
mode = "quote",
|
|
48
|
-
promptText = "Have a discount code?",
|
|
49
|
-
}: DiscountCodeFieldProps) {
|
|
48
|
+
export function DiscountCodeField({ coupon, fmt, disabled, mode = "quote", promptText }: DiscountCodeFieldProps) {
|
|
49
|
+
const t = useForgeT();
|
|
50
50
|
const theme = useForgeTheme();
|
|
51
|
+
const prompt = promptText ?? t("forge.discount_code.prompt");
|
|
51
52
|
// Automatic discounts have no code the buyer typed, so they are shown as
|
|
52
53
|
// applied even when the input was never opened.
|
|
53
54
|
const applied = coupon.appliedCoupons;
|
|
@@ -72,16 +73,16 @@ export function DiscountCodeField({
|
|
|
72
73
|
fontFamily: theme.fontFamily,
|
|
73
74
|
}}
|
|
74
75
|
>
|
|
75
|
-
{
|
|
76
|
+
{prompt}
|
|
76
77
|
</button>
|
|
77
78
|
)}
|
|
78
79
|
|
|
79
80
|
{showInput && (
|
|
80
81
|
<div style={{ display: "flex", gap: 8, alignItems: "flex-start" }}>
|
|
81
82
|
<input
|
|
82
|
-
aria-label="
|
|
83
|
+
aria-label={t("forge.discount_code.aria_input")}
|
|
83
84
|
data-testid="discount-code-input"
|
|
84
|
-
placeholder="
|
|
85
|
+
placeholder={t("forge.discount_code.placeholder")}
|
|
85
86
|
value={coupon.code}
|
|
86
87
|
onChange={(e) => coupon.setCode(e.target.value.toUpperCase())}
|
|
87
88
|
style={{ ...inputStyle(theme), flex: 1 }}
|
|
@@ -97,14 +98,14 @@ export function DiscountCodeField({
|
|
|
97
98
|
opacity: coupon.isApplying || !coupon.code.trim() ? 0.6 : 1,
|
|
98
99
|
}}
|
|
99
100
|
>
|
|
100
|
-
{coupon.isApplying ? "
|
|
101
|
+
{coupon.isApplying ? t("forge.discount_code.applying") : t("forge.discount_code.apply")}
|
|
101
102
|
</button>
|
|
102
103
|
)}
|
|
103
104
|
</div>
|
|
104
105
|
)}
|
|
105
106
|
|
|
106
107
|
{showInput && mode === "deferred" && (
|
|
107
|
-
<p style={{ fontSize: 12, opacity: 0.6, margin: 0 }}>
|
|
108
|
+
<p style={{ fontSize: 12, opacity: 0.6, margin: 0 }}>{t("forge.discount_code.deferred_note")}</p>
|
|
108
109
|
)}
|
|
109
110
|
|
|
110
111
|
{coupon.error && (
|
|
@@ -129,7 +130,7 @@ export function DiscountCodeField({
|
|
|
129
130
|
<div>
|
|
130
131
|
{applied.map((c) => (
|
|
131
132
|
<p key={c.code} style={{ margin: 0, fontSize: 14, fontWeight: 600, color: theme.colors.primary }}>
|
|
132
|
-
{c.code
|
|
133
|
+
{t("forge.discount_code.applied_line", { code: c.code, amount: fmt(c.discountAmount) })}
|
|
133
134
|
</p>
|
|
134
135
|
))}
|
|
135
136
|
</div>
|
|
@@ -150,7 +151,7 @@ export function DiscountCodeField({
|
|
|
150
151
|
fontFamily: theme.fontFamily,
|
|
151
152
|
}}
|
|
152
153
|
>
|
|
153
|
-
|
|
154
|
+
{t("forge.discount_code.remove")}
|
|
154
155
|
</button>
|
|
155
156
|
)}
|
|
156
157
|
</div>
|
|
@@ -176,18 +177,21 @@ export interface DiscountSummaryLinesProps {
|
|
|
176
177
|
* working code from a silently-ignored one, and no reason to trust either.
|
|
177
178
|
*/
|
|
178
179
|
export function DiscountSummaryLines({ subTotal, discountAmount, appliedCoupons, fmt }: DiscountSummaryLinesProps) {
|
|
180
|
+
const t = useForgeT();
|
|
179
181
|
const theme = useForgeTheme();
|
|
180
182
|
if (!(discountAmount > 0)) return null;
|
|
181
183
|
const codes = (appliedCoupons ?? []).map((c) => c.code).filter(Boolean);
|
|
182
184
|
return (
|
|
183
185
|
<div data-testid="discount-summary" style={{ display: "flex", flexDirection: "column", gap: 4, marginBottom: 8 }}>
|
|
184
186
|
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 14 }}>
|
|
185
|
-
<span style={{ opacity: 0.75 }}>
|
|
187
|
+
<span style={{ opacity: 0.75 }}>{t("forge.discount_code.subtotal")}</span>
|
|
186
188
|
<span style={{ opacity: 0.75 }}>{fmt(subTotal)}</span>
|
|
187
189
|
</div>
|
|
188
190
|
<div data-testid="discount-line" style={{ display: "flex", justifyContent: "space-between", fontSize: 14 }}>
|
|
189
191
|
<span style={{ color: theme.colors.primary }}>
|
|
190
|
-
|
|
192
|
+
{codes.length > 0
|
|
193
|
+
? t("forge.discount_code.discount_with_codes", { codes: codes.join(", ") })
|
|
194
|
+
: t("forge.discount_code.discount")}
|
|
191
195
|
</span>
|
|
192
196
|
<span style={{ color: theme.colors.primary }}>−{fmt(discountAmount)}</span>
|
|
193
197
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
+
import { useForgeT } from "../../i18n";
|
|
2
3
|
import { Donation, useDonationContext, type DonationPaymentRenderProps } from "../headless/donation";
|
|
3
4
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
5
|
import { Loading } from "./Loading";
|
|
@@ -28,6 +29,7 @@ export interface DonationButtonProps {
|
|
|
28
29
|
*/
|
|
29
30
|
export function DonationButton({ donationId, text, returnUrl, onSuccess, renderPayment, formatAmount }: DonationButtonProps) {
|
|
30
31
|
const theme = useForgeTheme();
|
|
32
|
+
const t = useForgeT();
|
|
31
33
|
const registered = usePaymentRenderer();
|
|
32
34
|
const fmt = useAmountFormatter(formatAmount);
|
|
33
35
|
// Donations confirm in place, so default the registered renderer to "manual".
|
|
@@ -49,7 +51,7 @@ export function DonationButton({ donationId, text, returnUrl, onSuccess, renderP
|
|
|
49
51
|
fontFamily: theme.fontFamily,
|
|
50
52
|
}}
|
|
51
53
|
>
|
|
52
|
-
{text ?? "
|
|
54
|
+
{text ?? t("forge.donation_button.donate")}
|
|
53
55
|
</button>
|
|
54
56
|
</Donation.Trigger>
|
|
55
57
|
|
|
@@ -71,7 +73,7 @@ export function DonationButton({ donationId, text, returnUrl, onSuccess, renderP
|
|
|
71
73
|
fontFamily: theme.fontFamily,
|
|
72
74
|
}}
|
|
73
75
|
>
|
|
74
|
-
<Donation.Title className="">{text ?? "
|
|
76
|
+
<Donation.Title className="">{text ?? t("forge.donation_button.title")}</Donation.Title>
|
|
75
77
|
<DonationBody fmt={fmt} renderPayment={renderPay} />
|
|
76
78
|
</Donation.Modal>
|
|
77
79
|
</Donation>
|
|
@@ -87,6 +89,7 @@ function DonationBody({
|
|
|
87
89
|
}) {
|
|
88
90
|
const c = useDonationContext();
|
|
89
91
|
const theme = useForgeTheme();
|
|
92
|
+
const t = useForgeT();
|
|
90
93
|
// Authoritative tax quote from the donation start-payment (display only).
|
|
91
94
|
const taxSummary = summarizeTaxQuote(c.startResult?.taxQuote);
|
|
92
95
|
const inputStyle = {
|
|
@@ -106,17 +109,19 @@ function DonationBody({
|
|
|
106
109
|
return (
|
|
107
110
|
<div style={{ textAlign: "center", padding: "16px 0" }}>
|
|
108
111
|
<h3 style={{ marginBottom: 8 }}>
|
|
109
|
-
{c.outcome === "succeeded"
|
|
112
|
+
{c.outcome === "succeeded"
|
|
113
|
+
? t("forge.donation_button.result_success_title")
|
|
114
|
+
: t("forge.donation_button.result_failed_title")}
|
|
110
115
|
</h3>
|
|
111
116
|
<p style={{ opacity: 0.8 }}>
|
|
112
117
|
{c.outcome === "succeeded"
|
|
113
|
-
? c.donation?.successMessage || "
|
|
114
|
-
: c.paymentErrorMessage || "
|
|
118
|
+
? c.donation?.successMessage || t("forge.donation_button.success_body")
|
|
119
|
+
: c.paymentErrorMessage || t("forge.donation_button.failed_body")}
|
|
115
120
|
</p>
|
|
116
121
|
<div style={{ display: "flex", gap: 8, marginTop: 16, justifyContent: "center" }}>
|
|
117
122
|
{c.outcome === "failed" && (
|
|
118
123
|
<button onClick={c.reset} style={{ ...inputStyle, width: "auto", cursor: "pointer" }}>
|
|
119
|
-
|
|
124
|
+
{t("forge.donation_button.retry")}
|
|
120
125
|
</button>
|
|
121
126
|
)}
|
|
122
127
|
<Donation.Close asChild>
|
|
@@ -130,7 +135,7 @@ function DonationBody({
|
|
|
130
135
|
cursor: "pointer",
|
|
131
136
|
}}
|
|
132
137
|
>
|
|
133
|
-
|
|
138
|
+
{t("forge.donation_button.close")}
|
|
134
139
|
</button>
|
|
135
140
|
</Donation.Close>
|
|
136
141
|
</div>
|
|
@@ -143,12 +148,14 @@ function DonationBody({
|
|
|
143
148
|
return (
|
|
144
149
|
<div style={{ marginTop: 16 }}>
|
|
145
150
|
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12 }}>
|
|
146
|
-
<span>
|
|
151
|
+
<span>{t("forge.donation_button.total")}</span>
|
|
147
152
|
<strong style={{ color: theme.colors.primary, textAlign: "right" }}>
|
|
148
153
|
{fmt(c.startResult?.totalAmount ?? c.estimatedTotal)}
|
|
149
154
|
{taxSummary && (
|
|
150
155
|
<span style={{ display: "block", fontSize: 12, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}>
|
|
151
|
-
{taxSummary.mode === "inclusive"
|
|
156
|
+
{taxSummary.mode === "inclusive"
|
|
157
|
+
? taxSummary.label
|
|
158
|
+
: t("forge.donation_button.tax_included", { amount: taxSummary.formattedTax })}
|
|
152
159
|
</span>
|
|
153
160
|
)}
|
|
154
161
|
</strong>
|
|
@@ -156,7 +163,7 @@ function DonationBody({
|
|
|
156
163
|
{renderPayment ? (
|
|
157
164
|
<Donation.PaymentStep>{(p) => renderPayment(p)}</Donation.PaymentStep>
|
|
158
165
|
) : (
|
|
159
|
-
<p style={{ opacity: 0.7 }}>
|
|
166
|
+
<p style={{ opacity: 0.7 }}>{t("forge.donation_button.payment_not_configured")}</p>
|
|
160
167
|
)}
|
|
161
168
|
</div>
|
|
162
169
|
);
|
|
@@ -195,7 +202,9 @@ function DonationBody({
|
|
|
195
202
|
)}
|
|
196
203
|
|
|
197
204
|
<label>
|
|
198
|
-
|
|
205
|
+
{c.minimum > 0
|
|
206
|
+
? t("forge.donation_button.custom_amount_min", { amount: fmt(c.minimum) })
|
|
207
|
+
: t("forge.donation_button.custom_amount")}
|
|
199
208
|
<input
|
|
200
209
|
type="number"
|
|
201
210
|
min={c.minimum || 0}
|
|
@@ -212,16 +221,38 @@ function DonationBody({
|
|
|
212
221
|
{c.donation?.coverFeesEnabled && c.effectiveAmount > 0 && (
|
|
213
222
|
<label style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
|
214
223
|
<input type="checkbox" checked={c.coverFees} onChange={(e) => c.setCoverFees(e.target.checked)} />
|
|
215
|
-
<span>
|
|
224
|
+
<span>{t("forge.donation_button.cover_fees", { amount: fmt(c.estimatedFee) })}</span>
|
|
216
225
|
</label>
|
|
217
226
|
)}
|
|
218
227
|
|
|
219
228
|
<div style={{ display: "flex", gap: 8 }}>
|
|
220
|
-
<input
|
|
221
|
-
|
|
229
|
+
<input
|
|
230
|
+
placeholder={t("forge.donation_button.first_name_placeholder")}
|
|
231
|
+
value={c.firstName}
|
|
232
|
+
onChange={(e) => c.setFirstName(e.target.value)}
|
|
233
|
+
style={inputStyle}
|
|
234
|
+
/>
|
|
235
|
+
<input
|
|
236
|
+
placeholder={t("forge.donation_button.last_name_placeholder")}
|
|
237
|
+
value={c.lastName}
|
|
238
|
+
onChange={(e) => c.setLastName(e.target.value)}
|
|
239
|
+
style={inputStyle}
|
|
240
|
+
/>
|
|
222
241
|
</div>
|
|
223
|
-
<input
|
|
224
|
-
|
|
242
|
+
<input
|
|
243
|
+
placeholder={t("forge.donation_button.email_placeholder")}
|
|
244
|
+
type="email"
|
|
245
|
+
value={c.email}
|
|
246
|
+
onChange={(e) => c.setEmail(e.target.value)}
|
|
247
|
+
style={inputStyle}
|
|
248
|
+
/>
|
|
249
|
+
<textarea
|
|
250
|
+
placeholder={t("forge.donation_button.message_placeholder")}
|
|
251
|
+
value={c.message}
|
|
252
|
+
onChange={(e) => c.setMessage(e.target.value)}
|
|
253
|
+
rows={2}
|
|
254
|
+
style={inputStyle}
|
|
255
|
+
/>
|
|
225
256
|
|
|
226
257
|
{c.startError && <p style={{ color: "#ef4444", fontSize: 14 }}>{c.startError}</p>}
|
|
227
258
|
|
|
@@ -239,7 +270,11 @@ function DonationBody({
|
|
|
239
270
|
opacity: c.isStarting ? 0.6 : 1,
|
|
240
271
|
}}
|
|
241
272
|
>
|
|
242
|
-
{c.isStarting
|
|
273
|
+
{c.isStarting
|
|
274
|
+
? t("forge.donation_button.processing")
|
|
275
|
+
: c.effectiveAmount > 0
|
|
276
|
+
? t("forge.donation_button.submit_amount", { amount: fmt(c.estimatedTotal) })
|
|
277
|
+
: t("forge.donation_button.submit")}
|
|
243
278
|
</button>
|
|
244
279
|
</div>
|
|
245
280
|
</Donation.AmountStep>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DonationButton } from "./DonationButton";
|
|
2
2
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
3
|
+
import { useForgeT } from "../../i18n";
|
|
3
4
|
|
|
4
5
|
export interface DonationPageProps {
|
|
5
6
|
/** Donation id from the URL search param (`?id=`). */
|
|
@@ -10,13 +11,12 @@ export interface DonationPageProps {
|
|
|
10
11
|
|
|
11
12
|
/** Standalone donation page — heading + copy wrapping the `DonationButton` flow. */
|
|
12
13
|
export function DonationPage({ donationId, formatAmount }: DonationPageProps) {
|
|
14
|
+
const t = useForgeT();
|
|
13
15
|
const fmt = useAmountFormatter(formatAmount);
|
|
14
16
|
return (
|
|
15
17
|
<div style={{ maxWidth: 520, margin: "0 auto", textAlign: "center" }}>
|
|
16
|
-
<h1 style={{ fontSize: 32, fontWeight: 800, marginBottom: 12 }}>
|
|
17
|
-
<p style={{ opacity: 0.75, marginBottom: 24 }}>
|
|
18
|
-
Your contribution keeps this going. Thank you.
|
|
19
|
-
</p>
|
|
18
|
+
<h1 style={{ fontSize: 32, fontWeight: 800, marginBottom: 12 }}>{t("forge.donation_page.title")}</h1>
|
|
19
|
+
<p style={{ opacity: 0.75, marginBottom: 24 }}>{t("forge.donation_page.subtitle")}</p>
|
|
20
20
|
{donationId ? (
|
|
21
21
|
<DonationButton donationId={donationId} formatAmount={fmt} />
|
|
22
22
|
) : (
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEmailListForm } from "../headless/forms/useEmailListForm";
|
|
2
|
+
import { useForgeT } from "../../i18n";
|
|
2
3
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
3
4
|
import { readableTextOn } from "../theme/contrast";
|
|
4
5
|
|
|
@@ -19,14 +20,21 @@ export function EmailListForm({
|
|
|
19
20
|
leadMagnetId,
|
|
20
21
|
includeName,
|
|
21
22
|
includePhoneNumber,
|
|
22
|
-
title
|
|
23
|
+
title,
|
|
23
24
|
description,
|
|
24
|
-
buttonText
|
|
25
|
-
successMessage
|
|
25
|
+
buttonText,
|
|
26
|
+
successMessage,
|
|
26
27
|
}: EmailListFormProps) {
|
|
28
|
+
const t = useForgeT();
|
|
27
29
|
const theme = useForgeTheme();
|
|
28
30
|
const f = useEmailListForm({ emailListId, leadMagnetId, includeName, includePhoneNumber });
|
|
29
31
|
|
|
32
|
+
// Defaults resolve here rather than in the signature so they can be
|
|
33
|
+
// translated. `??` keeps a caller-supplied "" falsy exactly as before.
|
|
34
|
+
const titleText = title ?? t("forge.email_list_form.title");
|
|
35
|
+
const buttonLabel = buttonText ?? t("forge.email_list_form.submit");
|
|
36
|
+
const successText = successMessage ?? t("forge.email_list_form.success");
|
|
37
|
+
|
|
30
38
|
const inputStyle = {
|
|
31
39
|
width: "100%",
|
|
32
40
|
padding: 10,
|
|
@@ -39,28 +47,33 @@ export function EmailListForm({
|
|
|
39
47
|
if (f.isSuccess) {
|
|
40
48
|
return (
|
|
41
49
|
<div style={{ color: theme.colors.text, fontFamily: theme.fontFamily, textAlign: "center", padding: 8 }}>
|
|
42
|
-
{f.requiresConfirmation ? "
|
|
50
|
+
{f.requiresConfirmation ? t("forge.email_list_form.confirm_required") : successText}
|
|
43
51
|
</div>
|
|
44
52
|
);
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
return (
|
|
48
56
|
<div style={{ display: "flex", flexDirection: "column", gap: 10, color: theme.colors.text, fontFamily: theme.fontFamily }}>
|
|
49
|
-
{
|
|
57
|
+
{titleText && <h3 style={{ fontWeight: 600 }}>{titleText}</h3>}
|
|
50
58
|
{description && <p style={{ opacity: 0.8, fontSize: 14 }}>{description}</p>}
|
|
51
59
|
{includeName && (
|
|
52
|
-
<input
|
|
60
|
+
<input
|
|
61
|
+
placeholder={t("forge.email_list_form.name_placeholder")}
|
|
62
|
+
value={f.name}
|
|
63
|
+
onChange={(e) => f.setName(e.target.value)}
|
|
64
|
+
style={inputStyle}
|
|
65
|
+
/>
|
|
53
66
|
)}
|
|
54
67
|
<input
|
|
55
68
|
type="email"
|
|
56
|
-
placeholder="
|
|
69
|
+
placeholder={t("forge.email_list_form.email_placeholder")}
|
|
57
70
|
value={f.email}
|
|
58
71
|
onChange={(e) => f.setEmail(e.target.value)}
|
|
59
72
|
style={inputStyle}
|
|
60
73
|
/>
|
|
61
74
|
{includePhoneNumber && (
|
|
62
75
|
<input
|
|
63
|
-
placeholder="
|
|
76
|
+
placeholder={t("forge.email_list_form.phone_placeholder")}
|
|
64
77
|
value={f.phoneNumber}
|
|
65
78
|
onChange={(e) => f.setPhoneNumber(e.target.value)}
|
|
66
79
|
style={inputStyle}
|
|
@@ -81,7 +94,7 @@ export function EmailListForm({
|
|
|
81
94
|
opacity: f.isSubmitting ? 0.6 : 1,
|
|
82
95
|
}}
|
|
83
96
|
>
|
|
84
|
-
{f.isSubmitting ? "
|
|
97
|
+
{f.isSubmitting ? t("forge.email_list_form.submitting") : buttonLabel}
|
|
85
98
|
</button>
|
|
86
99
|
</div>
|
|
87
100
|
);
|