@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
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
useLeaveEventWaitlist,
|
|
12
12
|
type WaitlistEntry,
|
|
13
13
|
} from "../../data/queries/useEventWaitlist";
|
|
14
|
+
import { useForgeT } from "../../i18n";
|
|
14
15
|
import { useThemeTokens, type ResolvedThemeTokens } from "../theme/ForgeThemeProvider";
|
|
15
16
|
import { Loading } from "./Loading";
|
|
16
17
|
|
|
@@ -40,7 +41,7 @@ export interface EventWaitlistProps {
|
|
|
40
41
|
event?: IEvent;
|
|
41
42
|
/** Slug or id, when the host has not already loaded the event. */
|
|
42
43
|
slug?: string;
|
|
43
|
-
/** Section heading. */
|
|
44
|
+
/** Section heading. Defaults to Forge's own translated heading. */
|
|
44
45
|
title?: string;
|
|
45
46
|
className?: string;
|
|
46
47
|
style?: CSSProperties;
|
|
@@ -48,8 +49,9 @@ export interface EventWaitlistProps {
|
|
|
48
49
|
|
|
49
50
|
const alpha = (hex: string, a: number) => hex + Math.round(a * 255).toString(16).padStart(2, "0");
|
|
50
51
|
|
|
51
|
-
export function EventWaitlist({ event: initialEvent, slug, title
|
|
52
|
+
export function EventWaitlist({ event: initialEvent, slug, title, className, style }: EventWaitlistProps) {
|
|
52
53
|
const t = useThemeTokens();
|
|
54
|
+
const tr = useForgeT();
|
|
53
55
|
const { user } = usePublicAuth();
|
|
54
56
|
const { data: fetched } = useEvent(initialEvent ? undefined : slug);
|
|
55
57
|
const event = initialEvent ?? fetched;
|
|
@@ -89,11 +91,9 @@ export function EventWaitlist({ event: initialEvent, slug, title = "Waitlist", c
|
|
|
89
91
|
}}
|
|
90
92
|
>
|
|
91
93
|
<Users size={18} style={{ color: t.primary }} />
|
|
92
|
-
{title}
|
|
94
|
+
{title ?? tr("forge.event_waitlist.title")}
|
|
93
95
|
</h2>
|
|
94
|
-
<p style={{ fontSize: 14, opacity: 0.75, margin: "0 0 16px" }}>
|
|
95
|
-
Sold out. Join the queue and we'll email you if a ticket is released.
|
|
96
|
-
</p>
|
|
96
|
+
<p style={{ fontSize: 14, opacity: 0.75, margin: "0 0 16px" }}>{tr("forge.event_waitlist.subtitle")}</p>
|
|
97
97
|
|
|
98
98
|
{isLoading && live.length === 0 && soldOutTiers.length === 0 ? (
|
|
99
99
|
<Loading />
|
|
@@ -138,6 +138,7 @@ function WaitlistPlaceCard({
|
|
|
138
138
|
t: ResolvedThemeTokens;
|
|
139
139
|
}) {
|
|
140
140
|
const leave = useLeaveEventWaitlist(eventId);
|
|
141
|
+
const tr = useForgeT();
|
|
141
142
|
const [error, setError] = useState<string | null>(null);
|
|
142
143
|
const offerOpen = entry.offer.offerOpen;
|
|
143
144
|
|
|
@@ -146,7 +147,7 @@ function WaitlistPlaceCard({
|
|
|
146
147
|
try {
|
|
147
148
|
await leave.mutateAsync({ entryId: entry.id });
|
|
148
149
|
} catch (err) {
|
|
149
|
-
setError(readError(err, "
|
|
150
|
+
setError(readError(err, tr("forge.event_waitlist.leave_error")));
|
|
150
151
|
}
|
|
151
152
|
};
|
|
152
153
|
|
|
@@ -160,9 +161,11 @@ function WaitlistPlaceCard({
|
|
|
160
161
|
>
|
|
161
162
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
|
|
162
163
|
<div>
|
|
163
|
-
<div style={{ fontWeight: 600 }}>{ticket?.title ?? "
|
|
164
|
+
<div style={{ fontWeight: 600 }}>{ticket?.title ?? tr("forge.event_waitlist.ticket_fallback")}</div>
|
|
164
165
|
<div style={{ fontSize: 13, opacity: 0.7 }}>
|
|
165
|
-
{entry.quantity
|
|
166
|
+
{entry.quantity === 1
|
|
167
|
+
? tr("forge.event_waitlist.quantity_requested_one", { count: entry.quantity })
|
|
168
|
+
: tr("forge.event_waitlist.quantity_requested_other", { count: entry.quantity })}
|
|
166
169
|
</div>
|
|
167
170
|
</div>
|
|
168
171
|
{offerOpen ? (
|
|
@@ -170,14 +173,16 @@ function WaitlistPlaceCard({
|
|
|
170
173
|
<BellRing size={16} />
|
|
171
174
|
{/* 2.6b — "reserved for you" is a materially different promise from
|
|
172
175
|
"available", and the fan acts differently on each. */}
|
|
173
|
-
{entry.offer.seatsReserved
|
|
176
|
+
{entry.offer.seatsReserved
|
|
177
|
+
? tr("forge.event_waitlist.offer_reserved")
|
|
178
|
+
: tr("forge.event_waitlist.offer_available")}
|
|
174
179
|
</span>
|
|
175
180
|
) : entry.position != null ? (
|
|
176
181
|
<span style={{ fontSize: 14, opacity: 0.85 }}>
|
|
177
|
-
|
|
182
|
+
{tr("forge.event_waitlist.position_label")} <strong style={{ color: t.primary }}>#{entry.position}</strong>
|
|
178
183
|
</span>
|
|
179
184
|
) : (
|
|
180
|
-
<span style={{ fontSize: 14, opacity: 0.85 }}>
|
|
185
|
+
<span style={{ fontSize: 14, opacity: 0.85 }}>{tr("forge.event_waitlist.on_the_list")}</span>
|
|
181
186
|
)}
|
|
182
187
|
</div>
|
|
183
188
|
|
|
@@ -200,7 +205,7 @@ function WaitlistPlaceCard({
|
|
|
200
205
|
fontWeight: 600,
|
|
201
206
|
}}
|
|
202
207
|
>
|
|
203
|
-
{leave.isPending ? "
|
|
208
|
+
{leave.isPending ? tr("forge.event_waitlist.leaving") : tr("forge.event_waitlist.leave")}
|
|
204
209
|
</button>
|
|
205
210
|
</div>
|
|
206
211
|
);
|
|
@@ -214,6 +219,7 @@ function WaitlistPlaceCard({
|
|
|
214
219
|
* locally, so a slow clock can shorten what the buyer sees but never lengthen it.
|
|
215
220
|
*/
|
|
216
221
|
function OfferDeadline({ entry, t }: { entry: WaitlistEntry; t: ResolvedThemeTokens }) {
|
|
222
|
+
const tr = useForgeT();
|
|
217
223
|
const [remaining, setRemaining] = useState(entry.offer.secondsRemaining ?? 0);
|
|
218
224
|
// Absent on a payload from an older API build — read as "not reserved", which
|
|
219
225
|
// is the weaker and therefore safe reading.
|
|
@@ -255,7 +261,9 @@ function OfferDeadline({ entry, t }: { entry: WaitlistEntry; t: ResolvedThemeTok
|
|
|
255
261
|
>
|
|
256
262
|
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 600 }}>
|
|
257
263
|
<Clock size={14} style={{ color: t.primary }} />
|
|
258
|
-
{remaining > 0
|
|
264
|
+
{remaining > 0
|
|
265
|
+
? tr("forge.event_waitlist.buy_within", { time: formatCountdown(remaining) })
|
|
266
|
+
: tr("forge.event_waitlist.offer_closed")}
|
|
259
267
|
</div>
|
|
260
268
|
{deadline && (
|
|
261
269
|
<div style={{ opacity: 0.8, marginTop: 4 }}>
|
|
@@ -272,10 +280,12 @@ function OfferDeadline({ entry, t }: { entry: WaitlistEntry; t: ResolvedThemeTok
|
|
|
272
280
|
failure this change closes.
|
|
273
281
|
*/}
|
|
274
282
|
{remaining <= 0
|
|
275
|
-
?
|
|
283
|
+
? tr("forge.event_waitlist.offer_expired_at", { deadline })
|
|
276
284
|
: reserved
|
|
277
|
-
?
|
|
278
|
-
|
|
285
|
+
? entry.quantity === 1
|
|
286
|
+
? tr("forge.event_waitlist.reserved_until_one", { deadline })
|
|
287
|
+
: tr("forge.event_waitlist.reserved_until_other", { count: entry.quantity, deadline })
|
|
288
|
+
: tr("forge.event_waitlist.offer_not_reserved", { deadline })}
|
|
279
289
|
</div>
|
|
280
290
|
)}
|
|
281
291
|
</div>
|
|
@@ -308,6 +318,7 @@ function JoinCard({
|
|
|
308
318
|
signedIn: boolean;
|
|
309
319
|
}) {
|
|
310
320
|
const join = useJoinEventWaitlist(eventId);
|
|
321
|
+
const tr = useForgeT();
|
|
311
322
|
const [open, setOpen] = useState(false);
|
|
312
323
|
const [email, setEmail] = useState("");
|
|
313
324
|
const [firstName, setFirstName] = useState("");
|
|
@@ -319,7 +330,7 @@ function JoinCard({
|
|
|
319
330
|
e.preventDefault();
|
|
320
331
|
setError(null);
|
|
321
332
|
if (!signedIn && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
322
|
-
return setError("
|
|
333
|
+
return setError(tr("forge.event_waitlist.email_invalid"));
|
|
323
334
|
}
|
|
324
335
|
try {
|
|
325
336
|
await join.mutateAsync({
|
|
@@ -330,7 +341,7 @@ function JoinCard({
|
|
|
330
341
|
quantity,
|
|
331
342
|
});
|
|
332
343
|
} catch (err) {
|
|
333
|
-
setError(readError(err, "
|
|
344
|
+
setError(readError(err, tr("forge.event_waitlist.join_error")));
|
|
334
345
|
}
|
|
335
346
|
};
|
|
336
347
|
|
|
@@ -349,7 +360,7 @@ function JoinCard({
|
|
|
349
360
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
|
|
350
361
|
<div>
|
|
351
362
|
<div style={{ fontWeight: 600 }}>{ticket.title}</div>
|
|
352
|
-
<div style={{ fontSize: 13, opacity: 0.7 }}>
|
|
363
|
+
<div style={{ fontSize: 13, opacity: 0.7 }}>{tr("forge.event_waitlist.sold_out")}</div>
|
|
353
364
|
</div>
|
|
354
365
|
{!open && (
|
|
355
366
|
<button
|
|
@@ -365,7 +376,7 @@ function JoinCard({
|
|
|
365
376
|
cursor: "pointer",
|
|
366
377
|
}}
|
|
367
378
|
>
|
|
368
|
-
|
|
379
|
+
{tr("forge.event_waitlist.join")}
|
|
369
380
|
</button>
|
|
370
381
|
)}
|
|
371
382
|
</div>
|
|
@@ -375,31 +386,31 @@ function JoinCard({
|
|
|
375
386
|
{!signedIn && (
|
|
376
387
|
<input
|
|
377
388
|
type="email"
|
|
378
|
-
placeholder="
|
|
389
|
+
placeholder={tr("forge.event_waitlist.email_placeholder")}
|
|
379
390
|
value={email}
|
|
380
391
|
onChange={(e) => setEmail(e.target.value)}
|
|
381
392
|
style={input}
|
|
382
|
-
aria-label="
|
|
393
|
+
aria-label={tr("forge.event_waitlist.aria_email")}
|
|
383
394
|
/>
|
|
384
395
|
)}
|
|
385
396
|
<div style={{ display: "flex", gap: 8 }}>
|
|
386
397
|
<input
|
|
387
|
-
placeholder="
|
|
398
|
+
placeholder={tr("forge.event_waitlist.first_name_placeholder")}
|
|
388
399
|
value={firstName}
|
|
389
400
|
onChange={(e) => setFirstName(e.target.value)}
|
|
390
401
|
style={input}
|
|
391
|
-
aria-label="
|
|
402
|
+
aria-label={tr("forge.event_waitlist.aria_first_name")}
|
|
392
403
|
/>
|
|
393
404
|
<input
|
|
394
|
-
placeholder="
|
|
405
|
+
placeholder={tr("forge.event_waitlist.last_name_placeholder")}
|
|
395
406
|
value={lastName}
|
|
396
407
|
onChange={(e) => setLastName(e.target.value)}
|
|
397
408
|
style={input}
|
|
398
|
-
aria-label="
|
|
409
|
+
aria-label={tr("forge.event_waitlist.aria_last_name")}
|
|
399
410
|
/>
|
|
400
411
|
</div>
|
|
401
412
|
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 14 }}>
|
|
402
|
-
|
|
413
|
+
{tr("forge.event_waitlist.quantity_label")}
|
|
403
414
|
<input
|
|
404
415
|
type="number"
|
|
405
416
|
min={1}
|
|
@@ -424,7 +435,7 @@ function JoinCard({
|
|
|
424
435
|
cursor: join.isPending ? "wait" : "pointer",
|
|
425
436
|
}}
|
|
426
437
|
>
|
|
427
|
-
{join.isPending ? "
|
|
438
|
+
{join.isPending ? tr("forge.event_waitlist.joining") : tr("forge.event_waitlist.join")}
|
|
428
439
|
</button>
|
|
429
440
|
<button
|
|
430
441
|
type="button"
|
|
@@ -438,12 +449,10 @@ function JoinCard({
|
|
|
438
449
|
cursor: "pointer",
|
|
439
450
|
}}
|
|
440
451
|
>
|
|
441
|
-
|
|
452
|
+
{tr("forge.event_waitlist.cancel")}
|
|
442
453
|
</button>
|
|
443
454
|
</div>
|
|
444
|
-
<p style={{ fontSize: 12, opacity: 0.65, margin: 0 }}>
|
|
445
|
-
We'll only email you about tickets for this event.
|
|
446
|
-
</p>
|
|
455
|
+
<p style={{ fontSize: 12, opacity: 0.65, margin: 0 }}>{tr("forge.event_waitlist.email_promise")}</p>
|
|
447
456
|
</form>
|
|
448
457
|
)}
|
|
449
458
|
</div>
|
|
@@ -2,6 +2,7 @@ import { useEvents } from "../../data/queries/useEvents";
|
|
|
2
2
|
import type { IEvent } from "../../types/models";
|
|
3
3
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
4
4
|
import { Loading } from "./Loading";
|
|
5
|
+
import { useForgeT } from "../../i18n";
|
|
5
6
|
|
|
6
7
|
export interface EventsListProps {
|
|
7
8
|
limit?: number;
|
|
@@ -22,23 +23,24 @@ function formatDate(dateTime: string, timezone?: string) {
|
|
|
22
23
|
});
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
function locationText(event: IEvent): string {
|
|
26
|
-
if (event.type === "virtual") return
|
|
26
|
+
function locationText(event: IEvent, virtualLabel: string): string {
|
|
27
|
+
if (event.type === "virtual") return virtualLabel;
|
|
27
28
|
if (event.address) return event.address.name || event.address.city;
|
|
28
|
-
return
|
|
29
|
+
return virtualLabel;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
/** Themed upcoming-events list, built on `useEvents`. */
|
|
32
33
|
export function EventsList({ limit, onSelect, className, style }: EventsListProps) {
|
|
33
|
-
const t =
|
|
34
|
+
const t = useForgeT();
|
|
35
|
+
const tokens = useThemeTokens();
|
|
34
36
|
const { data, isLoading } = useEvents();
|
|
35
37
|
const events = (data ?? []).slice(0, limit ?? 100);
|
|
36
38
|
|
|
37
39
|
if (isLoading) return <Loading />;
|
|
38
|
-
if (!events.length) return <p style={{ color:
|
|
40
|
+
if (!events.length) return <p style={{ color: tokens.text, opacity: 0.7 }}>{t("forge.events_list.empty_title")}</p>;
|
|
39
41
|
|
|
40
42
|
return (
|
|
41
|
-
<div className={className} style={{ display: "flex", flexDirection: "column", gap: 12, fontFamily:
|
|
43
|
+
<div className={className} style={{ display: "flex", flexDirection: "column", gap: 12, fontFamily: tokens.fontFamily, ...style }}>
|
|
42
44
|
{events.map((ev) => {
|
|
43
45
|
const cover = ev.media?.find((m) => m.type === "image")?.url ?? ev.media?.[0]?.url;
|
|
44
46
|
return (
|
|
@@ -51,21 +53,21 @@ export function EventsList({ limit, onSelect, className, style }: EventsListProp
|
|
|
51
53
|
alignItems: "center",
|
|
52
54
|
textAlign: "left",
|
|
53
55
|
cursor: onSelect ? "pointer" : "default",
|
|
54
|
-
background:
|
|
55
|
-
border: `1px solid ${
|
|
56
|
-
borderRadius:
|
|
56
|
+
background: tokens.surface,
|
|
57
|
+
border: `1px solid ${tokens.primary}20`,
|
|
58
|
+
borderRadius: tokens.cornerRadius,
|
|
57
59
|
padding: 10,
|
|
58
|
-
color:
|
|
60
|
+
color: tokens.text,
|
|
59
61
|
}}
|
|
60
62
|
>
|
|
61
63
|
<div
|
|
62
64
|
style={{
|
|
63
65
|
width: 72,
|
|
64
66
|
height: 72,
|
|
65
|
-
borderRadius:
|
|
67
|
+
borderRadius: tokens.cornerRadius,
|
|
66
68
|
overflow: "hidden",
|
|
67
69
|
flexShrink: 0,
|
|
68
|
-
background: `${
|
|
70
|
+
background: `${tokens.primary}10`,
|
|
69
71
|
}}
|
|
70
72
|
>
|
|
71
73
|
{cover && <img src={cover} alt={ev.title} style={{ width: "100%", height: "100%", objectFit: "cover" }} />}
|
|
@@ -73,7 +75,7 @@ export function EventsList({ limit, onSelect, className, style }: EventsListProp
|
|
|
73
75
|
<div>
|
|
74
76
|
<div style={{ fontWeight: 600 }}>{ev.title}</div>
|
|
75
77
|
<div style={{ fontSize: 13, opacity: 0.75 }}>{formatDate(ev.dateTime, ev.timezone)}</div>
|
|
76
|
-
<div style={{ fontSize: 13, opacity: 0.75 }}>{locationText(ev)}</div>
|
|
78
|
+
<div style={{ fontSize: 13, opacity: 0.75 }}>{locationText(ev, t("forge.events_list.virtual"))}</div>
|
|
77
79
|
</div>
|
|
78
80
|
</button>
|
|
79
81
|
);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { useForgotPassword } from "../../data/queries/useAuthActions";
|
|
3
|
+
import { useForgeT } from "../../i18n";
|
|
3
4
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
5
|
|
|
5
6
|
export interface ForgotPasswordFormProps {
|
|
@@ -11,6 +12,7 @@ const msg = (e: unknown) => (e as { response?: { data?: { message?: string } } }
|
|
|
11
12
|
|
|
12
13
|
/** Themed forgot-password request form, built on `useForgotPassword`. */
|
|
13
14
|
export function ForgotPasswordForm({ loginHref }: ForgotPasswordFormProps) {
|
|
15
|
+
const t = useForgeT();
|
|
14
16
|
const theme = useForgeTheme();
|
|
15
17
|
const forgotPassword = useForgotPassword();
|
|
16
18
|
const [email, setEmail] = useState("");
|
|
@@ -19,6 +21,11 @@ export function ForgotPasswordForm({ loginHref }: ForgotPasswordFormProps) {
|
|
|
19
21
|
const [isSuccess, setIsSuccess] = useState(false);
|
|
20
22
|
|
|
21
23
|
const card: React.CSSProperties = {
|
|
24
|
+
// See `LoginForm`. The host layout is a COLUMN flex container, and an auto
|
|
25
|
+
// cross-axis margin disables `align-items: stretch`, so `maxWidth` alone
|
|
26
|
+
// leaves the card shrink-to-fit. The cap is unchanged: this fills the
|
|
27
|
+
// container UP TO 420 and no further.
|
|
28
|
+
width: "100%",
|
|
22
29
|
maxWidth: 420,
|
|
23
30
|
margin: "40px auto 0",
|
|
24
31
|
padding: 24,
|
|
@@ -46,7 +53,7 @@ export function ForgotPasswordForm({ loginHref }: ForgotPasswordFormProps) {
|
|
|
46
53
|
await forgotPassword.mutateAsync({ email, origin: window.location.origin });
|
|
47
54
|
setIsSuccess(true);
|
|
48
55
|
} catch (err) {
|
|
49
|
-
setError(msg(err) || "
|
|
56
|
+
setError(msg(err) || t("forge.forgot_password_form.error"));
|
|
50
57
|
} finally {
|
|
51
58
|
setIsSubmitting(false);
|
|
52
59
|
}
|
|
@@ -55,12 +62,10 @@ export function ForgotPasswordForm({ loginHref }: ForgotPasswordFormProps) {
|
|
|
55
62
|
if (isSuccess) {
|
|
56
63
|
return (
|
|
57
64
|
<div style={card}>
|
|
58
|
-
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>
|
|
59
|
-
<p style={{ fontSize: 14, marginBottom: 24 }}>
|
|
60
|
-
If an account exists with that email, we've sent a password reset link. Please check your inbox.
|
|
61
|
-
</p>
|
|
65
|
+
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>{t("forge.forgot_password_form.sent_title")}</h1>
|
|
66
|
+
<p style={{ fontSize: 14, marginBottom: 24 }}>{t("forge.forgot_password_form.sent_body")}</p>
|
|
62
67
|
<a href={loginHref} style={link}>
|
|
63
|
-
|
|
68
|
+
{t("forge.forgot_password_form.back_to_login")}
|
|
64
69
|
</a>
|
|
65
70
|
</div>
|
|
66
71
|
);
|
|
@@ -68,14 +73,14 @@ export function ForgotPasswordForm({ loginHref }: ForgotPasswordFormProps) {
|
|
|
68
73
|
|
|
69
74
|
return (
|
|
70
75
|
<div style={card}>
|
|
71
|
-
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>
|
|
76
|
+
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>{t("forge.forgot_password_form.title")}</h1>
|
|
72
77
|
{error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{error}</p>}
|
|
73
78
|
<form onSubmit={onSubmit} style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
|
74
79
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
75
|
-
<label style={{ fontSize: 14 }}>
|
|
80
|
+
<label style={{ fontSize: 14 }}>{t("forge.forgot_password_form.email_label")}</label>
|
|
76
81
|
<input
|
|
77
82
|
type="email"
|
|
78
|
-
placeholder="
|
|
83
|
+
placeholder={t("forge.forgot_password_form.email_placeholder")}
|
|
79
84
|
value={email}
|
|
80
85
|
onChange={(e) => setEmail(e.target.value)}
|
|
81
86
|
style={{
|
|
@@ -91,13 +96,13 @@ export function ForgotPasswordForm({ loginHref }: ForgotPasswordFormProps) {
|
|
|
91
96
|
/>
|
|
92
97
|
</div>
|
|
93
98
|
<button type="submit" disabled={isSubmitting} style={button}>
|
|
94
|
-
{isSubmitting ? "
|
|
99
|
+
{isSubmitting ? t("forge.forgot_password_form.submitting") : t("forge.forgot_password_form.submit")}
|
|
95
100
|
</button>
|
|
96
101
|
</form>
|
|
97
102
|
<p style={{ marginTop: 16, fontSize: 14, textAlign: "center" }}>
|
|
98
|
-
|
|
103
|
+
{t("forge.forgot_password_form.login_prompt")}{" "}
|
|
99
104
|
<a href={loginHref} style={link}>
|
|
100
|
-
|
|
105
|
+
{t("forge.forgot_password_form.login_link")}
|
|
101
106
|
</a>
|
|
102
107
|
</p>
|
|
103
108
|
</div>
|
|
@@ -2,6 +2,7 @@ import { useState } from "react";
|
|
|
2
2
|
import { usePublicForm, useSubmitPublicForm } from "../../data/queries/useForms";
|
|
3
3
|
import type { FormField } from "../../types/models";
|
|
4
4
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
5
|
+
import { useForgeT } from "../../i18n";
|
|
5
6
|
import { Loading } from "./Loading";
|
|
6
7
|
|
|
7
8
|
export interface FormRendererProps {
|
|
@@ -15,6 +16,7 @@ export interface FormRendererProps {
|
|
|
15
16
|
*/
|
|
16
17
|
export function FormRenderer({ formId }: FormRendererProps) {
|
|
17
18
|
const theme = useForgeTheme();
|
|
19
|
+
const t = useForgeT();
|
|
18
20
|
const { data: form, isLoading } = usePublicForm(formId);
|
|
19
21
|
const submitMutation = useSubmitPublicForm(formId);
|
|
20
22
|
|
|
@@ -33,12 +35,12 @@ export function FormRenderer({ formId }: FormRendererProps) {
|
|
|
33
35
|
setError("");
|
|
34
36
|
for (const field of fields) {
|
|
35
37
|
if (field.isRequired && !answers[field.id]) {
|
|
36
|
-
setError(
|
|
38
|
+
setError(t("forge.form_renderer.required_error", { label: field.label }));
|
|
37
39
|
return;
|
|
38
40
|
}
|
|
39
41
|
if (field.type === "email" && answers[field.id]) {
|
|
40
42
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(answers[field.id])) {
|
|
41
|
-
setError("
|
|
43
|
+
setError(t("forge.form_renderer.invalid_email"));
|
|
42
44
|
return;
|
|
43
45
|
}
|
|
44
46
|
}
|
|
@@ -48,7 +50,7 @@ export function FormRenderer({ formId }: FormRendererProps) {
|
|
|
48
50
|
await submitMutation.mutateAsync(entries);
|
|
49
51
|
setIsCompleted(true);
|
|
50
52
|
} catch {
|
|
51
|
-
setError("
|
|
53
|
+
setError(t("forge.form_renderer.submit_error"));
|
|
52
54
|
}
|
|
53
55
|
};
|
|
54
56
|
|
|
@@ -58,9 +60,9 @@ export function FormRenderer({ formId }: FormRendererProps) {
|
|
|
58
60
|
return (
|
|
59
61
|
<div style={{ ...wrap, textAlign: "center" }}>
|
|
60
62
|
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>
|
|
61
|
-
{settings.thankYouTitle || "
|
|
63
|
+
{settings.thankYouTitle || t("forge.form_renderer.thank_you_title")}
|
|
62
64
|
</h1>
|
|
63
|
-
<p style={{ opacity: 0.8 }}>{settings.thankYouMessage || "
|
|
65
|
+
<p style={{ opacity: 0.8 }}>{settings.thankYouMessage || t("forge.form_renderer.thank_you_body")}</p>
|
|
64
66
|
</div>
|
|
65
67
|
);
|
|
66
68
|
}
|
|
@@ -99,7 +101,9 @@ export function FormRenderer({ formId }: FormRendererProps) {
|
|
|
99
101
|
color: theme.colors.background,
|
|
100
102
|
}}
|
|
101
103
|
>
|
|
102
|
-
{submitMutation.isPending
|
|
104
|
+
{submitMutation.isPending
|
|
105
|
+
? t("forge.form_renderer.submitting")
|
|
106
|
+
: settings.submitButtonText || t("forge.form_renderer.submit")}
|
|
103
107
|
</button>
|
|
104
108
|
</div>
|
|
105
109
|
);
|
|
@@ -116,6 +120,7 @@ export function FieldInput({
|
|
|
116
120
|
onChange: (v: string) => void;
|
|
117
121
|
theme: ReturnType<typeof useForgeTheme>;
|
|
118
122
|
}) {
|
|
123
|
+
const t = useForgeT();
|
|
119
124
|
const inputStyle: React.CSSProperties = {
|
|
120
125
|
width: "100%",
|
|
121
126
|
padding: "10px 12px",
|
|
@@ -149,7 +154,7 @@ export function FieldInput({
|
|
|
149
154
|
case "single_select":
|
|
150
155
|
control = (
|
|
151
156
|
<select value={value} onChange={(e) => onChange(e.target.value)} style={inputStyle}>
|
|
152
|
-
<option value="">
|
|
157
|
+
<option value="">{t("forge.form_renderer.select_placeholder")}</option>
|
|
153
158
|
{field.options?.map((o) => (
|
|
154
159
|
<option key={o.id} value={o.label}>
|
|
155
160
|
{o.label}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
import { useInstallPrompt } from "../../data/queries/usePWA";
|
|
3
3
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
4
|
+
import { useForgeT } from "../../i18n";
|
|
4
5
|
import { Button } from "./Button";
|
|
5
6
|
|
|
6
7
|
const DISMISS_KEY = "forge-install-banner-dismissed";
|
|
@@ -25,13 +26,15 @@ export interface InstallBannerProps {
|
|
|
25
26
|
* iOS the installed PWA is also the gateway to web push (iOS 16.4+).
|
|
26
27
|
* Hidden when installed, already dismissed (localStorage), or not `enabled`.
|
|
27
28
|
*/
|
|
28
|
-
export function InstallBanner({
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
29
|
+
export function InstallBanner({ enabled = true, title, description, iosInstructions }: InstallBannerProps) {
|
|
30
|
+
const tokens = useThemeTokens();
|
|
31
|
+
const t = useForgeT();
|
|
32
|
+
// The defaults moved out of the signature because they are now translated,
|
|
33
|
+
// and a hook cannot be called in a parameter default. A caller-supplied
|
|
34
|
+
// string still wins, exactly as before.
|
|
35
|
+
const heading = title ?? t("forge.install_banner.title");
|
|
36
|
+
const body = description ?? t("forge.install_banner.description");
|
|
37
|
+
const iosBody = iosInstructions ?? t("forge.install_banner.ios_instructions");
|
|
35
38
|
const { isInstallable, isInstalled, isIOS, isStandalone, promptInstall } = useInstallPrompt();
|
|
36
39
|
const [dismissed, setDismissed] = useState(true);
|
|
37
40
|
|
|
@@ -59,7 +62,7 @@ export function InstallBanner({
|
|
|
59
62
|
return (
|
|
60
63
|
<div
|
|
61
64
|
role="dialog"
|
|
62
|
-
aria-label={
|
|
65
|
+
aria-label={heading}
|
|
63
66
|
style={{
|
|
64
67
|
position: "fixed",
|
|
65
68
|
left: 12,
|
|
@@ -69,10 +72,10 @@ export function InstallBanner({
|
|
|
69
72
|
maxWidth: 520,
|
|
70
73
|
margin: "0 auto",
|
|
71
74
|
padding: 16,
|
|
72
|
-
borderRadius:
|
|
73
|
-
background:
|
|
74
|
-
color:
|
|
75
|
-
border: `1px solid ${
|
|
75
|
+
borderRadius: tokens.cornerRadius,
|
|
76
|
+
background: tokens.surface,
|
|
77
|
+
color: tokens.text,
|
|
78
|
+
border: `1px solid ${tokens.border}`,
|
|
76
79
|
boxShadow: "0 8px 30px rgba(0,0,0,0.18)",
|
|
77
80
|
display: "flex",
|
|
78
81
|
flexDirection: "column",
|
|
@@ -81,14 +84,14 @@ export function InstallBanner({
|
|
|
81
84
|
>
|
|
82
85
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 12 }}>
|
|
83
86
|
<div>
|
|
84
|
-
<div style={{ fontWeight: 700, fontSize: 15 }}>{
|
|
85
|
-
<div style={{ fontSize: 13, color:
|
|
87
|
+
<div style={{ fontWeight: 700, fontSize: 15 }}>{heading}</div>
|
|
88
|
+
<div style={{ fontSize: 13, color: tokens.muted, marginTop: 2 }}>{isIOS ? iosBody : body}</div>
|
|
86
89
|
</div>
|
|
87
90
|
<button
|
|
88
91
|
type="button"
|
|
89
|
-
aria-label="
|
|
92
|
+
aria-label={t("forge.install_banner.aria_dismiss")}
|
|
90
93
|
onClick={dismiss}
|
|
91
|
-
style={{ background: "transparent", border: "none", color:
|
|
94
|
+
style={{ background: "transparent", border: "none", color: tokens.muted, cursor: "pointer", fontSize: 18, lineHeight: 1 }}
|
|
92
95
|
>
|
|
93
96
|
{"×"}
|
|
94
97
|
</button>
|
|
@@ -102,10 +105,10 @@ export function InstallBanner({
|
|
|
102
105
|
if (ok) dismiss();
|
|
103
106
|
}}
|
|
104
107
|
>
|
|
105
|
-
|
|
108
|
+
{t("forge.install_banner.install")}
|
|
106
109
|
</Button>
|
|
107
110
|
<Button size="sm" variant="ghost" onClick={dismiss}>
|
|
108
|
-
|
|
111
|
+
{t("forge.install_banner.not_now")}
|
|
109
112
|
</Button>
|
|
110
113
|
</div>
|
|
111
114
|
)}
|
|
@@ -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,37 @@ export function InvoiceConfirmation({
|
|
|
20
21
|
invoicePath = `/i/invoice/${invoiceId}`,
|
|
21
22
|
}: InvoiceConfirmationProps) {
|
|
22
23
|
const theme = useForgeTheme();
|
|
24
|
+
const t = useForgeT();
|
|
23
25
|
const fmt = useAmountFormatter(formatAmount);
|
|
24
26
|
const { data, isLoading, error } = useInvoiceFinalize(invoiceId);
|
|
25
27
|
|
|
26
28
|
if (isLoading) return <Loading fullPage />;
|
|
27
|
-
if (error || !data) return <p style={{ color: theme.colors.text }}>
|
|
29
|
+
if (error || !data) return <p style={{ color: theme.colors.text }}>{t("forge.invoice_confirmation.load_error")}</p>;
|
|
28
30
|
|
|
29
31
|
const paid = data.status === "paid" || !!data.paidAt;
|
|
30
32
|
|
|
31
33
|
return (
|
|
32
34
|
<div style={{ maxWidth: 520, margin: "0 auto", textAlign: "center", color: theme.colors.text }}>
|
|
33
35
|
<h1 style={{ fontSize: 28, fontWeight: 800, marginBottom: 8 }}>
|
|
34
|
-
{paid ? "
|
|
36
|
+
{paid ? t("forge.invoice_confirmation.paid_title") : t("forge.invoice_confirmation.unpaid_title")}
|
|
35
37
|
</h1>
|
|
36
38
|
<p style={{ opacity: 0.75, marginBottom: 16 }}>
|
|
37
39
|
{paid
|
|
38
|
-
?
|
|
39
|
-
|
|
40
|
+
? t("forge.invoice_confirmation.paid_body", {
|
|
41
|
+
client_name: data.clientName,
|
|
42
|
+
invoice_number: data.invoiceNumber,
|
|
43
|
+
})
|
|
44
|
+
: t("forge.invoice_confirmation.unpaid_body", {
|
|
45
|
+
invoice_number: data.invoiceNumber,
|
|
46
|
+
status: data.status,
|
|
47
|
+
})}
|
|
40
48
|
</p>
|
|
41
49
|
<p style={{ fontWeight: 700, color: theme.colors.primary }}>{fmt(Number(data.total), data.currency)}</p>
|
|
42
50
|
<a
|
|
43
51
|
href={invoicePath}
|
|
44
52
|
style={{ display: "inline-block", marginTop: 24, color: theme.colors.primary, textDecoration: "underline" }}
|
|
45
53
|
>
|
|
46
|
-
|
|
54
|
+
{t("forge.invoice_confirmation.view_invoice")}
|
|
47
55
|
</a>
|
|
48
56
|
</div>
|
|
49
57
|
);
|