@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 { Loading } from "./Loading";
|
|
|
5
5
|
import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
|
|
6
6
|
import { readableTextOn } from "../theme/contrast";
|
|
7
7
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
8
|
+
import { useForgeT } from "../../i18n";
|
|
8
9
|
import { HoldNotice } from "./HoldNotice";
|
|
9
10
|
|
|
10
11
|
export interface OfferButtonProps {
|
|
@@ -20,6 +21,7 @@ export interface OfferButtonProps {
|
|
|
20
21
|
/** One-line themed product-purchase button + modal, built on the `<Offer>` compound. */
|
|
21
22
|
export function OfferButton({ productId, text, finalisePath, onSuccess, renderPayment, formatAmount }: OfferButtonProps) {
|
|
22
23
|
const theme = useForgeTheme();
|
|
24
|
+
const t = useForgeT();
|
|
23
25
|
const registered = usePaymentRenderer();
|
|
24
26
|
const fmt = useAmountFormatter(formatAmount);
|
|
25
27
|
const renderPay =
|
|
@@ -40,7 +42,7 @@ export function OfferButton({ productId, text, finalisePath, onSuccess, renderPa
|
|
|
40
42
|
fontFamily: theme.fontFamily,
|
|
41
43
|
}}
|
|
42
44
|
>
|
|
43
|
-
{text ?? "
|
|
45
|
+
{text ?? t("forge.offer_button.buy_now")}
|
|
44
46
|
</button>
|
|
45
47
|
</Offer.Trigger>
|
|
46
48
|
|
|
@@ -77,6 +79,7 @@ function OfferBody({
|
|
|
77
79
|
}) {
|
|
78
80
|
const c = useOfferContext();
|
|
79
81
|
const theme = useForgeTheme();
|
|
82
|
+
const t = useForgeT();
|
|
80
83
|
const inputStyle = {
|
|
81
84
|
width: "100%",
|
|
82
85
|
padding: 10,
|
|
@@ -87,7 +90,7 @@ function OfferBody({
|
|
|
87
90
|
} as const;
|
|
88
91
|
|
|
89
92
|
if (c.isLoading) return <Loading />;
|
|
90
|
-
if (!c.product) return <p style={{ marginTop: 8 }}>
|
|
93
|
+
if (!c.product) return <p style={{ marginTop: 8 }}>{t("forge.offer_button.not_found")}</p>;
|
|
91
94
|
|
|
92
95
|
// ── Payment (redirect) ──
|
|
93
96
|
if (c.step === "payment") {
|
|
@@ -95,7 +98,7 @@ function OfferBody({
|
|
|
95
98
|
<div>
|
|
96
99
|
<Offer.Title className="">{c.product.title}</Offer.Title>
|
|
97
100
|
<div style={{ display: "flex", justifyContent: "space-between", margin: "12px 0" }}>
|
|
98
|
-
<span>
|
|
101
|
+
<span>{t("forge.offer_button.total")}</span>
|
|
99
102
|
<strong style={{ color: theme.colors.primary }}>{fmt(c.totalPrice)}</strong>
|
|
100
103
|
</div>
|
|
101
104
|
{/* The reservation, counted down while the buyer pays. Nothing is drawn
|
|
@@ -110,7 +113,7 @@ function OfferBody({
|
|
|
110
113
|
{renderPayment ? (
|
|
111
114
|
<Offer.PaymentStep>{(p) => renderPayment(p)}</Offer.PaymentStep>
|
|
112
115
|
) : (
|
|
113
|
-
<p style={{ opacity: 0.7 }}>
|
|
116
|
+
<p style={{ opacity: 0.7 }}>{t("forge.offer_button.payment_unavailable")}</p>
|
|
114
117
|
)}
|
|
115
118
|
</div>
|
|
116
119
|
);
|
|
@@ -123,7 +126,7 @@ function OfferBody({
|
|
|
123
126
|
<div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 12 }}>
|
|
124
127
|
{c.variants.length > 1 && (
|
|
125
128
|
<label>
|
|
126
|
-
|
|
129
|
+
{t("forge.offer_button.option_label")}
|
|
127
130
|
<select
|
|
128
131
|
value={c.selectedVariant?.id ?? ""}
|
|
129
132
|
onChange={(e) => c.setSelectedVariantId(e.target.value)}
|
|
@@ -131,7 +134,8 @@ function OfferBody({
|
|
|
131
134
|
>
|
|
132
135
|
{c.variants.map((v) => (
|
|
133
136
|
<option key={v.id} value={v.id}>
|
|
134
|
-
{v.title || [v.color, v.size].filter(Boolean).join(" / ") || "
|
|
137
|
+
{v.title || [v.color, v.size].filter(Boolean).join(" / ") || t("forge.offer_button.variant_default")}{" "}
|
|
138
|
+
· {fmt(v.price)}
|
|
135
139
|
</option>
|
|
136
140
|
))}
|
|
137
141
|
</select>
|
|
@@ -139,7 +143,7 @@ function OfferBody({
|
|
|
139
143
|
)}
|
|
140
144
|
|
|
141
145
|
<label>
|
|
142
|
-
|
|
146
|
+
{t("forge.offer_button.quantity_label")}
|
|
143
147
|
<select value={c.quantity} onChange={(e) => c.setQuantity(Number(e.target.value))} style={inputStyle}>
|
|
144
148
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((n) => (
|
|
145
149
|
<option key={n} value={n}>
|
|
@@ -150,12 +154,28 @@ function OfferBody({
|
|
|
150
154
|
</label>
|
|
151
155
|
|
|
152
156
|
<div style={{ display: "flex", gap: 8 }}>
|
|
153
|
-
<input
|
|
154
|
-
|
|
157
|
+
<input
|
|
158
|
+
placeholder={t("forge.offer_button.first_name")}
|
|
159
|
+
value={c.firstName}
|
|
160
|
+
onChange={(e) => c.setFirstName(e.target.value)}
|
|
161
|
+
style={inputStyle}
|
|
162
|
+
/>
|
|
163
|
+
<input
|
|
164
|
+
placeholder={t("forge.offer_button.last_name")}
|
|
165
|
+
value={c.lastName}
|
|
166
|
+
onChange={(e) => c.setLastName(e.target.value)}
|
|
167
|
+
style={inputStyle}
|
|
168
|
+
/>
|
|
155
169
|
</div>
|
|
156
|
-
<input placeholder="Email" type="email" value={c.email} onChange={(e) => c.setEmail(e.target.value)} style={inputStyle} />
|
|
157
170
|
<input
|
|
158
|
-
placeholder="
|
|
171
|
+
placeholder={t("forge.offer_button.email")}
|
|
172
|
+
type="email"
|
|
173
|
+
value={c.email}
|
|
174
|
+
onChange={(e) => c.setEmail(e.target.value)}
|
|
175
|
+
style={inputStyle}
|
|
176
|
+
/>
|
|
177
|
+
<input
|
|
178
|
+
placeholder={t("forge.offer_button.confirm_email")}
|
|
159
179
|
type="email"
|
|
160
180
|
value={c.confirmEmail}
|
|
161
181
|
onChange={(e) => c.setConfirmEmail(e.target.value)}
|
|
@@ -163,7 +183,7 @@ function OfferBody({
|
|
|
163
183
|
/>
|
|
164
184
|
|
|
165
185
|
<div style={{ display: "flex", justifyContent: "space-between", fontWeight: 600 }}>
|
|
166
|
-
<span>
|
|
186
|
+
<span>{t("forge.offer_button.total")}</span>
|
|
167
187
|
<span style={{ color: theme.colors.primary }}>{fmt(c.totalPrice)}</span>
|
|
168
188
|
</div>
|
|
169
189
|
|
|
@@ -196,7 +216,7 @@ function OfferBody({
|
|
|
196
216
|
opacity: c.isProcessing ? 0.6 : 1,
|
|
197
217
|
}}
|
|
198
218
|
>
|
|
199
|
-
{c.isProcessing ? "
|
|
219
|
+
{c.isProcessing ? t("forge.offer_button.processing") : t("forge.offer_button.continue_to_payment")}
|
|
200
220
|
</button>
|
|
201
221
|
</div>
|
|
202
222
|
</Offer.DetailsStep>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useForgeT } from "../../i18n";
|
|
1
2
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
2
3
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
3
4
|
import { Loading } from "./Loading";
|
|
@@ -20,30 +21,35 @@ export function PaymentLinkConfirmation({
|
|
|
20
21
|
paymentPath = `/i/payment-link/${paymentLinkId}`,
|
|
21
22
|
}: PaymentLinkConfirmationProps) {
|
|
22
23
|
const theme = useForgeTheme();
|
|
24
|
+
const t = useForgeT();
|
|
23
25
|
const fmt = useAmountFormatter(formatAmount);
|
|
24
26
|
const { data, isLoading, error } = usePaymentLinkFinalize(paymentLinkId);
|
|
25
27
|
|
|
26
28
|
if (isLoading) return <Loading fullPage />;
|
|
27
|
-
if (error || !data)
|
|
29
|
+
if (error || !data)
|
|
30
|
+
return <p style={{ color: theme.colors.text }}>{t("forge.payment_link_confirmation.load_error")}</p>;
|
|
28
31
|
|
|
29
32
|
const paid = data.status === "paid" || !!data.paidAt;
|
|
30
33
|
|
|
31
34
|
return (
|
|
32
35
|
<div style={{ maxWidth: 520, margin: "0 auto", textAlign: "center", color: theme.colors.text }}>
|
|
33
36
|
<h1 style={{ fontSize: 28, fontWeight: 800, marginBottom: 8 }}>
|
|
34
|
-
{paid ? "
|
|
37
|
+
{paid ? t("forge.payment_link_confirmation.paid_title") : t("forge.payment_link_confirmation.unpaid_title")}
|
|
35
38
|
</h1>
|
|
36
39
|
<p style={{ opacity: 0.75, marginBottom: 16 }}>
|
|
37
40
|
{paid
|
|
38
|
-
?
|
|
39
|
-
|
|
41
|
+
? t("forge.payment_link_confirmation.paid_body", {
|
|
42
|
+
recipient_name: data.recipientName,
|
|
43
|
+
profile_name: data.profileName,
|
|
44
|
+
})
|
|
45
|
+
: t("forge.payment_link_confirmation.unpaid_body", { status: data.status })}
|
|
40
46
|
</p>
|
|
41
47
|
<p style={{ fontWeight: 700, color: theme.colors.primary }}>{fmt(Number(data.amount), data.currency)}</p>
|
|
42
48
|
<a
|
|
43
49
|
href={paymentPath}
|
|
44
50
|
style={{ display: "inline-block", marginTop: 24, color: theme.colors.primary, textDecoration: "underline" }}
|
|
45
51
|
>
|
|
46
|
-
|
|
52
|
+
{t("forge.payment_link_confirmation.back_to_payment")}
|
|
47
53
|
</a>
|
|
48
54
|
</div>
|
|
49
55
|
);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useForgeT } from "../../i18n";
|
|
1
2
|
import { usePaymentLinkPayment } from "../headless/paymentLink/usePaymentLinkPayment";
|
|
2
3
|
import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
|
|
3
4
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
@@ -20,6 +21,7 @@ export interface PaymentLinkPaymentProps {
|
|
|
20
21
|
* (`usePaymentRenderer`).
|
|
21
22
|
*/
|
|
22
23
|
export function PaymentLinkPayment({ paymentLinkId, formatAmount, returnPath }: PaymentLinkPaymentProps) {
|
|
24
|
+
const t = useForgeT();
|
|
23
25
|
const theme = useForgeTheme();
|
|
24
26
|
const fmt = useAmountFormatter(formatAmount);
|
|
25
27
|
const renderPayment = usePaymentRenderer();
|
|
@@ -27,7 +29,8 @@ export function PaymentLinkPayment({ paymentLinkId, formatAmount, returnPath }:
|
|
|
27
29
|
usePaymentLinkPayment(paymentLinkId, { returnPath });
|
|
28
30
|
|
|
29
31
|
if (link.isLoading) return <Loading fullPage />;
|
|
30
|
-
if (link.error || !link.data)
|
|
32
|
+
if (link.error || !link.data)
|
|
33
|
+
return <p style={{ color: theme.colors.text }}>{t("forge.payment_link_payment.not_found")}</p>;
|
|
31
34
|
|
|
32
35
|
const data = link.data;
|
|
33
36
|
const isPaid = data.status === "paid" || !!data.paidAt;
|
|
@@ -35,8 +38,10 @@ export function PaymentLinkPayment({ paymentLinkId, formatAmount, returnPath }:
|
|
|
35
38
|
|
|
36
39
|
return (
|
|
37
40
|
<div style={{ maxWidth: 520, margin: "0 auto", color: theme.colors.text }}>
|
|
38
|
-
<h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 4 }}>
|
|
39
|
-
<p style={{ opacity: 0.7, fontSize: 14, marginBottom: 16 }}>
|
|
41
|
+
<h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 4 }}>{t("forge.payment_link_payment.title")}</h1>
|
|
42
|
+
<p style={{ opacity: 0.7, fontSize: 14, marginBottom: 16 }}>
|
|
43
|
+
{t("forge.payment_link_payment.from_profile", { profile_name: data.profileName })}
|
|
44
|
+
</p>
|
|
40
45
|
|
|
41
46
|
<div
|
|
42
47
|
style={{
|
|
@@ -53,28 +58,37 @@ export function PaymentLinkPayment({ paymentLinkId, formatAmount, returnPath }:
|
|
|
53
58
|
const taxSummary = summarizeTaxQuote(taxQuote, money);
|
|
54
59
|
return taxSummary ? (
|
|
55
60
|
<span style={{ display: "block", fontSize: 13, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}>
|
|
56
|
-
{taxSummary.mode === "inclusive"
|
|
61
|
+
{taxSummary.mode === "inclusive"
|
|
62
|
+
? taxSummary.label
|
|
63
|
+
: t("forge.payment_link_payment.tax_added", { tax_amount: taxSummary.formattedTax })}
|
|
57
64
|
</span>
|
|
58
65
|
) : null;
|
|
59
66
|
})()}
|
|
60
67
|
</div>
|
|
61
|
-
<div style={{ fontSize: 14, opacity: 0.8, marginTop: 8 }}>
|
|
68
|
+
<div style={{ fontSize: 14, opacity: 0.8, marginTop: 8 }}>
|
|
69
|
+
{t("forge.payment_link_payment.billed_to", { recipient_name: data.recipientName })}
|
|
70
|
+
</div>
|
|
62
71
|
{data.message && <p style={{ marginTop: 12, lineHeight: 1.6, opacity: 0.9 }}>{data.message}</p>}
|
|
63
72
|
</div>
|
|
64
73
|
|
|
65
74
|
{isPaid ? (
|
|
66
|
-
<p style={{ fontWeight: 700, color: theme.colors.primary }}>
|
|
75
|
+
<p style={{ fontWeight: 700, color: theme.colors.primary }}>{t("forge.payment_link_payment.paid_notice")}</p>
|
|
67
76
|
) : (
|
|
68
77
|
<div>
|
|
69
|
-
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>
|
|
78
|
+
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>
|
|
79
|
+
{t("forge.payment_link_payment.payment_heading")}
|
|
80
|
+
</h2>
|
|
70
81
|
{error && <p style={{ color: "#ef4444", fontSize: 14, marginBottom: 8 }}>{error}</p>}
|
|
71
82
|
{/* Paystack brings its own modal, so it is checked before the Stripe
|
|
72
83
|
renderer and needs none of it. */}
|
|
73
84
|
{isPaystack ? (
|
|
74
85
|
canOpenPaystack ? (
|
|
75
|
-
<PaystackPayButton
|
|
86
|
+
<PaystackPayButton
|
|
87
|
+
onPay={openPaystackCheckout}
|
|
88
|
+
label={t("forge.payment_link_payment.pay_amount", { amount: money(Number(data.amount)) })}
|
|
89
|
+
/>
|
|
76
90
|
) : (
|
|
77
|
-
<Loading label="
|
|
91
|
+
<Loading label={t("forge.payment_link_payment.preparing_payment")} size={22} />
|
|
78
92
|
)
|
|
79
93
|
) : clientSecret ? (
|
|
80
94
|
renderPayment?.({
|
|
@@ -84,7 +98,13 @@ export function PaymentLinkPayment({ paymentLinkId, formatAmount, returnPath }:
|
|
|
84
98
|
mode: "redirect",
|
|
85
99
|
})
|
|
86
100
|
) : (
|
|
87
|
-
<>
|
|
101
|
+
<>
|
|
102
|
+
{isStarting ? (
|
|
103
|
+
<Loading label={t("forge.payment_link_payment.preparing_payment")} size={22} />
|
|
104
|
+
) : (
|
|
105
|
+
<p style={{ opacity: 0.7 }}>{t("forge.payment_link_payment.payment_unavailable")}</p>
|
|
106
|
+
)}
|
|
107
|
+
</>
|
|
88
108
|
)}
|
|
89
109
|
</div>
|
|
90
110
|
)}
|
|
@@ -2,10 +2,11 @@ import { Play, Pause } from "lucide-react";
|
|
|
2
2
|
import { usePodcast, findEpisode, episodeAudio } from "../../data/queries/usePodcast";
|
|
3
3
|
import { usePodcastPlayer } from "../headless/podcast/usePodcastPlayer";
|
|
4
4
|
import type { BlogPost, BlogCategory } from "../../types/models";
|
|
5
|
+
import { useForgeT } from "../../i18n";
|
|
5
6
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
6
7
|
import { Loading } from "./Loading";
|
|
7
8
|
// Baseline `.rich-text` content styles ship with Forge (WYSIWYG with the admin
|
|
8
|
-
// editor)
|
|
9
|
+
// editor), so no host-loaded stylesheet is needed.
|
|
9
10
|
import "./rich-text.css";
|
|
10
11
|
|
|
11
12
|
export interface PodcastEpisodeProps {
|
|
@@ -28,7 +29,7 @@ function fmt(sec?: number | null) {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
|
-
* Single-episode page
|
|
32
|
+
* Single-episode page. Plays this episode and offers Previous / Next episode
|
|
32
33
|
* buttons. Shares the queue + player with the show page (persistent playback).
|
|
33
34
|
* Built on `usePodcast` + `usePodcastPlayer`.
|
|
34
35
|
*/
|
|
@@ -40,6 +41,7 @@ export function PodcastEpisode({
|
|
|
40
41
|
initialShow,
|
|
41
42
|
initialEpisodes,
|
|
42
43
|
}: PodcastEpisodeProps) {
|
|
44
|
+
const t = useForgeT();
|
|
43
45
|
const theme = useForgeTheme();
|
|
44
46
|
const { show, episodes, isLoading } = usePodcast(showSlug, {
|
|
45
47
|
initialShow: initialShow ?? undefined,
|
|
@@ -49,7 +51,7 @@ export function PodcastEpisode({
|
|
|
49
51
|
const { episode, previous, next } = findEpisode(episodes, episodeSlug);
|
|
50
52
|
|
|
51
53
|
if (isLoading) return <Loading fullPage />;
|
|
52
|
-
if (!episode) return <p style={{ color: theme.colors.text }}>
|
|
54
|
+
if (!episode) return <p style={{ color: theme.colors.text }}>{t("forge.podcast_episode.not_found")}</p>;
|
|
53
55
|
|
|
54
56
|
const border = `${theme.colors.text}1a`;
|
|
55
57
|
const dur = episodeAudio(episode)?.durationSec;
|
|
@@ -58,7 +60,7 @@ export function PodcastEpisode({
|
|
|
58
60
|
return (
|
|
59
61
|
<article style={{ maxWidth: 720, margin: "0 auto", padding: "40px 20px", color: theme.colors.text }}>
|
|
60
62
|
<a href={showHref(showSlug)} style={{ color: theme.colors.primary, textDecoration: "none", fontSize: 14 }}>
|
|
61
|
-
← {show?.title ?? "
|
|
63
|
+
← {show?.title ?? t("forge.podcast_episode.show_fallback")}
|
|
62
64
|
</a>
|
|
63
65
|
|
|
64
66
|
<h1 style={{ fontSize: 26, fontWeight: 800, marginTop: 12 }}>{episode.title}</h1>
|
|
@@ -85,7 +87,7 @@ export function PodcastEpisode({
|
|
|
85
87
|
}}
|
|
86
88
|
>
|
|
87
89
|
{playing ? <Pause size={18} /> : <Play size={18} />}
|
|
88
|
-
{playing ? "
|
|
90
|
+
{playing ? t("forge.podcast_episode.pause") : t("forge.podcast_episode.play")}
|
|
89
91
|
</button>
|
|
90
92
|
{player.isCurrent(episode) && (
|
|
91
93
|
<span style={{ fontSize: 13, opacity: 0.6 }}>
|
|
@@ -94,7 +96,7 @@ export function PodcastEpisode({
|
|
|
94
96
|
)}
|
|
95
97
|
</div>
|
|
96
98
|
|
|
97
|
-
{/* Show notes
|
|
99
|
+
{/* Show notes. `rich-text` = shared baseline content styles (WYSIWYG
|
|
98
100
|
with the editor), bundled with Forge via the ./rich-text.css import. */}
|
|
99
101
|
<div
|
|
100
102
|
className="rich-text"
|
|
@@ -103,10 +105,19 @@ export function PodcastEpisode({
|
|
|
103
105
|
/>
|
|
104
106
|
|
|
105
107
|
{/* Prev / Next episode navigation */}
|
|
106
|
-
<nav
|
|
108
|
+
<nav
|
|
109
|
+
style={{
|
|
110
|
+
display: "flex",
|
|
111
|
+
justifyContent: "space-between",
|
|
112
|
+
gap: 12,
|
|
113
|
+
marginTop: 32,
|
|
114
|
+
borderTop: `1px solid ${border}`,
|
|
115
|
+
paddingTop: 18,
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
107
118
|
{previous ? (
|
|
108
119
|
<a href={episodeHref(previous.slug)} style={navLink(theme)}>
|
|
109
|
-
<span style={{ opacity: 0.55, fontSize: 12 }}
|
|
120
|
+
<span style={{ opacity: 0.55, fontSize: 12 }}>{t("forge.podcast_episode.previous")}</span>
|
|
110
121
|
<span style={{ fontWeight: 600 }}>{previous.title}</span>
|
|
111
122
|
</a>
|
|
112
123
|
) : (
|
|
@@ -114,7 +125,7 @@ export function PodcastEpisode({
|
|
|
114
125
|
)}
|
|
115
126
|
{next ? (
|
|
116
127
|
<a href={episodeHref(next.slug)} style={{ ...navLink(theme), textAlign: "right", alignItems: "flex-end" }}>
|
|
117
|
-
<span style={{ opacity: 0.55, fontSize: 12 }}>
|
|
128
|
+
<span style={{ opacity: 0.55, fontSize: 12 }}>{t("forge.podcast_episode.next")}</span>
|
|
118
129
|
<span style={{ fontWeight: 600 }}>{next.title}</span>
|
|
119
130
|
</a>
|
|
120
131
|
) : (
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { usePodcasts } from "../../data/queries/usePodcast";
|
|
2
2
|
import type { PodcastShow } from "../../types/models";
|
|
3
|
+
import { useForgeT } from "../../i18n";
|
|
3
4
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
5
|
import { Loading } from "./Loading";
|
|
5
6
|
|
|
@@ -13,16 +14,19 @@ export interface PodcastListProps {
|
|
|
13
14
|
/** Podcast index — grid of the creator's shows. Built on `usePodcasts`. */
|
|
14
15
|
export function PodcastList({ showHref = (s) => `/i/podcasts/${s}`, initialShows }: PodcastListProps) {
|
|
15
16
|
const theme = useForgeTheme();
|
|
17
|
+
const t = useForgeT();
|
|
16
18
|
const { data: shows, isLoading } = usePodcasts({ initialData: initialShows ?? undefined });
|
|
17
19
|
|
|
18
20
|
if (isLoading) return <Loading fullPage />;
|
|
19
21
|
const list = shows ?? [];
|
|
20
|
-
if (!list.length)
|
|
22
|
+
if (!list.length) {
|
|
23
|
+
return <p style={{ opacity: 0.7, color: theme.colors.text }}>{t("forge.podcast_list.empty_title")}</p>;
|
|
24
|
+
}
|
|
21
25
|
|
|
22
26
|
return (
|
|
23
27
|
<div style={{ maxWidth: 960, margin: "0 auto", padding: "40px 20px", color: theme.colors.text }}>
|
|
24
28
|
<header style={{ marginBottom: 28 }}>
|
|
25
|
-
<h1 style={{ fontSize: 30, fontWeight: 800 }}>
|
|
29
|
+
<h1 style={{ fontSize: 30, fontWeight: 800 }}>{t("forge.podcast_list.title")}</h1>
|
|
26
30
|
</header>
|
|
27
31
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 24 }}>
|
|
28
32
|
{list.map((show) => (
|
|
@@ -47,7 +51,9 @@ export function PodcastList({ showHref = (s) => `/i/podcasts/${s}`, initialShows
|
|
|
47
51
|
<h3 style={{ fontWeight: 700 }}>{show.title}</h3>
|
|
48
52
|
{show.author && <p style={{ fontSize: 13, opacity: 0.6, marginTop: 2 }}>{show.author}</p>}
|
|
49
53
|
<p style={{ fontSize: 12, opacity: 0.5, marginTop: 6 }}>
|
|
50
|
-
{show.episodeCount
|
|
54
|
+
{show.episodeCount === 1
|
|
55
|
+
? t("forge.podcast_list.episode_count_one", { count: show.episodeCount })
|
|
56
|
+
: t("forge.podcast_list.episode_count_other", { count: show.episodeCount })}
|
|
51
57
|
</p>
|
|
52
58
|
</div>
|
|
53
59
|
</a>
|
|
@@ -4,6 +4,7 @@ import { usePodcastPlayer } from "../headless/podcast/usePodcastPlayer";
|
|
|
4
4
|
import type { BlogPost, BlogCategory } from "../../types/models";
|
|
5
5
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
6
6
|
import { Loading } from "./Loading";
|
|
7
|
+
import { useForgeT } from "../../i18n";
|
|
7
8
|
|
|
8
9
|
export interface PodcastShowProps {
|
|
9
10
|
/** Show (podcast category) slug. */
|
|
@@ -28,6 +29,7 @@ function fmt(sec?: number | null) {
|
|
|
28
29
|
* Built on `usePodcast` + `usePodcastPlayer` (the shared AudioPlayer + queue).
|
|
29
30
|
*/
|
|
30
31
|
export function PodcastShow({ slug, episodeHref = (s) => `/i/podcasts/${slug}/${s}`, initialShow, initialEpisodes }: PodcastShowProps) {
|
|
32
|
+
const t = useForgeT();
|
|
31
33
|
const theme = useForgeTheme();
|
|
32
34
|
const { show, episodes, isLoading } = usePodcast(slug, {
|
|
33
35
|
initialShow: initialShow ?? undefined,
|
|
@@ -36,7 +38,7 @@ export function PodcastShow({ slug, episodeHref = (s) => `/i/podcasts/${slug}/${
|
|
|
36
38
|
const player = usePodcastPlayer({ episodes, show });
|
|
37
39
|
|
|
38
40
|
if (isLoading) return <Loading fullPage />;
|
|
39
|
-
if (!show) return <p>
|
|
41
|
+
if (!show) return <p>{t("forge.podcast_show.not_found")}</p>;
|
|
40
42
|
|
|
41
43
|
const cover = show.coverImage?.url;
|
|
42
44
|
const nowPlaying = player.currentEpisode ?? episodes[0] ?? null;
|
|
@@ -73,16 +75,30 @@ export function PodcastShow({ slug, episodeHref = (s) => `/i/podcasts/${slug}/${
|
|
|
73
75
|
boxShadow: "0 4px 16px rgba(0,0,0,0.06)",
|
|
74
76
|
}}
|
|
75
77
|
>
|
|
76
|
-
<div style={{ fontSize: 13, opacity: 0.6 }}>
|
|
78
|
+
<div style={{ fontSize: 13, opacity: 0.6 }}>{t("forge.podcast_show.now_playing")}</div>
|
|
77
79
|
<div style={{ fontWeight: 600, margin: "2px 0 10px" }}>{nowPlaying.title}</div>
|
|
78
80
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
79
|
-
<button
|
|
81
|
+
<button
|
|
82
|
+
onClick={player.playPrevious}
|
|
83
|
+
disabled={!player.hasPrevious}
|
|
84
|
+
aria-label={t("forge.podcast_show.aria_previous")}
|
|
85
|
+
style={iconBtn(theme, !player.hasPrevious)}
|
|
86
|
+
>
|
|
80
87
|
<SkipBack size={18} />
|
|
81
88
|
</button>
|
|
82
|
-
<button
|
|
89
|
+
<button
|
|
90
|
+
onClick={() => player.toggleEpisode(nowPlaying)}
|
|
91
|
+
aria-label={t("forge.podcast_show.aria_play_pause")}
|
|
92
|
+
style={{ ...iconBtn(theme, false), background: theme.colors.primary, color: theme.colors.background, width: 44, height: 44 }}
|
|
93
|
+
>
|
|
83
94
|
{player.isEpisodePlaying(nowPlaying) ? <Pause size={20} /> : <Play size={20} />}
|
|
84
95
|
</button>
|
|
85
|
-
<button
|
|
96
|
+
<button
|
|
97
|
+
onClick={player.playNext}
|
|
98
|
+
disabled={!player.hasNext}
|
|
99
|
+
aria-label={t("forge.podcast_show.aria_next")}
|
|
100
|
+
style={iconBtn(theme, !player.hasNext)}
|
|
101
|
+
>
|
|
86
102
|
<SkipForward size={18} />
|
|
87
103
|
</button>
|
|
88
104
|
<div style={{ flex: 1 }}>
|
|
@@ -125,7 +141,13 @@ export function PodcastShow({ slug, episodeHref = (s) => `/i/podcasts/${slug}/${
|
|
|
125
141
|
borderBottom: `1px solid ${border}`,
|
|
126
142
|
}}
|
|
127
143
|
>
|
|
128
|
-
<button
|
|
144
|
+
<button
|
|
145
|
+
onClick={() => player.toggleEpisode(ep)}
|
|
146
|
+
aria-label={
|
|
147
|
+
player.isEpisodePlaying(ep) ? t("forge.podcast_show.aria_pause_episode") : t("forge.podcast_show.aria_play_episode")
|
|
148
|
+
}
|
|
149
|
+
style={{ ...iconBtn(theme, false), flexShrink: 0 }}
|
|
150
|
+
>
|
|
129
151
|
{player.isEpisodePlaying(ep) ? <Pause size={16} /> : <Play size={16} />}
|
|
130
152
|
</button>
|
|
131
153
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
@@ -140,7 +162,7 @@ export function PodcastShow({ slug, episodeHref = (s) => `/i/podcasts/${slug}/${
|
|
|
140
162
|
</div>
|
|
141
163
|
);
|
|
142
164
|
})}
|
|
143
|
-
{!episodes.length && <p style={{ opacity: 0.7 }}>
|
|
165
|
+
{!episodes.length && <p style={{ opacity: 0.7 }}>{t("forge.podcast_show.empty_title")}</p>}
|
|
144
166
|
</div>
|
|
145
167
|
</div>
|
|
146
168
|
);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useGetPosts } from "../../data/queries/usePosts";
|
|
2
2
|
import type { IPublicPost } from "../../types/models";
|
|
3
|
+
import { useForgeT } from "../../i18n";
|
|
3
4
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
4
5
|
import { Loading } from "./Loading";
|
|
5
6
|
|
|
@@ -13,12 +14,13 @@ export interface PostsFeedProps {
|
|
|
13
14
|
|
|
14
15
|
/** Themed posts grid, built on `useGetPosts`. */
|
|
15
16
|
export function PostsFeed({ columns = 3, limit, onSelect, className, style }: PostsFeedProps) {
|
|
16
|
-
const t =
|
|
17
|
+
const t = useForgeT();
|
|
18
|
+
const tokens = useThemeTokens();
|
|
17
19
|
const { data, isLoading } = useGetPosts({ page: 1 });
|
|
18
20
|
const posts = (data?.data ?? []).slice(0, limit ?? 100);
|
|
19
21
|
|
|
20
22
|
if (isLoading) return <Loading />;
|
|
21
|
-
if (!posts.length) return <p style={{ color:
|
|
23
|
+
if (!posts.length) return <p style={{ color: tokens.text, opacity: 0.7 }}>{t("forge.posts_feed.empty_body")}</p>;
|
|
22
24
|
|
|
23
25
|
const gap = 16;
|
|
24
26
|
const track = `minmax(max(150px, calc((100% - ${(columns - 1) * gap}px) / ${columns})), 1fr)`;
|
|
@@ -30,7 +32,7 @@ export function PostsFeed({ columns = 3, limit, onSelect, className, style }: Po
|
|
|
30
32
|
display: "grid",
|
|
31
33
|
gridTemplateColumns: `repeat(auto-fill, ${track})`,
|
|
32
34
|
gap,
|
|
33
|
-
fontFamily:
|
|
35
|
+
fontFamily: tokens.fontFamily,
|
|
34
36
|
...style,
|
|
35
37
|
}}
|
|
36
38
|
>
|
|
@@ -43,15 +45,15 @@ export function PostsFeed({ columns = 3, limit, onSelect, className, style }: Po
|
|
|
43
45
|
style={{
|
|
44
46
|
textAlign: "left",
|
|
45
47
|
cursor: onSelect ? "pointer" : "default",
|
|
46
|
-
background:
|
|
47
|
-
border: `1px solid ${
|
|
48
|
-
borderRadius:
|
|
48
|
+
background: tokens.surface,
|
|
49
|
+
border: `1px solid ${tokens.primary}20`,
|
|
50
|
+
borderRadius: tokens.cornerRadius,
|
|
49
51
|
overflow: "hidden",
|
|
50
52
|
padding: 0,
|
|
51
|
-
color:
|
|
53
|
+
color: tokens.text,
|
|
52
54
|
}}
|
|
53
55
|
>
|
|
54
|
-
<div style={{ aspectRatio: "1 / 1", background: `${
|
|
56
|
+
<div style={{ aspectRatio: "1 / 1", background: `${tokens.primary}10` }}>
|
|
55
57
|
{cover && <img src={cover} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />}
|
|
56
58
|
</div>
|
|
57
59
|
<div style={{ padding: 10, fontSize: 14 }}>{post.caption?.slice(0, 100)}</div>
|
|
@@ -2,6 +2,7 @@ import type { CSSProperties } from "react";
|
|
|
2
2
|
import { KeyRound } from "lucide-react";
|
|
3
3
|
import type { PresaleCodeField } from "../headless/event/usePresaleCode";
|
|
4
4
|
import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
|
|
5
|
+
import { useForgeT } from "../../i18n";
|
|
5
6
|
import { buttonStyle } from "./Button";
|
|
6
7
|
|
|
7
8
|
/** Append an alpha to a 6-digit hex color (mirrors frontend-shared addAlphaToHexCode). */
|
|
@@ -38,13 +39,9 @@ export interface PresaleCodeFieldProps {
|
|
|
38
39
|
* names no tier and confirms no partial guess; a box that said "that isn't
|
|
39
40
|
* the VIP code" would be a way to enumerate them.
|
|
40
41
|
*/
|
|
41
|
-
export function PresaleCodeField({
|
|
42
|
-
presale,
|
|
43
|
-
promptText = "Have a presale code?",
|
|
44
|
-
className,
|
|
45
|
-
style,
|
|
46
|
-
}: PresaleCodeFieldProps) {
|
|
42
|
+
export function PresaleCodeField({ presale, promptText, className, style }: PresaleCodeFieldProps) {
|
|
47
43
|
const theme = useForgeTheme();
|
|
44
|
+
const t = useForgeT();
|
|
48
45
|
if (!presale.visible) return null;
|
|
49
46
|
|
|
50
47
|
const isChecking = presale.status === "checking";
|
|
@@ -78,7 +75,7 @@ export function PresaleCodeField({
|
|
|
78
75
|
}}
|
|
79
76
|
>
|
|
80
77
|
<KeyRound size={14} />
|
|
81
|
-
|
|
78
|
+
{t("forge.presale_code.unlocked", { code: presale.code ?? "" })}
|
|
82
79
|
</span>
|
|
83
80
|
<button
|
|
84
81
|
type="button"
|
|
@@ -95,7 +92,7 @@ export function PresaleCodeField({
|
|
|
95
92
|
fontFamily: theme.fontFamily,
|
|
96
93
|
}}
|
|
97
94
|
>
|
|
98
|
-
|
|
95
|
+
{t("forge.presale_code.remove")}
|
|
99
96
|
</button>
|
|
100
97
|
</div>
|
|
101
98
|
);
|
|
@@ -112,13 +109,13 @@ export function PresaleCodeField({
|
|
|
112
109
|
style={{ fontSize: 14, fontWeight: 600, display: "inline-flex", alignItems: "center", gap: 6 }}
|
|
113
110
|
>
|
|
114
111
|
<KeyRound size={14} />
|
|
115
|
-
{promptText}
|
|
112
|
+
{promptText ?? t("forge.presale_code.prompt")}
|
|
116
113
|
</label>
|
|
117
114
|
<div style={{ display: "flex", gap: 8, alignItems: "flex-start" }}>
|
|
118
115
|
<input
|
|
119
116
|
id="presale-code-input"
|
|
120
117
|
data-testid="presale-code-input"
|
|
121
|
-
placeholder="
|
|
118
|
+
placeholder={t("forge.presale_code.input_placeholder")}
|
|
122
119
|
value={presale.input}
|
|
123
120
|
onChange={(e) => presale.setInput(e.target.value.toUpperCase())}
|
|
124
121
|
// Enter is what a buyer does after typing a code into a one-field
|
|
@@ -143,7 +140,7 @@ export function PresaleCodeField({
|
|
|
143
140
|
opacity: isChecking || !presale.input.trim() ? 0.6 : 1,
|
|
144
141
|
}}
|
|
145
142
|
>
|
|
146
|
-
{isChecking ? "
|
|
143
|
+
{isChecking ? t("forge.presale_code.checking") : t("forge.presale_code.unlock")}
|
|
147
144
|
</button>
|
|
148
145
|
</div>
|
|
149
146
|
{presale.message && (
|