@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useSignupForm } from "../headless/auth/useSignupForm";
|
|
2
|
+
import { useForgeT } from "../../i18n";
|
|
2
3
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
3
4
|
|
|
4
5
|
export interface SignupFormProps {
|
|
@@ -12,10 +13,21 @@ export interface SignupFormProps {
|
|
|
12
13
|
|
|
13
14
|
/** Themed account signup form, built on `useSignupForm`. */
|
|
14
15
|
export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode }: SignupFormProps) {
|
|
16
|
+
const t = useForgeT();
|
|
15
17
|
const theme = useForgeTheme();
|
|
16
18
|
const form = useSignupForm({ onSuccess, membershipTierId, couponCode });
|
|
17
19
|
|
|
20
|
+
// One key for the whole sentence, split on its own `{email}` slot so the
|
|
21
|
+
// address keeps its <strong> without the sentence being assembled from two
|
|
22
|
+
// separately translated fragments.
|
|
23
|
+
const codeSentParts = t("forge.signup_form.code_sent").split("{email}");
|
|
24
|
+
|
|
18
25
|
const card: React.CSSProperties = {
|
|
26
|
+
// See `LoginForm`. The host layout is a COLUMN flex container, and an auto
|
|
27
|
+
// cross-axis margin disables `align-items: stretch`, so `maxWidth` alone
|
|
28
|
+
// leaves the card shrink-to-fit. The cap is unchanged: this fills the
|
|
29
|
+
// container UP TO 420 and no further.
|
|
30
|
+
width: "100%",
|
|
19
31
|
maxWidth: 420,
|
|
20
32
|
margin: "40px auto 0",
|
|
21
33
|
padding: 24,
|
|
@@ -53,7 +65,7 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
|
|
|
53
65
|
return (
|
|
54
66
|
<div style={card}>
|
|
55
67
|
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>
|
|
56
|
-
{form.awaitingCode ? "
|
|
68
|
+
{form.awaitingCode ? t("forge.signup_form.verify_title") : t("forge.signup_form.title")}
|
|
57
69
|
</h1>
|
|
58
70
|
{form.error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{form.error}</p>}
|
|
59
71
|
<form
|
|
@@ -68,10 +80,12 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
|
|
|
68
80
|
{form.awaitingCode ? (
|
|
69
81
|
<>
|
|
70
82
|
<p style={{ fontSize: 14, margin: 0 }}>
|
|
71
|
-
|
|
83
|
+
{codeSentParts[0]}
|
|
84
|
+
<strong>{form.email}</strong>
|
|
85
|
+
{codeSentParts[1]}
|
|
72
86
|
</p>
|
|
73
87
|
{field(
|
|
74
|
-
"
|
|
88
|
+
t("forge.signup_form.code_label"),
|
|
75
89
|
<input
|
|
76
90
|
inputMode="numeric"
|
|
77
91
|
autoComplete="one-time-code"
|
|
@@ -86,42 +100,42 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
|
|
|
86
100
|
<>
|
|
87
101
|
<div style={{ display: "flex", gap: 12 }}>
|
|
88
102
|
{field(
|
|
89
|
-
"
|
|
103
|
+
t("forge.signup_form.first_name_label"),
|
|
90
104
|
<input
|
|
91
105
|
value={form.firstName}
|
|
92
106
|
onChange={(e) => form.setFirstName(e.target.value)}
|
|
93
107
|
style={input}
|
|
94
|
-
placeholder="
|
|
108
|
+
placeholder={t("forge.signup_form.first_name_placeholder")}
|
|
95
109
|
/>,
|
|
96
110
|
)}
|
|
97
111
|
{field(
|
|
98
|
-
"
|
|
112
|
+
t("forge.signup_form.last_name_label"),
|
|
99
113
|
<input
|
|
100
114
|
value={form.lastName}
|
|
101
115
|
onChange={(e) => form.setLastName(e.target.value)}
|
|
102
116
|
style={input}
|
|
103
|
-
placeholder="
|
|
117
|
+
placeholder={t("forge.signup_form.last_name_placeholder")}
|
|
104
118
|
/>,
|
|
105
119
|
)}
|
|
106
120
|
</div>
|
|
107
121
|
{field(
|
|
108
|
-
"
|
|
122
|
+
t("forge.signup_form.email_label"),
|
|
109
123
|
<input
|
|
110
124
|
type="email"
|
|
111
125
|
value={form.email}
|
|
112
126
|
onChange={(e) => form.setEmail(e.target.value)}
|
|
113
127
|
style={input}
|
|
114
|
-
placeholder="
|
|
128
|
+
placeholder={t("forge.signup_form.email_placeholder")}
|
|
115
129
|
/>,
|
|
116
130
|
)}
|
|
117
131
|
{field(
|
|
118
|
-
"
|
|
132
|
+
t("forge.signup_form.password_label"),
|
|
119
133
|
<input
|
|
120
134
|
type="password"
|
|
121
135
|
value={form.password}
|
|
122
136
|
onChange={(e) => form.setPassword(e.target.value)}
|
|
123
137
|
style={input}
|
|
124
|
-
placeholder="
|
|
138
|
+
placeholder={t("forge.signup_form.password_placeholder")}
|
|
125
139
|
/>,
|
|
126
140
|
)}
|
|
127
141
|
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13 }}>
|
|
@@ -131,12 +145,16 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
|
|
|
131
145
|
onChange={(e) => form.setAcceptedTerms(e.target.checked)}
|
|
132
146
|
style={{ accentColor: theme.colors.primary }}
|
|
133
147
|
/>
|
|
134
|
-
|
|
148
|
+
{t("forge.signup_form.accept_terms")}
|
|
135
149
|
</label>
|
|
136
150
|
</>
|
|
137
151
|
)}
|
|
138
152
|
<button type="submit" disabled={form.isSubmitting} style={button}>
|
|
139
|
-
{form.isSubmitting
|
|
153
|
+
{form.isSubmitting
|
|
154
|
+
? t("forge.signup_form.submitting")
|
|
155
|
+
: form.awaitingCode
|
|
156
|
+
? t("forge.signup_form.verify_submit")
|
|
157
|
+
: t("forge.signup_form.submit")}
|
|
140
158
|
</button>
|
|
141
159
|
{form.awaitingCode && (
|
|
142
160
|
<button
|
|
@@ -145,14 +163,14 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
|
|
|
145
163
|
disabled={form.isSubmitting}
|
|
146
164
|
style={{ background: "none", border: "none", color: theme.colors.primary, cursor: "pointer", fontSize: 13 }}
|
|
147
165
|
>
|
|
148
|
-
|
|
166
|
+
{t("forge.signup_form.resend_code")}
|
|
149
167
|
</button>
|
|
150
168
|
)}
|
|
151
169
|
</form>
|
|
152
170
|
<p style={{ marginTop: 16, fontSize: 14, textAlign: "center" }}>
|
|
153
|
-
|
|
171
|
+
{t("forge.signup_form.login_prompt")}{" "}
|
|
154
172
|
<a href={loginHref} style={{ color: theme.colors.primary, textDecoration: "underline" }}>
|
|
155
|
-
|
|
173
|
+
{t("forge.signup_form.login_link")}
|
|
156
174
|
</a>
|
|
157
175
|
</p>
|
|
158
176
|
</div>
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "../../data/queries/usePassTransfers";
|
|
10
10
|
import type { MyTicketPass, MyTicketPendingTransfer } from "../../data/queries/useMyTickets";
|
|
11
11
|
import { useThemeTokens, type ResolvedThemeTokens } from "../theme/ForgeThemeProvider";
|
|
12
|
+
import { useForgeT } from "../../i18n";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Events 2.2 — handing a ticket to somebody else, and taking one.
|
|
@@ -131,7 +132,7 @@ export interface TicketTransferPanelProps {
|
|
|
131
132
|
* bridge across the refetch that follows a send.
|
|
132
133
|
*/
|
|
133
134
|
export function TicketTransferPanel({ passIds, passes, className, style }: TicketTransferPanelProps) {
|
|
134
|
-
const
|
|
135
|
+
const tokens = useThemeTokens();
|
|
135
136
|
const [openFor, setOpenFor] = useState<string | null>(null);
|
|
136
137
|
|
|
137
138
|
if (passIds.length === 0) return null;
|
|
@@ -150,7 +151,7 @@ export function TicketTransferPanel({ passIds, passes, className, style }: Ticke
|
|
|
150
151
|
key={passId}
|
|
151
152
|
passId={passId}
|
|
152
153
|
serverTransfer={byId.get(passId)?.pendingTransfer ?? null}
|
|
153
|
-
|
|
154
|
+
tokens={tokens}
|
|
154
155
|
showPassId={!single}
|
|
155
156
|
isOpen={openFor === passId}
|
|
156
157
|
onToggle={() => setOpenFor((current) => (current === passId ? null : passId))}
|
|
@@ -163,18 +164,19 @@ export function TicketTransferPanel({ passIds, passes, className, style }: Ticke
|
|
|
163
164
|
function TicketTransferRow({
|
|
164
165
|
passId,
|
|
165
166
|
serverTransfer,
|
|
166
|
-
|
|
167
|
+
tokens,
|
|
167
168
|
showPassId,
|
|
168
169
|
isOpen,
|
|
169
170
|
onToggle,
|
|
170
171
|
}: {
|
|
171
172
|
passId: string;
|
|
172
173
|
serverTransfer: MyTicketPendingTransfer | null;
|
|
173
|
-
|
|
174
|
+
tokens: ResolvedThemeTokens;
|
|
174
175
|
showPassId: boolean;
|
|
175
176
|
isOpen: boolean;
|
|
176
177
|
onToggle: () => void;
|
|
177
178
|
}) {
|
|
179
|
+
const t = useForgeT();
|
|
178
180
|
const send = useSendPassTransfer();
|
|
179
181
|
const cancel = useCancelPassTransfer();
|
|
180
182
|
const remembered = usePendingPassTransfers(passId);
|
|
@@ -199,7 +201,7 @@ function TicketTransferRow({
|
|
|
199
201
|
// The server distinguishes "already has a live transfer", "you have
|
|
200
202
|
// checked in", "the show was cancelled" and several more. Its wording,
|
|
201
203
|
// not ours.
|
|
202
|
-
setError(serverMessage(err, "
|
|
204
|
+
setError(serverMessage(err, t("forge.ticket_transfer.send_error")));
|
|
203
205
|
}
|
|
204
206
|
};
|
|
205
207
|
|
|
@@ -209,27 +211,27 @@ function TicketTransferRow({
|
|
|
209
211
|
try {
|
|
210
212
|
await cancel.mutateAsync({ transferId });
|
|
211
213
|
} catch (err) {
|
|
212
|
-
setError(serverMessage(err, "
|
|
214
|
+
setError(serverMessage(err, t("forge.ticket_transfer.withdraw_error")));
|
|
213
215
|
}
|
|
214
216
|
};
|
|
215
217
|
|
|
216
218
|
return (
|
|
217
219
|
<div
|
|
218
220
|
style={{
|
|
219
|
-
border: `1px solid ${
|
|
220
|
-
borderRadius:
|
|
221
|
+
border: `1px solid ${tokens.primary}20`,
|
|
222
|
+
borderRadius: tokens.cornerRadius,
|
|
221
223
|
padding: 12,
|
|
222
|
-
color:
|
|
223
|
-
fontFamily:
|
|
224
|
+
color: tokens.text,
|
|
225
|
+
fontFamily: tokens.fontFamily,
|
|
224
226
|
}}
|
|
225
227
|
>
|
|
226
228
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
|
|
227
229
|
<div>
|
|
228
|
-
<div style={{ fontWeight: 600, fontSize: 14 }}>
|
|
229
|
-
{showPassId && <div style={{ fontSize: 12, color:
|
|
230
|
+
<div style={{ fontWeight: 600, fontSize: 14 }}>{t("forge.ticket_transfer.row_title")}</div>
|
|
231
|
+
{showPassId && <div style={{ fontSize: 12, color: tokens.muted }}>{passId}</div>}
|
|
230
232
|
</div>
|
|
231
|
-
<button type="button" onClick={onToggle} style={ghostButton(
|
|
232
|
-
{isOpen ? "
|
|
233
|
+
<button type="button" onClick={onToggle} style={ghostButton(tokens, false)} aria-expanded={isOpen}>
|
|
234
|
+
{isOpen ? t("forge.ticket_transfer.close") : t("forge.ticket_transfer.open_send")}
|
|
233
235
|
</button>
|
|
234
236
|
</div>
|
|
235
237
|
|
|
@@ -240,8 +242,8 @@ function TicketTransferRow({
|
|
|
240
242
|
style={{
|
|
241
243
|
marginTop: 12,
|
|
242
244
|
padding: 12,
|
|
243
|
-
borderRadius:
|
|
244
|
-
border: `1px solid ${
|
|
245
|
+
borderRadius: tokens.cornerRadius,
|
|
246
|
+
border: `1px solid ${tokens.primary}4d`,
|
|
245
247
|
fontSize: 14,
|
|
246
248
|
}}
|
|
247
249
|
>
|
|
@@ -251,21 +253,21 @@ function TicketTransferRow({
|
|
|
251
253
|
? `. The link works until ${formatWhen(pending.expiresAt)}.`
|
|
252
254
|
: "."}
|
|
253
255
|
</div>
|
|
254
|
-
<div style={{ fontSize: 13, color:
|
|
256
|
+
<div style={{ fontSize: 13, color: tokens.muted, marginTop: 4 }}>
|
|
255
257
|
{pending.open
|
|
256
|
-
? "
|
|
257
|
-
: /* Lapsed but not yet swept. Still `pending`, still withdrawable
|
|
258
|
+
? t("forge.ticket_transfer.pending_open")
|
|
259
|
+
: /* Lapsed but not yet swept. Still `pending`, still withdrawable -
|
|
258
260
|
hiding it would leave the sender with a live row and no way to
|
|
259
261
|
address it. */
|
|
260
|
-
"
|
|
262
|
+
t("forge.ticket_transfer.pending_expired")}
|
|
261
263
|
</div>
|
|
262
264
|
<button
|
|
263
265
|
type="button"
|
|
264
266
|
onClick={() => void onCancel(pending.transferId)}
|
|
265
267
|
disabled={cancel.isPending}
|
|
266
|
-
style={{ ...ghostButton(
|
|
268
|
+
style={{ ...ghostButton(tokens, cancel.isPending), marginTop: 10 }}
|
|
267
269
|
>
|
|
268
|
-
{cancel.isPending ? "
|
|
270
|
+
{cancel.isPending ? t("forge.ticket_transfer.withdrawing") : t("forge.ticket_transfer.withdraw")}
|
|
269
271
|
</button>
|
|
270
272
|
</div>
|
|
271
273
|
)}
|
|
@@ -277,7 +279,7 @@ function TicketTransferRow({
|
|
|
277
279
|
)}
|
|
278
280
|
|
|
279
281
|
{error && (
|
|
280
|
-
<p style={{ fontSize: 13, marginTop: 10, color:
|
|
282
|
+
<p style={{ fontSize: 13, marginTop: 10, color: tokens.primary }} role="alert">
|
|
281
283
|
{error}
|
|
282
284
|
</p>
|
|
283
285
|
)}
|
|
@@ -289,24 +291,22 @@ function TicketTransferRow({
|
|
|
289
291
|
required
|
|
290
292
|
value={toEmail}
|
|
291
293
|
onChange={(event) => setToEmail(event.target.value)}
|
|
292
|
-
placeholder="
|
|
293
|
-
aria-label="
|
|
294
|
-
style={inputStyle(
|
|
294
|
+
placeholder={t("forge.ticket_transfer.recipient_email_placeholder")}
|
|
295
|
+
aria-label={t("forge.ticket_transfer.recipient_email_label")}
|
|
296
|
+
style={inputStyle(tokens)}
|
|
295
297
|
/>
|
|
296
298
|
<input
|
|
297
299
|
type="text"
|
|
298
300
|
maxLength={240}
|
|
299
301
|
value={toName}
|
|
300
302
|
onChange={(event) => setToName(event.target.value)}
|
|
301
|
-
placeholder="
|
|
302
|
-
aria-label="
|
|
303
|
-
style={inputStyle(
|
|
303
|
+
placeholder={t("forge.ticket_transfer.recipient_name_placeholder")}
|
|
304
|
+
aria-label={t("forge.ticket_transfer.recipient_name_label")}
|
|
305
|
+
style={inputStyle(tokens)}
|
|
304
306
|
/>
|
|
305
|
-
<p style={{ fontSize: 12, color:
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
<button type="submit" disabled={send.isPending} style={primaryButton(t, send.isPending)}>
|
|
309
|
-
{send.isPending ? "Sending…" : "Send ticket"}
|
|
307
|
+
<p style={{ fontSize: 12, color: tokens.muted, margin: 0 }}>{t("forge.ticket_transfer.send_note")}</p>
|
|
308
|
+
<button type="submit" disabled={send.isPending} style={primaryButton(tokens, send.isPending)}>
|
|
309
|
+
{send.isPending ? t("forge.ticket_transfer.sending") : t("forge.ticket_transfer.send")}
|
|
310
310
|
</button>
|
|
311
311
|
</form>
|
|
312
312
|
)}
|
|
@@ -339,7 +339,8 @@ export interface ClaimTicketTransferProps {
|
|
|
339
339
|
* was cancelled since — comes back from the server with its own wording.
|
|
340
340
|
*/
|
|
341
341
|
export function ClaimTicketTransfer({ token, onClaimed, className, style }: ClaimTicketTransferProps) {
|
|
342
|
-
const
|
|
342
|
+
const tokens = useThemeTokens();
|
|
343
|
+
const t = useForgeT();
|
|
343
344
|
const claim = useClaimPassTransfer();
|
|
344
345
|
const [name, setName] = useState("");
|
|
345
346
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -348,18 +349,18 @@ export function ClaimTicketTransfer({ token, onClaimed, className, style }: Clai
|
|
|
348
349
|
const card: CSSProperties = {
|
|
349
350
|
maxWidth: 460,
|
|
350
351
|
margin: "0 auto",
|
|
351
|
-
color:
|
|
352
|
-
fontFamily:
|
|
352
|
+
color: tokens.text,
|
|
353
|
+
fontFamily: tokens.fontFamily,
|
|
353
354
|
...style,
|
|
354
355
|
};
|
|
355
356
|
|
|
356
357
|
if (!token) {
|
|
357
358
|
return (
|
|
358
359
|
<div className={className} style={{ ...card, textAlign: "center" }}>
|
|
359
|
-
<h1 style={{ fontSize: 24, fontWeight: 700, fontFamily:
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
</p>
|
|
360
|
+
<h1 style={{ fontSize: 24, fontWeight: 700, fontFamily: tokens.headingFontFamily }}>
|
|
361
|
+
{t("forge.ticket_transfer.invalid_title")}
|
|
362
|
+
</h1>
|
|
363
|
+
<p style={{ color: tokens.muted, marginTop: 8 }}>{t("forge.ticket_transfer.invalid_body")}</p>
|
|
363
364
|
</div>
|
|
364
365
|
);
|
|
365
366
|
}
|
|
@@ -367,7 +368,9 @@ export function ClaimTicketTransfer({ token, onClaimed, className, style }: Clai
|
|
|
367
368
|
if (claimed) {
|
|
368
369
|
return (
|
|
369
370
|
<div className={className} style={card}>
|
|
370
|
-
<h1 style={{ fontSize: 24, fontWeight: 700, fontFamily:
|
|
371
|
+
<h1 style={{ fontSize: 24, fontWeight: 700, fontFamily: tokens.headingFontFamily }}>
|
|
372
|
+
{t("forge.ticket_transfer.claimed_title")}
|
|
373
|
+
</h1>
|
|
371
374
|
<p style={{ marginTop: 8 }}>
|
|
372
375
|
Ticket <strong>{claimed.eventPassId}</strong> is now in your name. We have emailed it to{" "}
|
|
373
376
|
<strong>{claimed.toEmail}</strong>. Bring that QR code to the door.
|
|
@@ -386,35 +389,33 @@ export function ClaimTicketTransfer({ token, onClaimed, className, style }: Clai
|
|
|
386
389
|
} catch (err) {
|
|
387
390
|
// "Already claimed" and "expired" are materially different answers to
|
|
388
391
|
// somebody standing outside a venue. Never collapsed into one sentence.
|
|
389
|
-
setError(serverMessage(err, "
|
|
392
|
+
setError(serverMessage(err, t("forge.ticket_transfer.claim_error")));
|
|
390
393
|
}
|
|
391
394
|
};
|
|
392
395
|
|
|
393
396
|
return (
|
|
394
397
|
<div className={className} style={card}>
|
|
395
|
-
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8, fontFamily:
|
|
396
|
-
|
|
398
|
+
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8, fontFamily: tokens.headingFontFamily }}>
|
|
399
|
+
{t("forge.ticket_transfer.claim_title")}
|
|
397
400
|
</h1>
|
|
398
|
-
<p style={{ color:
|
|
399
|
-
Someone sent you their ticket. Tell us what to put on it. You do not need an account.
|
|
400
|
-
</p>
|
|
401
|
+
<p style={{ color: tokens.muted, marginBottom: 24 }}>{t("forge.ticket_transfer.claim_subtitle")}</p>
|
|
401
402
|
<form onSubmit={onSubmit} style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
|
402
403
|
<input
|
|
403
404
|
type="text"
|
|
404
405
|
maxLength={240}
|
|
405
406
|
value={name}
|
|
406
407
|
onChange={(event) => setName(event.target.value)}
|
|
407
|
-
placeholder="
|
|
408
|
-
aria-label="
|
|
409
|
-
style={inputStyle(
|
|
408
|
+
placeholder={t("forge.ticket_transfer.claim_name_placeholder")}
|
|
409
|
+
aria-label={t("forge.ticket_transfer.claim_name_label")}
|
|
410
|
+
style={inputStyle(tokens)}
|
|
410
411
|
/>
|
|
411
412
|
{error && (
|
|
412
|
-
<p style={{ color:
|
|
413
|
+
<p style={{ color: tokens.primary, fontSize: 14 }} role="alert">
|
|
413
414
|
{error}
|
|
414
415
|
</p>
|
|
415
416
|
)}
|
|
416
|
-
<button type="submit" disabled={claim.isPending} style={primaryButton(
|
|
417
|
-
{claim.isPending ? "
|
|
417
|
+
<button type="submit" disabled={claim.isPending} style={primaryButton(tokens, claim.isPending)}>
|
|
418
|
+
{claim.isPending ? t("forge.ticket_transfer.claiming") : t("forge.ticket_transfer.claim_submit")}
|
|
418
419
|
</button>
|
|
419
420
|
</form>
|
|
420
421
|
</div>
|
|
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
|
|
2
2
|
import { User, LogIn, LogOut } from "lucide-react";
|
|
3
3
|
import { usePublicAuth } from "../../contexts/PublicAuthContext";
|
|
4
4
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
5
|
+
import { useForgeT } from "../../i18n";
|
|
5
6
|
|
|
6
7
|
export interface UserMenuProps {
|
|
7
8
|
/** Custom trigger icon (defaults to a user icon). */
|
|
@@ -41,7 +42,8 @@ export function UserMenu({
|
|
|
41
42
|
className,
|
|
42
43
|
style,
|
|
43
44
|
}: UserMenuProps) {
|
|
44
|
-
const
|
|
45
|
+
const tokens = useThemeTokens();
|
|
46
|
+
const t = useForgeT();
|
|
45
47
|
const { user, logout } = usePublicAuth();
|
|
46
48
|
const [open, setOpen] = useState(false);
|
|
47
49
|
const ref = useRef<HTMLDivElement>(null);
|
|
@@ -63,7 +65,7 @@ export function UserMenu({
|
|
|
63
65
|
}, [open]);
|
|
64
66
|
|
|
65
67
|
const item: React.CSSProperties = {
|
|
66
|
-
color:
|
|
68
|
+
color: tokens.primary,
|
|
67
69
|
background: "transparent",
|
|
68
70
|
border: "none",
|
|
69
71
|
cursor: "pointer",
|
|
@@ -81,13 +83,13 @@ export function UserMenu({
|
|
|
81
83
|
<button
|
|
82
84
|
type="button"
|
|
83
85
|
onClick={() => setOpen((v) => !v)}
|
|
84
|
-
aria-label="
|
|
86
|
+
aria-label={t("forge.user_menu.aria_trigger")}
|
|
85
87
|
aria-expanded={open}
|
|
86
88
|
style={{
|
|
87
89
|
background: "transparent",
|
|
88
90
|
border: "none",
|
|
89
91
|
cursor: "pointer",
|
|
90
|
-
color:
|
|
92
|
+
color: tokens.text,
|
|
91
93
|
display: "inline-flex",
|
|
92
94
|
alignItems: "center",
|
|
93
95
|
padding: 4,
|
|
@@ -105,14 +107,14 @@ export function UserMenu({
|
|
|
105
107
|
...(align === "left" ? { left: 0 } : { right: 0 }),
|
|
106
108
|
minWidth: 220,
|
|
107
109
|
maxWidth: "calc(100vw - 32px)",
|
|
108
|
-
background:
|
|
109
|
-
color:
|
|
110
|
-
border: `1px solid ${
|
|
111
|
-
borderRadius:
|
|
110
|
+
background: tokens.background,
|
|
111
|
+
color: tokens.text,
|
|
112
|
+
border: `1px solid ${tokens.border}`,
|
|
113
|
+
borderRadius: tokens.cornerRadius,
|
|
112
114
|
boxShadow: "0 10px 30px -12px rgba(0,0,0,0.4)",
|
|
113
115
|
padding: 16,
|
|
114
116
|
zIndex: 60,
|
|
115
|
-
fontFamily:
|
|
117
|
+
fontFamily: tokens.fontFamily,
|
|
116
118
|
}}
|
|
117
119
|
>
|
|
118
120
|
{user ? (
|
|
@@ -126,7 +128,7 @@ export function UserMenu({
|
|
|
126
128
|
<div
|
|
127
129
|
style={{
|
|
128
130
|
paddingTop: 8,
|
|
129
|
-
borderTop: `1px solid ${
|
|
131
|
+
borderTop: `1px solid ${tokens.border}`,
|
|
130
132
|
display: "flex",
|
|
131
133
|
flexDirection: "column",
|
|
132
134
|
gap: 10,
|
|
@@ -134,7 +136,7 @@ export function UserMenu({
|
|
|
134
136
|
}}
|
|
135
137
|
>
|
|
136
138
|
<a href={accountHref} role="menuitem" style={item} onClick={() => setOpen(false)}>
|
|
137
|
-
|
|
139
|
+
{t("forge.user_menu.account")}
|
|
138
140
|
</a>
|
|
139
141
|
<button
|
|
140
142
|
type="button"
|
|
@@ -145,17 +147,17 @@ export function UserMenu({
|
|
|
145
147
|
void logout();
|
|
146
148
|
}}
|
|
147
149
|
>
|
|
148
|
-
<LogOut size={14} />
|
|
150
|
+
<LogOut size={14} /> {t("forge.user_menu.log_out")}
|
|
149
151
|
</button>
|
|
150
152
|
</div>
|
|
151
153
|
</div>
|
|
152
154
|
) : (
|
|
153
155
|
<div style={{ display: "flex", flexDirection: "column", gap: 10, alignItems: "flex-start" }}>
|
|
154
156
|
<a href={loginHref} role="menuitem" style={item} onClick={() => setOpen(false)}>
|
|
155
|
-
<LogIn size={16} />
|
|
157
|
+
<LogIn size={16} /> {t("forge.user_menu.log_in")}
|
|
156
158
|
</a>
|
|
157
159
|
<a href={signupHref} role="menuitem" style={item} onClick={() => setOpen(false)}>
|
|
158
|
-
|
|
160
|
+
{t("forge.user_menu.create_account")}
|
|
159
161
|
</a>
|
|
160
162
|
</div>
|
|
161
163
|
)}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { ForgeClientProvider } from "../../../provider/ForgeProvider";
|
|
5
|
+
import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
|
|
6
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
7
|
+
import { communityKeys, type CommunitySpace } from "../../../data/queries/useCommunity";
|
|
8
|
+
import { AccountDashboard } from "../AccountDashboard";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The account page's way into the members corner.
|
|
12
|
+
*
|
|
13
|
+
* Before this, a signed-in member landed on the membership card and the only
|
|
14
|
+
* doors on it led back to billing: change the plan, cancel it, fix the card.
|
|
15
|
+
* The community they were paying for was reachable only by typing the URL.
|
|
16
|
+
*
|
|
17
|
+
* The gate is the server's own answer. `/public/community/spaces` returns the
|
|
18
|
+
* open spaces PLUS the ones gated to a tier the caller holds, so the list being
|
|
19
|
+
* empty is the server saying "there is nothing here you can open". Drawing the
|
|
20
|
+
* link anyway would walk a member into a bounce, which is why the empty case is
|
|
21
|
+
* asserted as hard as the happy one.
|
|
22
|
+
*
|
|
23
|
+
* Rendered through `react-dom/server`: no DOM, no effects, so the spaces query
|
|
24
|
+
* is SEEDED into the react-query cache rather than mocked. That keeps the real
|
|
25
|
+
* `useCommunitySpaces` in the path, and a rename of its query key fails here.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const PROFILE_ID = "profile-1";
|
|
29
|
+
|
|
30
|
+
const space = (overrides: Partial<CommunitySpace> = {}): CommunitySpace => ({
|
|
31
|
+
id: "space-1",
|
|
32
|
+
profileId: PROFILE_ID,
|
|
33
|
+
name: "Backstage",
|
|
34
|
+
slug: "backstage",
|
|
35
|
+
description: null,
|
|
36
|
+
type: "feed",
|
|
37
|
+
courseId: null,
|
|
38
|
+
order: 0,
|
|
39
|
+
archivedAt: null,
|
|
40
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
41
|
+
tiers: [],
|
|
42
|
+
...overrides,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** A signed-in member holding a paid tier that renews. */
|
|
46
|
+
const member = {
|
|
47
|
+
id: "account-1",
|
|
48
|
+
email: "fan@example.com",
|
|
49
|
+
firstName: "Fan",
|
|
50
|
+
lastName: "Person",
|
|
51
|
+
kind: "fan",
|
|
52
|
+
status: "active",
|
|
53
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
54
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
55
|
+
membership: {
|
|
56
|
+
id: "membership-1",
|
|
57
|
+
membershipTierId: "tier-1",
|
|
58
|
+
endDate: "2026-12-01T00:00:00.000Z",
|
|
59
|
+
status: "active",
|
|
60
|
+
startDate: "2026-01-01T00:00:00.000Z",
|
|
61
|
+
subscriptionAmount: 10,
|
|
62
|
+
subscriptionCurrency: "USD",
|
|
63
|
+
billingCycle: "month",
|
|
64
|
+
membershipTier: { id: "tier-1", name: "Inner Circle", benefits: [] },
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The membership tab as a Forge code website draws it.
|
|
70
|
+
*
|
|
71
|
+
* `spaces === null` stands for the query never having answered (the member has
|
|
72
|
+
* no accessible spaces cached), which is the state a non-member sits in.
|
|
73
|
+
*/
|
|
74
|
+
function render(opts: { spaces: CommunitySpace[] | null; onNavigateCommunity?: () => void }): string {
|
|
75
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
76
|
+
if (opts.spaces) queryClient.setQueryData(communityKeys.spaces(PROFILE_ID), opts.spaces);
|
|
77
|
+
|
|
78
|
+
return renderToStaticMarkup(
|
|
79
|
+
<QueryClientProvider client={queryClient}>
|
|
80
|
+
<ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
|
|
81
|
+
<PublicAuthContext.Provider
|
|
82
|
+
value={{ user: member, currencies: null, userSelectedCurrency: "USD", logout: () => {} } as never}
|
|
83
|
+
>
|
|
84
|
+
<ForgeThemeProvider
|
|
85
|
+
theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
|
|
86
|
+
>
|
|
87
|
+
<AccountDashboard
|
|
88
|
+
tab="membership"
|
|
89
|
+
onNavigateMembership={() => {}}
|
|
90
|
+
onNavigateCommunity={opts.onNavigateCommunity}
|
|
91
|
+
/>
|
|
92
|
+
</ForgeThemeProvider>
|
|
93
|
+
</PublicAuthContext.Provider>
|
|
94
|
+
</ForgeClientProvider>
|
|
95
|
+
</QueryClientProvider>,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe("AccountDashboard: the way into the members corner", () => {
|
|
100
|
+
it("REGRESSION: offers the community to a member who has a space to open", () => {
|
|
101
|
+
// The gap this closes. Without it the membership card's only exits are
|
|
102
|
+
// Change and Cancel, and the community is unreachable from the account page.
|
|
103
|
+
const html = render({ spaces: [space()], onNavigateCommunity: () => {} });
|
|
104
|
+
|
|
105
|
+
expect(html).toContain("Community spaces");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("REGRESSION: says nothing when the member can open no space", () => {
|
|
109
|
+
// A link here would promise a door that is locked: the server returned no
|
|
110
|
+
// space this account may enter, so the honest account page is silent.
|
|
111
|
+
const html = render({ spaces: [], onNavigateCommunity: () => {} });
|
|
112
|
+
|
|
113
|
+
expect(html).not.toContain("Community spaces");
|
|
114
|
+
// The billing actions are untouched, so this is the entry point missing and
|
|
115
|
+
// not the whole card failing to draw.
|
|
116
|
+
expect(html).toContain("Change");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("draws nothing when the host site has no community route to send anyone to", () => {
|
|
120
|
+
// `onNavigateCommunity` is optional. A site that leaves it out gets no
|
|
121
|
+
// button, even with spaces in the cache, rather than a dead one.
|
|
122
|
+
const html = render({ spaces: [space()] });
|
|
123
|
+
|
|
124
|
+
expect(html).not.toContain("Community spaces");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("stays quiet while the spaces query has not answered yet", () => {
|
|
128
|
+
// First paint has no cached answer. Guessing "yes" here would flash a link
|
|
129
|
+
// and then withdraw it from members who never had access.
|
|
130
|
+
const html = render({ spaces: null, onNavigateCommunity: () => {} });
|
|
131
|
+
|
|
132
|
+
expect(html).not.toContain("Community spaces");
|
|
133
|
+
});
|
|
134
|
+
});
|