From bda23f7921a1a365a3a122df5ce179c4b2ef7af7 Mon Sep 17 00:00:00 2001 From: Atakan Seckin Date: Thu, 30 Jul 2026 12:58:05 +0200 Subject: [PATCH 01/15] Add email-link capture plumbing: gated action types, pending store, request action Client-side foundation for the lightweight accounts flow: the gated action type union (post_vote / post_subscribe / forecast), a localStorage-backed pending record store shared across trees via useSyncExternalStore (plus the sessionStorage stash for OAuth carry-through), the wire mapping shared by the email and social paths, and the requestEmailLink API client + server action (Turnstile headers, always-204 anti-enumeration contract). Co-Authored-By: Claude Fable 5 --- front_end/src/app/(main)/accounts/actions.ts | 38 ++++++ .../components/email_capture/pending_store.ts | 125 ++++++++++++++++++ .../use_email_capture_pending.ts | 12 ++ .../src/services/api/auth/auth.server.ts | 28 +++- front_end/src/types/gated_actions.ts | 27 ++++ front_end/src/utils/gated_actions.ts | 33 +++++ 6 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 front_end/src/components/email_capture/pending_store.ts create mode 100644 front_end/src/components/email_capture/use_email_capture_pending.ts create mode 100644 front_end/src/types/gated_actions.ts create mode 100644 front_end/src/utils/gated_actions.ts diff --git a/front_end/src/app/(main)/accounts/actions.ts b/front_end/src/app/(main)/accounts/actions.ts index 0673c60767..d226847629 100644 --- a/front_end/src/app/(main)/accounts/actions.ts +++ b/front_end/src/app/(main)/accounts/actions.ts @@ -12,8 +12,10 @@ import ServerProfileApi from "@/services/api/profile/profile.server"; import { getAuthCookieManager } from "@/services/auth_tokens"; import { LanguageService } from "@/services/language_service"; import { AuthResponse, SignUpResponse } from "@/types/auth"; +import { GatedActionInput } from "@/types/gated_actions"; import { CurrentUser } from "@/types/users"; import { ApiError } from "@/utils/core/errors"; +import { mapGatedActionToWire } from "@/utils/gated_actions"; import { getPublicSettings } from "@/utils/public_settings.server"; export type ApiErrorPayload = { @@ -222,6 +224,42 @@ export async function simplifiedSignUpAction( } } +export async function requestEmailLinkAction(params: { + email: string; + redirectUrl?: string | null; + gatedAction?: GatedActionInput | null; + turnstileToken?: string; +}): Promise<{ errors?: ApiErrorPayload | null }> { + const headersList = await headers(); + const ipAddress = + headersList.get("CF-Connecting-IP") || headersList.get("X-Real-IP"); + + try { + await ServerAuthApi.requestEmailLink( + { + email: params.email, + redirect_url: params.redirectUrl ?? null, + gated_action: params.gatedAction + ? mapGatedActionToWire(params.gatedAction) + : null, + }, + { + ...(params.turnstileToken + ? { "cf-turnstile-response": params.turnstileToken } + : {}), + ...(ipAddress ? { "CF-Connecting-IP": ipAddress } : {}), + } + ); + return { errors: null }; + } catch (err: unknown) { + return { + errors: ApiError.isApiError(err) + ? (err.data as ApiErrorPayload) + : { detail: "Something went wrong. Please try again." }, + }; + } +} + export async function verifyEmailLinkAction( userId: string, token: string diff --git a/front_end/src/components/email_capture/pending_store.ts b/front_end/src/components/email_capture/pending_store.ts new file mode 100644 index 0000000000..70b8bab234 --- /dev/null +++ b/front_end/src/components/email_capture/pending_store.ts @@ -0,0 +1,125 @@ +import { + EmailCapturePendingRecord, + SocialGatedActionStash, +} from "@/types/gated_actions"; + +// Matches the backend link TTL (AUTH_EMAIL_LINK_TIMEOUT, 24h default). An +// older record means every link it produced is dead, so it reads as absent. +const PENDING_TTL_MS = 24 * 60 * 60 * 1000; +const PENDING_KEY = "emailCapturePending:v1"; +const CHANGE_EVENT = "emailCapturePendingChange"; + +const SOCIAL_STASH_KEY = "socialGatedAction:v1"; +const SOCIAL_STASH_TTL_MS = 15 * 60 * 1000; + +// The banner (TopChrome) and the drawer (GlobalModals) live in separate trees, +// so the record is exposed through a useSyncExternalStore-compatible store: a +// custom event syncs same-tab writes, the native "storage" event other tabs. +let cachedRaw: string | null | undefined; +let cachedRecord: EmailCapturePendingRecord | null = null; + +const parseRecord = (raw: string | null): EmailCapturePendingRecord | null => { + if (!raw) return null; + try { + const record = JSON.parse(raw) as EmailCapturePendingRecord; + if ( + typeof record?.email !== "string" || + typeof record?.sentAt !== "number" || + Date.now() - record.sentAt > PENDING_TTL_MS + ) { + return null; + } + return record; + } catch { + return null; + } +}; + +export const readPending = (): EmailCapturePendingRecord | null => { + if (typeof window === "undefined") return null; + let raw: string | null = null; + try { + raw = window.localStorage.getItem(PENDING_KEY); + } catch { + return null; + } + if (raw !== cachedRaw) { + cachedRaw = raw; + cachedRecord = parseRecord(raw); + } + // Expiry check must run on every read, not only on raw changes + if (cachedRecord && Date.now() - cachedRecord.sentAt > PENDING_TTL_MS) { + cachedRecord = null; + } + return cachedRecord; +}; + +const notifyChange = () => { + window.dispatchEvent(new Event(CHANGE_EVENT)); +}; + +export const writePending = (record: EmailCapturePendingRecord) => { + try { + window.localStorage.setItem(PENDING_KEY, JSON.stringify(record)); + } catch { + return; + } + notifyChange(); +}; + +export const clearPending = () => { + try { + window.localStorage.removeItem(PENDING_KEY); + } catch { + return; + } + notifyChange(); +}; + +export const subscribePending = (onChange: () => void) => { + window.addEventListener(CHANGE_EVENT, onChange); + window.addEventListener("storage", onChange); + return () => { + window.removeEventListener(CHANGE_EVENT, onChange); + window.removeEventListener("storage", onChange); + }; +}; + +// The Google path is a full-page OAuth redirect: the action is stashed in +// sessionStorage before leaving and attached to the code exchange on return. +export const stashSocialGatedAction = ( + stash: Omit +) => { + try { + window.sessionStorage.setItem( + SOCIAL_STASH_KEY, + JSON.stringify({ ...stash, stashedAt: Date.now() }) + ); + } catch { + // Losing the stash only means the user redoes the tap after OAuth + } +}; + +export const takeSocialGatedAction = (): SocialGatedActionStash | null => { + let raw: string | null = null; + try { + raw = window.sessionStorage.getItem(SOCIAL_STASH_KEY); + window.sessionStorage.removeItem(SOCIAL_STASH_KEY); + } catch { + return null; + } + if (!raw) return null; + try { + const stash = JSON.parse(raw) as SocialGatedActionStash; + if ( + !stash?.gatedAction || + typeof stash.stashedAt !== "number" || + Date.now() - stash.stashedAt > SOCIAL_STASH_TTL_MS + ) { + return null; + } + return stash; + } catch { + return null; + } +}; diff --git a/front_end/src/components/email_capture/use_email_capture_pending.ts b/front_end/src/components/email_capture/use_email_capture_pending.ts new file mode 100644 index 0000000000..0077b42e85 --- /dev/null +++ b/front_end/src/components/email_capture/use_email_capture_pending.ts @@ -0,0 +1,12 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +import { readPending, subscribePending } from "./pending_store"; + +const getServerSnapshot = () => null; + +const useEmailCapturePending = () => + useSyncExternalStore(subscribePending, readPending, getServerSnapshot); + +export default useEmailCapturePending; diff --git a/front_end/src/services/api/auth/auth.server.ts b/front_end/src/services/api/auth/auth.server.ts index e1c5d77250..ffae5d0a8b 100644 --- a/front_end/src/services/api/auth/auth.server.ts +++ b/front_end/src/services/api/auth/auth.server.ts @@ -10,6 +10,7 @@ import { SocialProviderType, } from "@/types/auth"; import { serverFetcher } from "@/utils/core/fetch/fetch.server"; +import { GatedActionWire } from "@/utils/gated_actions"; export type SignUpProps = { email: string; @@ -58,14 +59,19 @@ class ServerAuthApiClass extends ApiService { async exchangeSocialOauthCode( provider: SocialProviderType, code: string, - redirect_uri: string + redirect_uri: string, + gated_action?: GatedActionWire | null ): Promise { return this.post< SocialAuthResponse, - { code: string; redirect_uri: string } + { + code: string; + redirect_uri: string; + gated_action?: GatedActionWire | null; + } >( `/auth/social/${provider}/`, - { code, redirect_uri }, + { code, redirect_uri, ...(gated_action ? { gated_action } : {}) }, {}, { passAuthHeader: false } ); @@ -108,6 +114,22 @@ class ServerAuthApiClass extends ApiService { ); } + async requestEmailLink( + body: { + email: string; + redirect_url?: string | null; + gated_action?: GatedActionWire | null; + }, + headers: HeadersInit + ) { + return this.post( + "/auth/email-link/", + body, + { headers }, + { passAuthHeader: false } + ); + } + async verifyEmailLink(userId: string, token: string) { return this.post( "/auth/email-link/verify/", diff --git a/front_end/src/types/gated_actions.ts b/front_end/src/types/gated_actions.ts new file mode 100644 index 0000000000..39e77c90db --- /dev/null +++ b/front_end/src/types/gated_actions.ts @@ -0,0 +1,27 @@ +import type { ForecastPayload } from "@/services/api/questions/questions.server"; +import { PostSubscription } from "@/types/post"; + +export type GatedActionTrigger = "post_vote" | "post_subscribe" | "forecast"; + +export type GatedActionInput = + | { type: "post_vote"; payload: { post: number; direction: 1 | -1 } } + | { + type: "post_subscribe"; + payload: { post: number; subscriptions: PostSubscription[] }; + } + | { type: "forecast"; payload: ForecastPayload[] }; + +export type EmailCapturePendingRecord = { + email: string; + sentAt: number; + trigger: GatedActionTrigger; + surface?: string; + gatedAction: GatedActionInput | null; + redirectUrl: string; +}; + +export type SocialGatedActionStash = { + gatedAction: GatedActionInput; + trigger: GatedActionTrigger; + stashedAt: number; +}; diff --git a/front_end/src/utils/gated_actions.ts b/front_end/src/utils/gated_actions.ts new file mode 100644 index 0000000000..34e2ca7d62 --- /dev/null +++ b/front_end/src/utils/gated_actions.ts @@ -0,0 +1,33 @@ +import { GatedActionInput } from "@/types/gated_actions"; + +export type GatedActionWire = { + type: string; + payload: unknown; +}; + +/** + * Maps a client-side gated action to the backend envelope. Forecast payloads + * use the same wire shape as ServerQuestionsApi.createForecasts. Shared by the + * email-link request and the social code exchange. + */ +export const mapGatedActionToWire = ( + action: GatedActionInput +): GatedActionWire => { + if (action.type === "forecast") { + return { + type: "forecast", + payload: action.payload.map( + ({ questionId, forecastData, distributionInput, forecastEndTime }) => ({ + question: questionId, + continuous_cdf: forecastData.continuousCdf, + probability_yes: forecastData.probabilityYes, + probability_yes_per_category: forecastData.probabilityYesPerCategory, + distribution_input: distributionInput, + // May arrive as an ISO string when rehydrated from localStorage + end_time: forecastEndTime, + }) + ), + }; + } + return { type: action.type, payload: action.payload }; +}; From d0c8afd47e960932731185eda049aa3ae7093c22 Mon Sep 17 00:00:00 2001 From: Atakan Seckin Date: Thu, 30 Jul 2026 12:58:35 +0200 Subject: [PATCH 02/15] Add email capture drawer on a shared bottom-sheet primitive BottomDrawer wraps Base UI's Drawer (animated enter/exit even when mounted on demand, swipe-down dismiss, max height capped below the navbar). The capture drawer runs the options/input/sent state machine: subscribe opens on checkbox cards with only 'When it resolves' preselected, vote and forecast go straight to email; includes prefilled repeat state, resend cooldown, Google button with action stash, Turnstile, and per-trigger copy in en.json. Registered as the emailCapture modal type. Non-English locales pending translations:generate. Co-Authored-By: Claude Fable 5 --- front_end/messages/en.json | 62 +- .../email_capture/email_capture_drawer.tsx | 721 ++++++++++++++++++ front_end/src/components/global_modals.tsx | 16 + front_end/src/components/ui/drawer.tsx | 69 ++ front_end/src/contexts/modal_context.tsx | 11 +- 5 files changed, 877 insertions(+), 2 deletions(-) create mode 100644 front_end/src/components/email_capture/email_capture_drawer.tsx create mode 100644 front_end/src/components/ui/drawer.tsx diff --git a/front_end/messages/en.json b/front_end/messages/en.json index b4b32ac9e3..9f240f8e33 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -2377,5 +2377,65 @@ "midtermsHubSeatAdvantageTooltip": "{count} seat advantage", "midtermsHubSeatAdvantageOverTooltip": ">{count} seat advantage", "midtermsHubProbabilityTooltip": "{value}% probability", - "midtermsHubEvenTooltip": "Even" + "midtermsHubEvenTooltip": "Even", + "emailCaptureOptionsTitle": "Get updates on this question.", + "emailCaptureOptionsSubtitle": "Pick what you want to hear about.", + "emailCaptureOptionResolve": "When it resolves", + "emailCaptureOptionForecast": "Forecast changes", + "emailCaptureOptionDiscussion": "New discussion", + "emailCaptureOptionsContinue": "Continue", + "emailCaptureOptionsCaptionNone": "Pick at least one to continue.", + "emailCaptureSubscribeTitle": "Enter your email to follow this question", + "emailCaptureSubscribeBody": "We'll send you a link to complete this action.", + "emailCaptureVoteTitle": "Save your vote", + "emailCaptureVoteBody": "Enter your email. We'll send a link that saves your vote.", + "emailCaptureVoteBodyRepeat": "We'll send a new link to {email}. It saves this vote instead.", + "emailCaptureVoteCaption": "The link signs you in and saves your vote. It works for a day.", + "emailCaptureForecastTitle": "Save your forecast", + "emailCaptureForecastBody": "Enter your email. We'll send a link that saves your forecast.", + "emailCaptureForecastBodyRepeat": "We'll send a new link to {email}. It saves this forecast instead.", + "emailCaptureForecastCaption": "The link signs you in and saves your forecast. It works for a day.", + "emailCaptureForecastBodyNoDraft": "Enter your email. We'll send a link that signs you in to forecast.", + "emailCaptureSignInCaption": "The link signs you in. It works for a day.", + "emailCaptureBodyRepeat": "We'll send a new link to {email}.", + "emailCaptureEmailLabel": "Email", + "emailCaptureSend": "Send link", + "emailCaptureSendNew": "Send new link", + "emailCaptureSending": "Sending...", + "emailCaptureTryAgain": "Try again", + "emailCaptureFormatError": "That doesn't look like an email address. Check it and try again.", + "emailCaptureServerError": "Something went wrong on our end. Your email was not sent. Try again.", + "emailCaptureSentTitle": "Check your email", + "emailCaptureSentBody": "We sent a link to {email}.", + "emailCaptureSentActionVote": "Click it to save your vote.", + "emailCaptureSentActionForecast": "Click it to save your forecast.", + "emailCaptureSentActionSubscribeAll": "Click it to get updates on this question.", + "emailCaptureSentActionSignIn": "Click it to sign in and make your forecast.", + "emailCaptureSentActionSubscribeOne": "Click it to get updates when {a}.", + "emailCaptureSentActionSubscribeTwo": "Click it to get updates when {a} or {b}.", + "emailCapturePhraseResolve": "this resolves", + "emailCapturePhraseForecast": "the forecast moves", + "emailCapturePhraseDiscussion": "there is new discussion", + "emailCaptureSentNote": "The link signs you in and works for a day. You can open it on any device.", + "emailCaptureSentNoteRepeat": "This link replaces your earlier one. Only your newest link works.", + "emailCaptureDone": "Done", + "emailCaptureWrongAddress": "Wrong address? Use a different email", + "emailCaptureUseDifferentEmail": "Use a different email", + "emailCaptureRecapSentTo": "Sent to {email}.", + "emailCaptureRecapActionVote": "Click the link in that email to save your vote.", + "emailCaptureRecapActionForecast": "Click the link in that email to save your forecast.", + "emailCaptureRecapActionSubscribe": "Click the link in that email to turn on your updates.", + "emailCaptureResend": "Resend email", + "emailCaptureResendIn": "Resend in {seconds}s", + "emailCaptureResendFeedback": "Sent. Check {email}.", + "emailCaptureGoogle": "Sign in with Google", + "emailCapturePassword": "Sign in with password", + "emailCaptureBack": "Back", + "emailConfirmBannerText": "We sent a confirmation email to .", + "emailLinkRequestNew": "Send a new link", + "emailLinkNewSent": "We sent a new link to {email}.", + "emailLinkNewSentWithAction": "We sent a new link to {email}. It carries the same action.", + "notifyMeCtaLabel": "Notify me when this resolves", + "notifyMeCtaFollowing": "You're following this question", + "emailCaptureSubscribeBodyRepeat": "We'll send a new link to {email}. It turns on these updates instead." } diff --git a/front_end/src/components/email_capture/email_capture_drawer.tsx b/front_end/src/components/email_capture/email_capture_drawer.tsx new file mode 100644 index 0000000000..abf2c77e6b --- /dev/null +++ b/front_end/src/components/email_capture/email_capture_drawer.tsx @@ -0,0 +1,721 @@ +"use client"; + +import { faArrowLeft, faCheck } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Turnstile, TurnstileInstance } from "@marsidev/react-turnstile"; +import { usePathname, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { FC, useEffect, useMemo, useRef, useState } from "react"; + +import { requestEmailLinkAction } from "@/app/(main)/accounts/actions"; +import BaseModal from "@/components/base_modal"; +import { Google } from "@/components/icons/google"; +import { + getDefaultSubscriptionProps, + getInitialNotebookSubscriptions, +} from "@/components/post_subscribe/subscribe_button/utils"; +import Button from "@/components/ui/button"; +import BottomDrawer from "@/components/ui/drawer"; +import { Input } from "@/components/ui/form_field"; +import { useModal } from "@/contexts/modal_context"; +import { usePublicSettings } from "@/contexts/public_settings_context"; +import { useBreakpoint } from "@/hooks/tailwind"; +import { useServerAction } from "@/hooks/use_server_action"; +import useSocialAuth from "@/hooks/use_social_auth"; +import { GatedActionInput, GatedActionTrigger } from "@/types/gated_actions"; +import { PostSubscription, PostSubscriptionType } from "@/types/post"; +import { sendAnalyticsEvent } from "@/utils/analytics"; +import cn from "@/utils/core/cn"; + +import { + readPending, + stashSocialGatedAction, + writePending, +} from "./pending_store"; + +const RESEND_COOLDOWN_S = 30; + +type EmailCaptureView = "options" | "input" | "sent"; + +type SubscribeOptionId = "resolve" | "forecast" | "discussion"; + +type Props = { + isOpen: boolean; + onClose: () => void; + trigger: GatedActionTrigger; + surface?: string; + gatedAction?: GatedActionInput | null; + subscribePost?: { postId: number; isNotebook: boolean }; + initialView?: EmailCaptureView; +}; + +const OPTION_DEFS = [ + { id: "resolve", labelKey: "emailCaptureOptionResolve" }, + { id: "forecast", labelKey: "emailCaptureOptionForecast" }, + { id: "discussion", labelKey: "emailCaptureOptionDiscussion" }, +] as const satisfies readonly { id: SubscribeOptionId; labelKey: string }[]; + +const EmailCaptureDrawer: FC = ({ + isOpen, + onClose, + trigger, + surface, + gatedAction, + subscribePost, + initialView, +}) => { + const t = useTranslations(); + const { setCurrentModal } = useModal(); + const { PUBLIC_TURNSTILE_SITE_KEY } = usePublicSettings(); + const { socialProviders, getOAuthUrl } = useSocialAuth(); + const isDesktop = useBreakpoint("sm"); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const openedAsRecap = initialView === "sent"; + const hasOptionsStep = + trigger === "post_subscribe" && + !subscribePost?.isNotebook && + !openedAsRecap; + + const [view, setView] = useState( + initialView ?? (hasOptionsStep ? "options" : "input") + ); + const [selection, setSelection] = useState< + Record + >({ resolve: true, forecast: false, discussion: false }); + const [draft, setDraft] = useState(""); + const [editingEmail, setEditingEmail] = useState(false); + const [error, setError] = useState<"format" | "server" | null>(null); + const [sentEmail, setSentEmail] = useState(null); + const [wasRepeatSend, setWasRepeatSend] = useState(false); + const [lastSendAt, setLastSendAt] = useState(null); + const [resendFeedback, setResendFeedback] = useState(false); + const [cooldownLeft, setCooldownLeft] = useState(0); + + const [isTurnstileValidated, setIsTurnstileValidated] = useState( + !PUBLIC_TURNSTILE_SITE_KEY + ); + const turnstileRef = useRef(null); + const turnstileTokenRef = useRef(undefined); + const sentThisSessionRef = useRef(false); + + // The pending record backs the repeat/recap states. Read once per open; + // live updates while the sheet is open are our own writes. + const pending = useMemo(() => (isOpen ? readPending() : null), [isOpen]); + const prefilled = !openedAsRecap && !!pending && !editingEmail; + + const redirectUrl = useMemo(() => { + const query = searchParams.toString(); + return query ? `${pathname}?${query}` : pathname; + }, [pathname, searchParams]); + + const googleUrl = socialProviders?.some((p) => p.name === "google-oauth2") + ? getOAuthUrl("google-oauth2", pathname) + : null; + + useEffect(() => { + if (!isOpen) return; + sentThisSessionRef.current = false; + sendAnalyticsEvent("emailCaptureShown", { trigger, surface }); + if (hasOptionsStep) { + sendAnalyticsEvent("subscribeOptionsShown", { trigger, surface }); + } + if (openedAsRecap) { + const record = readPending(); + setSentEmail(record?.email ?? null); + setLastSendAt(record?.sentAt ?? null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + useEffect(() => { + if (!lastSendAt) return; + const tick = () => { + const left = Math.ceil( + RESEND_COOLDOWN_S - (Date.now() - lastSendAt) / 1000 + ); + setCooldownLeft(Math.max(0, left)); + }; + tick(); + const interval = setInterval(tick, 1000); + return () => clearInterval(interval); + }, [lastSendAt]); + + const selectedCount = OPTION_DEFS.filter((o) => selection[o.id]).length; + + const buildSubscriptions = (): PostSubscription[] => { + if (subscribePost?.isNotebook) return getInitialNotebookSubscriptions(); + const defaults = getDefaultSubscriptionProps(); + const subscriptions: PostSubscription[] = []; + if (selection.discussion) { + subscriptions.push({ + type: PostSubscriptionType.NEW_COMMENTS, + ...defaults[PostSubscriptionType.NEW_COMMENTS], + }); + } + if (selection.resolve) { + subscriptions.push({ + type: PostSubscriptionType.STATUS_CHANGE, + ...defaults[PostSubscriptionType.STATUS_CHANGE], + }); + } + if (selection.forecast) { + subscriptions.push({ + type: PostSubscriptionType.CP_CHANGE, + ...defaults[PostSubscriptionType.CP_CHANGE], + }); + } + return subscriptions; + }; + + const resolveGatedAction = (): GatedActionInput | null => { + if (trigger === "post_subscribe" && subscribePost) { + return { + type: "post_subscribe", + payload: { + post: subscribePost.postId, + subscriptions: buildSubscriptions(), + }, + }; + } + return gatedAction ?? pending?.gatedAction ?? null; + }; + + const validEmail = (value: string) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); + + const performSend = async ( + email: string, + action: GatedActionInput | null + ) => { + const response = await requestEmailLinkAction({ + email, + redirectUrl, + gatedAction: action, + turnstileToken: turnstileTokenRef.current, + }); + turnstileRef.current?.reset(); + if (response.errors) { + setError("server"); + sendAnalyticsEvent("emailSubmitFailed", { + trigger, + surface, + reason: + typeof response.errors.email !== "undefined" + ? "invalid_email" + : String(response.errors.message ?? "").includes("captcha") + ? "captcha_failed" + : "unknown", + }); + return false; + } + const sendAt = Date.now(); + writePending({ + email, + sentAt: sendAt, + trigger, + surface, + gatedAction: action, + redirectUrl, + }); + setSentEmail(email); + setLastSendAt(sendAt); + sentThisSessionRef.current = true; + sendAnalyticsEvent("emailSubmitted", { trigger, surface }); + return true; + }; + + const onSubmit = async () => { + const email = prefilled ? pending?.email ?? "" : draft.trim(); + if (!validEmail(email)) { + setError("format"); + sendAnalyticsEvent("emailSubmitFailed", { + trigger, + surface, + reason: "invalid_email", + }); + return; + } + setError(null); + const wasRepeat = prefilled || !!pending; + const ok = await performSend(email, resolveGatedAction()); + if (ok) { + setWasRepeatSend(wasRepeat); + setView("sent"); + } + }; + const [submit, isPending] = useServerAction(onSubmit); + + const onResend = async () => { + const record = readPending(); + if (!record) return; + const ok = await performSend(record.email, record.gatedAction); + if (ok) setResendFeedback(true); + }; + const [resend, isResending] = useServerAction(onResend); + + const handleClose = () => { + if (view !== "sent" && !sentThisSessionRef.current) { + sendAnalyticsEvent("captureAbandoned", { + trigger, + surface, + step: view === "options" ? "options" : "input", + }); + } + onClose(); + }; + + // On mobile the registry unmounts us on close, which would cut the exit + // animation short; play it first, then really close via onOpenChangeComplete + const [sheetOpen, setSheetOpen] = useState(true); + const requestClose = () => { + if (isDesktop) { + handleClose(); + } else { + setSheetOpen(false); + } + }; + + const handleContinue = () => { + if (selectedCount === 0) return; + sendAnalyticsEvent("subscribeOptionsContinued", { + trigger, + surface, + selected: OPTION_DEFS.filter((o) => selection[o.id]).map((o) => o.id), + }); + setView("input"); + }; + + const handleGoogle = () => { + if (!googleUrl) return; + const action = resolveGatedAction(); + if (action) { + stashSocialGatedAction({ gatedAction: action, trigger }); + } + window.location.href = googleUrl; + }; + + const inputCopy = (() => { + const email = pending?.email ?? ""; + switch (trigger) { + case "post_vote": + return { + title: t("emailCaptureVoteTitle"), + body: prefilled + ? t("emailCaptureVoteBodyRepeat", { email }) + : t("emailCaptureVoteBody"), + caption: t("emailCaptureVoteCaption"), + }; + case "forecast": { + // Opened without a drafted forecast (untouched slider): the link only + // signs the user in, so the copy must not promise saving anything + const hasDraft = !!gatedAction; + return { + title: t("emailCaptureForecastTitle"), + body: prefilled + ? hasDraft + ? t("emailCaptureForecastBodyRepeat", { email }) + : t("emailCaptureBodyRepeat", { email }) + : hasDraft + ? t("emailCaptureForecastBody") + : t("emailCaptureForecastBodyNoDraft"), + caption: hasDraft + ? t("emailCaptureForecastCaption") + : t("emailCaptureSignInCaption"), + }; + } + default: + return { + title: t("emailCaptureSubscribeTitle"), + body: prefilled + ? t("emailCaptureSubscribeBodyRepeat", { email }) + : t("emailCaptureSubscribeBody"), + caption: null, + }; + } + })(); + + const sentAction = (() => { + if (trigger === "post_vote") return t("emailCaptureSentActionVote"); + if (trigger === "forecast") { + // What actually went out: the fresh draft, else the still-pending + // earlier action (resolveGatedAction keeps it so we never clear it) + const sentType = (gatedAction ?? pending?.gatedAction)?.type; + if (sentType === "forecast") return t("emailCaptureSentActionForecast"); + if (sentType === "post_vote") return t("emailCaptureSentActionVote"); + if (sentType === "post_subscribe") + return t("emailCaptureSentActionSubscribeAll"); + return t("emailCaptureSentActionSignIn"); + } + const picked = OPTION_DEFS.filter((o) => selection[o.id]); + if ( + subscribePost?.isNotebook || + picked.length === 0 || + picked.length === 3 + ) { + return t("emailCaptureSentActionSubscribeAll"); + } + const phrases: Record = { + resolve: t("emailCapturePhraseResolve"), + forecast: t("emailCapturePhraseForecast"), + discussion: t("emailCapturePhraseDiscussion"), + }; + const [first, second] = picked; + return second + ? t("emailCaptureSentActionSubscribeTwo", { + a: phrases[first?.id ?? "resolve"], + b: phrases[second.id], + }) + : t("emailCaptureSentActionSubscribeOne", { + a: phrases[first?.id ?? "resolve"], + }); + })(); + + const recapAction = (() => { + const recapTrigger = pending?.trigger ?? trigger; + if (recapTrigger === "post_vote") return t("emailCaptureRecapActionVote"); + if (recapTrigger === "forecast") + return t("emailCaptureRecapActionForecast"); + return t("emailCaptureRecapActionSubscribe"); + })(); + + const secondaryLink = + "cursor-pointer border-none bg-transparent p-0.5 text-sm text-gray-600 underline underline-offset-4 dark:text-gray-600-dark"; + + const envelopeBadge = ( +
+ + + + +
+ ); + + const content = ( +
+ {/* Header row: back (subscribe email step only); close is provided by the shell */} + {view === "input" && hasOptionsStep && ( + + )} + + {view === "options" && ( + <> +
+

+ {t("emailCaptureOptionsTitle")} +

+

+ {t("emailCaptureOptionsSubtitle")} +

+
+
+ {OPTION_DEFS.map((option) => { + const isOn = selection[option.id]; + return ( + + ); + })} +
+ + {selectedCount === 0 && ( + + {t("emailCaptureOptionsCaptionNone")} + + )} + + )} + + {view === "input" && ( + <> +
+

+ {inputCopy.title} +

+

+ {inputCopy.body} +

+
+ {error === "server" && ( +
+ {t("emailCaptureServerError")} +
+ )} + {!prefilled && ( +
+ + { + setDraft(e.target.value); + if (error === "format") setError(null); + }} + className={cn( + "h-12 w-full rounded border-[1.5px] bg-gray-0 px-3.5 text-base text-gray-900 dark:bg-gray-0-dark dark:text-gray-900-dark", + error === "format" + ? "border-salmon-500 dark:border-salmon-500-dark" + : "border-gray-400 dark:border-gray-400-dark" + )} + /> + {error === "format" && ( + + {t("emailCaptureFormatError")} + + )} +
+ )} + + {prefilled && ( + + )} + {inputCopy.caption && ( + + {inputCopy.caption} + + )} + {googleUrl && ( + <> +
+
+ + {t("or")} + +
+
+ + + )} + + {PUBLIC_TURNSTILE_SITE_KEY && ( + { + turnstileTokenRef.current = token; + setIsTurnstileValidated(true); + }} + onError={() => setIsTurnstileValidated(false)} + onExpire={() => setIsTurnstileValidated(false)} + /> + )} + + )} + + {view === "sent" && ( + <> + {envelopeBadge} +
+

+ {t("emailCaptureSentTitle")} +

+

+ {openedAsRecap + ? `${t("emailCaptureRecapSentTo", { email: sentEmail ?? "" })} ${recapAction}` + : `${t("emailCaptureSentBody", { email: sentEmail ?? "" })} ${sentAction}`} +

+ {!openedAsRecap && ( +

+ {wasRepeatSend + ? t("emailCaptureSentNoteRepeat") + : t("emailCaptureSentNote")} +

+ )} +
+ {openedAsRecap ? ( + <> + {resendFeedback && ( +
+ {t("emailCaptureResendFeedback", { email: sentEmail ?? "" })} +
+ )} + + + + ) : ( + <> + + + + )} + + )} +
+ ); + + if (isDesktop) { + return ( + + {content} + + ); + } + + return ( + { + if (!open) setSheetOpen(false); + }} + onOpenChangeComplete={(open) => { + if (!open && !sheetOpen) handleClose(); + }} + label={inputCopy.title} + > +
+ +
+ {content} +
+ ); +}; + +export default EmailCaptureDrawer; diff --git a/front_end/src/components/global_modals.tsx b/front_end/src/components/global_modals.tsx index 6a7e38ca0c..36f21d046d 100644 --- a/front_end/src/components/global_modals.tsx +++ b/front_end/src/components/global_modals.tsx @@ -74,6 +74,11 @@ const DisputeKeyFactorModal = dynamic( { ssr: false } ); +const EmailCaptureDrawer = dynamic( + () => import("@/components/email_capture/email_capture_drawer"), + { ssr: false } +); + function isModal( m: CurrentModal | null, type: T @@ -153,6 +158,17 @@ const GlobalModals: FC = () => { onSubmitted={currentModal.data.onSubmitted} /> )} + {isModal(currentModal, "emailCapture") && currentModal.data && ( + + )} {isModal(currentModal, "copyQuestionLink") && currentModal.data && ( void; + onOpenChangeComplete?: (open: boolean) => void; + label?: string; + className?: string; +}>; + +/** + * Bottom-sheet drawer for mobile flows, built on Base UI. Renders children + * inside a swipe-down-dismissable sheet with a drag handle; header controls + * (back/close) are the consumer's responsibility. + */ +const BottomDrawer: FC = ({ + open, + onOpenChange, + onOpenChangeComplete, + label, + className, + children, +}) => { + // Base UI skips the enter transition when the root mounts already open + // (the global-modal registry mounts drawers on demand), so echo `open` + // through state one tick later to always get the slide-up + const [animatedOpen, setAnimatedOpen] = useState(false); + useEffect(() => { + setAnimatedOpen(open); + }, [open]); + + return ( + + + + + + + {label && ( + {label} + )} +
+
+
+ {children} + + + + + + ); +}; + +export default BottomDrawer; diff --git a/front_end/src/contexts/modal_context.tsx b/front_end/src/contexts/modal_context.tsx index 64d13dcb4d..1da3ceaf35 100644 --- a/front_end/src/contexts/modal_context.tsx +++ b/front_end/src/contexts/modal_context.tsx @@ -10,6 +10,7 @@ import { import { QuestionLinkDirection, QuestionLinkStrength } from "@/types/coherence"; import { CommentType } from "@/types/comment"; +import { GatedActionInput, GatedActionTrigger } from "@/types/gated_actions"; import { CurrentUser } from "@/types/users"; export type ModalType = @@ -23,7 +24,8 @@ export type ModalType = | "confirm" | "accountInactive" | "disputeKeyFactor" - | "copyQuestionLink"; + | "copyQuestionLink" + | "emailCapture"; type ModalDataByType = { signin: { @@ -54,6 +56,13 @@ type ModalDataByType = { onRemove: (tempId: number) => void; onSubmitted?: () => void; }; + emailCapture: { + trigger: GatedActionTrigger; + surface?: string; + gatedAction?: GatedActionInput | null; + subscribePost?: { postId: number; isNotebook: boolean }; + initialView?: "options" | "input" | "sent"; + }; copyQuestionLink: { fromQuestionTitle: string; toQuestionTitle: string; From 8d3173a4d6490fa933d983051d3ef1b591a949e4 Mon Sep 17 00:00:00 2001 From: Atakan Seckin Date: Thu, 30 Jul 2026 12:59:05 +0200 Subject: [PATCH 03/15] Gate logged-out consumer actions into the email capture flow Post votes, question subscriptions, and question-level forecasts now open the capture drawer with the drafted action attached instead of the signup modal. Forecast makers expose buildForecastPayload so the untouched-slider case falls back to a sign-in-only drawer without clearing pending actions. The consumer Predict button opens the maker for visitors (mobile: bottom drawer instead of the fullscreen overlay; one active drawer at a time), and the new NotifyMeCta gives mobile a subscribe entry point. Group/conditional makers and comment/key-factor gates keep the existing signin modal. Co-Authored-By: Claude Fable 5 --- .../components/question_page_shell/index.tsx | 4 + .../question_page_shell/notify_me_cta.tsx | 77 +++++++++++++++++++ .../question_predict_button.tsx | 56 ++++++++++---- .../forecast_maker_binary.tsx | 33 +++++--- .../forecast_maker_continuous.tsx | 55 +++++++++---- .../forecast_maker_multiple_choice.tsx | 44 +++++++---- .../forecast_maker/predict_button.tsx | 35 ++++++++- .../post_card/basic_post_card/post_voter.tsx | 16 +++- .../contexts/post_subscription_context.tsx | 9 ++- 9 files changed, 264 insertions(+), 65 deletions(-) create mode 100644 front_end/src/app/(main)/questions/[id]/components/question_page_shell/notify_me_cta.tsx diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx index 2a0627b4a9..1347021a49 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx @@ -49,6 +49,7 @@ import { } from "@/utils/questions/helpers"; import MetaRow from "./meta_row"; +import NotifyMeCta from "./notify_me_cta"; import QuestionPageShellTabs from "./tabs"; import TitleRow from "./title_row"; import KeyFactorsQuestionConsumerSection from "../key_factors/key_factors_question_consumer_section"; @@ -431,6 +432,9 @@ export const ConsumerShell: FC<{ )}
+
+ +
{shouldShowKeyFactorsSection && (
diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/notify_me_cta.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/notify_me_cta.tsx new file mode 100644 index 0000000000..e9d18ebe46 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/notify_me_cta.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { faBell as faBellRegular } from "@fortawesome/free-regular-svg-icons"; +import { faBell, faCheck } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useTranslations } from "next-intl"; +import { FC, useEffect, useRef } from "react"; + +import { usePostSubscriptionContext } from "@/contexts/post_subscription_context"; +import { sendAnalyticsEvent } from "@/utils/analytics"; +import cn from "@/utils/core/cn"; + +type Props = { + className?: string; +}; + +/** + * Consumer subscribe entry point for viewports where the Follow pill is + * hidden. Logged-out taps open the email-capture drawer via the subscription + * context; signed-in taps subscribe directly. + */ +const NotifyMeCta: FC = ({ className }) => { + const t = useTranslations(); + const { isSubscribed, isLoading, handleSubscribe, handleCustomize } = + usePostSubscriptionContext(); + const shownTrackedRef = useRef(false); + + useEffect(() => { + if (!shownTrackedRef.current) { + shownTrackedRef.current = true; + sendAnalyticsEvent("notifyCtaShown", { surface: "questionPageCta" }); + } + }, []); + + if (isSubscribed) { + return ( + + ); + } + + return ( + + ); +}; + +export default NotifyMeCta; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/action_buttons/question_predict_button.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/action_buttons/question_predict_button.tsx index 6f70b0b059..32ebbff659 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/action_buttons/question_predict_button.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/action_buttons/question_predict_button.tsx @@ -3,12 +3,12 @@ import { faPercent } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import BaseModal from "@/components/base_modal"; import ForecastMaker from "@/components/forecast_maker"; -import MobileAccordionModal from "@/components/forecast_maker/continuous_group_accordion/group_forecast_accordion_modal"; import Button from "@/components/ui/button"; +import BottomDrawer from "@/components/ui/drawer"; import { useAuth } from "@/contexts/auth_context"; import { useModal } from "@/contexts/modal_context"; import { useBreakpoint } from "@/hooks/tailwind"; @@ -26,16 +26,22 @@ const QuestionPredictButton: React.FC = ({ post, className }) => { const [isOpen, setIsOpen] = useState(false); const isDesktop = useBreakpoint("sm"); const { user } = useAuth(); - const { setCurrentModal } = useModal(); + const { currentModal } = useModal(); - const handleClick = () => { - if (!user) { - setCurrentModal({ type: "signin" }); - return; + // Only one surface at a time: when the maker's gate opens a global modal + // (email capture, signup), dismiss the maker instead of stacking under it + useEffect(() => { + if (currentModal) { + setIsOpen(false); } - if (user.is_bot) { + }, [currentModal]); + + const handleClick = () => { + if (user?.is_bot) { return; } + // Logged-out users get the forecast maker too; its submit button gates + // into the email-capture drawer carrying the drafted forecast setIsOpen(true); }; @@ -51,16 +57,32 @@ const QuestionPredictButton: React.FC = ({ post, className }) => { {!isDesktop ? ( - setIsOpen(false)} - title={post.question?.title ?? ""} + { + if (!open) setIsOpen(false); + }} + label={post.question?.title ?? ""} > - setIsOpen(false)} - /> - +
+

+ {post.question?.title ?? ""} +

+ +
+
+ setIsOpen(false)} + /> +
+ ) : ( = ({ const [submitError, setSubmitError] = useState(); const [isWithdrawModalOpen, setIsWithdrawModalOpen] = useState(false); - const handlePredictSubmit = async ( + const buildForecastPayload = ( forecastExpiration: ForecastExpirationValue - ) => { - setSubmitError(undefined); + ): ForecastPayload[] | null => { + if (forecast === null) return null; - if (forecast === null) return; - - sendPredictEvent(post, question, hideCP); - - const forecastValue = round(forecast / 100, BINARY_FORECAST_PRECISION); - const response = await createForecasts(post.id, [ + return [ { questionId: question.id, forecastData: { continuousCdf: null, - probabilityYes: forecastValue, + probabilityYes: round(forecast / 100, BINARY_FORECAST_PRECISION), probabilityYesPerCategory: null, }, forecastEndTime: forecastExpirationToDate( @@ -130,7 +126,19 @@ const ForecastMakerBinary: FC = ({ getExpirationBaseDate(question) ), }, - ]); + ]; + }; + const handlePredictSubmit = async ( + forecastExpiration: ForecastExpirationValue + ) => { + setSubmitError(undefined); + + const payload = buildForecastPayload(forecastExpiration); + if (!payload) return; + + sendPredictEvent(post, question, hideCP); + + const response = await createForecasts(post.id, payload); setIsForecastDirty(false); if (response && "errors" in response && !!response.errors) { @@ -215,6 +223,9 @@ const ForecastMakerBinary: FC = ({ isDirty={isForecastDirty} isPending={isPending} onSubmit={() => submit(modalSavedState.forecastExpiration)} + buildGatedForecastPayload={() => + buildForecastPayload(modalSavedState.forecastExpiration) + } predictLabel={predictLabel} predictionExpirationChip={expirationShortChip} onPredictionExpirationClick={() => diff --git a/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_continuous.tsx b/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_continuous.tsx index ac1b74675f..a20d500ffb 100644 --- a/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_continuous.tsx +++ b/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_continuous.tsx @@ -14,6 +14,7 @@ import LoadingIndicator from "@/components/ui/loading_indicator"; import { useAuth } from "@/contexts/auth_context"; import { useHideCP } from "@/contexts/cp_context"; import { useServerAction } from "@/hooks/use_server_action"; +import type { ForecastPayload } from "@/services/api/questions/questions.server"; import { ContinuousForecastInputType } from "@/types/charts"; import { ErrorResponse } from "@/types/fetch"; import { PostWithForecasts } from "@/types/post"; @@ -254,12 +255,9 @@ const ForecastMakerContinuous: FC = ({ question.scheduled_close_time ); - const handlePredictSubmit = async ( + const buildForecastPayload = ( forecastExpiration: ForecastExpirationValue - ) => { - setSubmitError(undefined); - sendPredictEvent(post, question, hideCP); - + ): ForecastPayload[] | null => { if (forecastInputMode === ContinuousForecastInputType.Quantile) { const validationErrors = validateUserQuantileData({ question, @@ -267,18 +265,10 @@ const ForecastMakerContinuous: FC = ({ cdf: userCdf, t, }); - - if (validationErrors.length !== 0) { - setSubmitError( - !isNil(validationErrors[0]) - ? new Error(validationErrors[0]) - : new Error(t("unexpectedError")) - ); - return; - } + if (validationErrors.length !== 0) return null; } - const response = await createForecasts(post.id, [ + return [ { questionId: question.id, forecastData: { @@ -298,7 +288,37 @@ const ForecastMakerContinuous: FC = ({ : clearQuantileComponents(quantileDistributionComponents), } as DistributionSlider | DistributionQuantile, }, - ]); + ]; + }; + + const handlePredictSubmit = async ( + forecastExpiration: ForecastExpirationValue + ) => { + setSubmitError(undefined); + sendPredictEvent(post, question, hideCP); + + if (forecastInputMode === ContinuousForecastInputType.Quantile) { + const validationErrors = validateUserQuantileData({ + question, + components: quantileDistributionComponents, + cdf: userCdf, + t, + }); + + if (validationErrors.length !== 0) { + setSubmitError( + !isNil(validationErrors[0]) + ? new Error(validationErrors[0]) + : new Error(t("unexpectedError")) + ); + return; + } + } + + const payload = buildForecastPayload(forecastExpiration); + if (!payload) return; + + const response = await createForecasts(post.id, payload); setIsDirty(false); if (response && "errors" in response && !!response.errors) { setSubmitError(response.errors); @@ -418,6 +438,9 @@ const ForecastMakerContinuous: FC = ({ )} submit(modalSavedState.forecastExpiration)} + buildGatedForecastPayload={() => + buildForecastPayload(modalSavedState.forecastExpiration) + } isDirty={predictButtonIsDirty} hasUserForecast={!!previousForecast} isUserForecastActive={hasUserActiveForecast} diff --git a/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_multiple_choice.tsx b/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_multiple_choice.tsx index 872df92fc9..81afcb3ffe 100644 --- a/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_multiple_choice.tsx +++ b/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_multiple_choice.tsx @@ -26,6 +26,7 @@ import { useAuth } from "@/contexts/auth_context"; import { useHideCP } from "@/contexts/cp_context"; import useAppTheme from "@/hooks/use_app_theme"; import { useServerAction } from "@/hooks/use_server_action"; +import type { ForecastPayload } from "@/services/api/questions/questions.server"; import { ErrorResponse } from "@/types/fetch"; import { PostWithForecasts } from "@/types/post"; import { @@ -452,11 +453,11 @@ const ForecastMakerMultipleChoice: FC = ({ ); }; - const handlePredictSubmit = useCallback( - async (forecastExpiration?: ForecastExpirationValue) => { - setSubmitError(undefined); - - if (!isForecastValid) return; + const buildForecastPayload = useCallback( + ( + forecastExpiration?: ForecastExpirationValue + ): ForecastPayload[] | null => { + if (!isForecastValid) return null; const forecastValue: Record = {}; choicesForecasts.forEach((el) => { @@ -471,8 +472,8 @@ const ForecastMakerMultipleChoice: FC = ({ ); } }); - sendPredictEvent(post, question, hideCP); - const response = await createForecasts(post.id, [ + + return [ { questionId: question.id, forecastEndTime: forecastExpirationToDate( @@ -485,23 +486,33 @@ const ForecastMakerMultipleChoice: FC = ({ probabilityYesPerCategory: forecastValue, }, }, - ]); - setIsDirty(false); - if (response && "errors" in response && !!response.errors) { - setSubmitError(response.errors); - } - onPredictionSubmit?.(); + ]; }, [ isForecastValid, choicesForecasts, - post, question, - hideCP, modalSavedState.forecastExpiration, - onPredictionSubmit, ] ); + + const handlePredictSubmit = useCallback( + async (forecastExpiration?: ForecastExpirationValue) => { + setSubmitError(undefined); + + const payload = buildForecastPayload(forecastExpiration); + if (!payload) return; + + sendPredictEvent(post, question, hideCP); + const response = await createForecasts(post.id, payload); + setIsDirty(false); + if (response && "errors" in response && !!response.errors) { + setSubmitError(response.errors); + } + onPredictionSubmit?.(); + }, + [buildForecastPayload, post, question, hideCP, onPredictionSubmit] + ); const [submit, isPending] = useServerAction(handlePredictSubmit); const handlePredictWithdraw = async () => { @@ -677,6 +688,7 @@ const ForecastMakerMultipleChoice: FC = ({ )} buildForecastPayload()} isDirty={isDirty} hasUserForecast={forecastHasValues} isUserForecastActive={isOpenQuestionPredicted(question)} diff --git a/front_end/src/components/forecast_maker/predict_button.tsx b/front_end/src/components/forecast_maker/predict_button.tsx index e9defd101b..8dd0418ea6 100644 --- a/front_end/src/components/forecast_maker/predict_button.tsx +++ b/front_end/src/components/forecast_maker/predict_button.tsx @@ -7,6 +7,7 @@ import React, { FC, ReactNode, useMemo } from "react"; import Button from "@/components/ui/button"; import { useAuth } from "@/contexts/auth_context"; import { useModal } from "@/contexts/modal_context"; +import type { ForecastPayload } from "@/services/api/questions/questions.server"; import cn from "@/utils/core/cn"; type Props = { @@ -19,6 +20,9 @@ type Props = { isDisabled?: boolean; predictionExpirationChip?: ReactNode; onPredictionExpirationClick?: () => void; + // When provided, logged-out users get the email-capture drawer with their + // forecast as the deferred action instead of the signup modal + buildGatedForecastPayload?: () => ForecastPayload[] | null; }; const PredictButton: FC = ({ @@ -31,6 +35,7 @@ const PredictButton: FC = ({ isDisabled, predictionExpirationChip, onPredictionExpirationClick, + buildGatedForecastPayload, }) => { const { user } = useAuth(); const { setCurrentModal } = useModal(); @@ -75,7 +80,9 @@ const PredictButton: FC = ({ ]); const buttonLabel = useMemo(() => { if (!user) { - return t("signUpToPredict"); + return buildGatedForecastPayload + ? t("emailCaptureForecastTitle") + : t("signUpToPredict"); } if (hasUserForecast && !isDirty && isUserForecastActive) { @@ -83,11 +90,33 @@ const PredictButton: FC = ({ } return predictLabel ?? t("saveChange"); - }, [hasUserForecast, isDirty, predictLabel, t, user, isUserForecastActive]); + }, [ + hasUserForecast, + isDirty, + predictLabel, + t, + user, + isUserForecastActive, + buildGatedForecastPayload, + ]); const handleClick = () => { if (!user) { - setCurrentModal({ type: "signup" }); + if (buildGatedForecastPayload) { + const payload = buildGatedForecastPayload(); + setCurrentModal({ + type: "emailCapture", + data: { + trigger: "forecast", + surface: "predictButton", + // Untouched slider yields no payload; the drawer falls back to + // sign-in-only copy and never clears a pending action + gatedAction: payload?.length ? { type: "forecast", payload } : null, + }, + }); + } else { + setCurrentModal({ type: "signup" }); + } return; } diff --git a/front_end/src/components/post_card/basic_post_card/post_voter.tsx b/front_end/src/components/post_card/basic_post_card/post_voter.tsx index ed9c3514f9..4d3de5f8f3 100644 --- a/front_end/src/components/post_card/basic_post_card/post_voter.tsx +++ b/front_end/src/components/post_card/basic_post_card/post_voter.tsx @@ -27,7 +27,21 @@ const PostVoter: FC = ({ className, post, questionPage, compact }) => { const [vote, setVote] = useState(post.vote); const handleVote = async (direction: VoteDirection) => { if (!user) { - setCurrentModal({ type: "signin" }); + if (direction === 1 || direction === -1) { + setCurrentModal({ + type: "emailCapture", + data: { + trigger: "post_vote", + surface: questionPage ? "questionPage" : "questionFeed", + gatedAction: { + type: "post_vote", + payload: { post: post.id, direction }, + }, + }, + }); + } else { + setCurrentModal({ type: "signin" }); + } return; } if (user.is_bot) { diff --git a/front_end/src/contexts/post_subscription_context.tsx b/front_end/src/contexts/post_subscription_context.tsx index 58cab6eb7b..ae7e1b1c06 100644 --- a/front_end/src/contexts/post_subscription_context.tsx +++ b/front_end/src/contexts/post_subscription_context.tsx @@ -61,7 +61,14 @@ export const PostSubscriptionProvider: React.FC< const handleSubscribe = useCallback(async () => { if (!user) { - setCurrentModal({ type: "signup" }); + setCurrentModal({ + type: "emailCapture", + data: { + trigger: "post_subscribe", + surface: "postSubscribeButton", + subscribePost: { postId: post.id, isNotebook: !!post.notebook }, + }, + }); return; } From 72a52fe2de68737852b2e84b4c1b665d28222c07 Mon Sep 17 00:00:00 2001 From: Atakan Seckin Date: Thu, 30 Jul 2026 12:59:37 +0200 Subject: [PATCH 04/15] Add confirm-email banner and dead-link recovery Device-local banner under the top chrome while a capture record is pending; tapping it reopens the drawer in recap state with the resend cooldown. The magic-link failure page gains an inline form that requests a fresh link, re-sending the stored gated action when one exists. Co-Authored-By: Claude Fable 5 --- .../email/components/email_link_verify.tsx | 88 ++++++++++++++++++- .../components/email_confirm_banner.tsx | 88 +++++++++++++++++++ .../src/app/(main)/components/top_chrome.tsx | 4 + 3 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 front_end/src/app/(main)/components/email_confirm_banner.tsx diff --git a/front_end/src/app/(main)/auth/email/components/email_link_verify.tsx b/front_end/src/app/(main)/auth/email/components/email_link_verify.tsx index a0514d0cd2..54220fd1f9 100644 --- a/front_end/src/app/(main)/auth/email/components/email_link_verify.tsx +++ b/front_end/src/app/(main)/auth/email/components/email_link_verify.tsx @@ -1,13 +1,21 @@ "use client"; +import { Turnstile, TurnstileInstance } from "@marsidev/react-turnstile"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { FC, useEffect, useRef, useState } from "react"; -import { verifyEmailLinkAction } from "@/app/(main)/accounts/actions"; +import { + requestEmailLinkAction, + verifyEmailLinkAction, +} from "@/app/(main)/accounts/actions"; +import { readPending } from "@/components/email_capture/pending_store"; import Button from "@/components/ui/button"; +import { Input } from "@/components/ui/form_field"; import LoadingIndicator from "@/components/ui/loading_indicator"; import { useAuth } from "@/contexts/auth_context"; +import { usePublicSettings } from "@/contexts/public_settings_context"; +import { useServerAction } from "@/hooks/use_server_action"; import { ensureRelativeRedirect } from "@/utils/navigation"; type Props = { @@ -39,9 +47,22 @@ const EmailLinkVerify: FC = ({ userId, token, redirectUrl }) => { const t = useTranslations(); const router = useRouter(); const { user, setUser } = useAuth(); + const { PUBLIC_TURNSTILE_SITE_KEY } = usePublicSettings(); const firedRef = useRef(false); const [failed, setFailed] = useState(false); + // Recovery form state: a dead link should never be a dead end. The device's + // pending record (when present) supplies the address and the deferred action + // so the fresh link carries the same intent. + const [draft, setDraft] = useState(""); + const [requestSent, setRequestSent] = useState(false); + const [sentWithAction, setSentWithAction] = useState(false); + const [isTurnstileValidated, setIsTurnstileValidated] = useState( + !PUBLIC_TURNSTILE_SITE_KEY + ); + const turnstileRef = useRef(null); + const turnstileTokenRef = useRef(undefined); + useEffect(() => { // Consumption is JS-gated (scanner protection) and must fire exactly once. if (firedRef.current) return; @@ -55,6 +76,7 @@ const EmailLinkVerify: FC = ({ userId, token, redirectUrl }) => { if (!userId || !token) { setFailed(true); + setDraft(readPending()?.email ?? ""); return; } @@ -63,6 +85,7 @@ const EmailLinkVerify: FC = ({ userId, token, redirectUrl }) => { if ("errors" in result) { setFailed(true); + setDraft(readPending()?.email ?? ""); return; } @@ -73,6 +96,24 @@ const EmailLinkVerify: FC = ({ userId, token, redirectUrl }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const requestNewLink = async () => { + const email = draft.trim(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return; + const pending = readPending(); + const response = await requestEmailLinkAction({ + email, + redirectUrl: pending?.redirectUrl ?? null, + gatedAction: pending?.gatedAction ?? null, + turnstileToken: turnstileTokenRef.current, + }); + turnstileRef.current?.reset(); + if (!response.errors) { + setSentWithAction(!!pending?.gatedAction); + setRequestSent(true); + } + }; + const [submitRequest, isRequesting] = useServerAction(requestNewLink); + if (failed) { // redirect_url is deliberately NOT honored on failure. return ( @@ -80,10 +121,51 @@ const EmailLinkVerify: FC = ({ userId, token, redirectUrl }) => {

{t("emailLinkInvalidTitle")}

-

+

{t("emailLinkInvalidDescription")}

- + {requestSent ? ( +
+ {sentWithAction + ? t("emailLinkNewSentWithAction", { email: draft.trim() }) + : t("emailLinkNewSent", { email: draft.trim() })} +
+ ) : ( +
+ setDraft(e.target.value)} + className="h-12 w-full rounded border-[1.5px] border-gray-400 bg-gray-0 px-3.5 text-center text-base text-gray-900 dark:border-gray-400-dark dark:bg-gray-0-dark dark:text-gray-900-dark" + /> + + {PUBLIC_TURNSTILE_SITE_KEY && ( + { + turnstileTokenRef.current = turnstileToken; + setIsTurnstileValidated(true); + }} + onError={() => setIsTurnstileValidated(false)} + onExpire={() => setIsTurnstileValidated(false)} + /> + )} +
+ )} +
); } diff --git a/front_end/src/app/(main)/components/email_confirm_banner.tsx b/front_end/src/app/(main)/components/email_confirm_banner.tsx new file mode 100644 index 0000000000..eef51ff9f0 --- /dev/null +++ b/front_end/src/app/(main)/components/email_confirm_banner.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { faChevronRight, faEnvelope } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useTranslations } from "next-intl"; +import { FC, useEffect, useRef } from "react"; + +import { clearPending } from "@/components/email_capture/pending_store"; +import useEmailCapturePending from "@/components/email_capture/use_email_capture_pending"; +import { useAuth } from "@/contexts/auth_context"; +import { useModal } from "@/contexts/modal_context"; +import { sendAnalyticsEvent } from "@/utils/analytics"; + +/** + * Device-local reminder that a confirmation email is out. Appears after the + * capture sheet is dismissed post-send, survives navigation and reloads, and + * disappears the moment this device sees a signed-in user. + */ +const EmailConfirmBanner: FC = () => { + const t = useTranslations(); + const { user } = useAuth(); + const { currentModal, setCurrentModal } = useModal(); + const pending = useEmailCapturePending(); + const shownTrackedRef = useRef(false); + + const drawerOpen = currentModal?.type === "emailCapture"; + const visible = !!pending && !user && !drawerOpen; + + useEffect(() => { + if (user && pending) { + clearPending(); + } + }, [user, pending]); + + useEffect(() => { + if (visible && !shownTrackedRef.current) { + shownTrackedRef.current = true; + sendAnalyticsEvent("confirmBannerShown", { + trigger: pending?.trigger, + surface: pending?.surface, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible]); + + if (!visible || !pending) return null; + + const openRecap = () => { + sendAnalyticsEvent("confirmBannerClicked", { + trigger: pending.trigger, + surface: pending.surface, + }); + setCurrentModal({ + type: "emailCapture", + data: { + trigger: pending.trigger, + surface: "confirmBanner", + gatedAction: pending.gatedAction, + initialView: "sent", + }, + }); + }; + + return ( + + ); +}; + +export default EmailConfirmBanner; diff --git a/front_end/src/app/(main)/components/top_chrome.tsx b/front_end/src/app/(main)/components/top_chrome.tsx index 5ce59eba52..25dbab65e2 100644 --- a/front_end/src/app/(main)/components/top_chrome.tsx +++ b/front_end/src/app/(main)/components/top_chrome.tsx @@ -3,6 +3,7 @@ import { logError } from "@/utils/core/errors"; import { ApiForecastingBanner } from "./api_forecasting_banner_server"; import Bulletins from "./bulletins"; import ContentTranslatedBanner from "./content_translated_banner"; +import EmailConfirmBanner from "./email_confirm_banner"; import { ImpersonationBanner } from "./impersonation_banner_server"; import { TopChromeClient } from "./top_chrome_client"; import { @@ -82,6 +83,9 @@ export const TopChrome = ({ + + + {!hideTranslationBanner && ( From 67ed6558bbd6bfd41293e69b02537be3fac2a3ab Mon Sep 17 00:00:00 2001 From: Atakan Seckin Date: Thu, 30 Jul 2026 12:59:37 +0200 Subject: [PATCH 05/15] Carry gated actions through the social OAuth flow The drawer's Google path stashes the pending action in sessionStorage before the redirect; the callback attaches it to the code exchange (backend applies it best-effort per 9e543e58) with a 15-minute staleness guard, and clears the capture record on success. Co-Authored-By: Claude Fable 5 --- .../app/(main)/accounts/social/[provider]/actions.ts | 8 ++++++-- .../app/(main)/accounts/social/[provider]/client.tsx | 11 ++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/front_end/src/app/(main)/accounts/social/[provider]/actions.ts b/front_end/src/app/(main)/accounts/social/[provider]/actions.ts index af417d2c4e..a3cf8a9f83 100644 --- a/front_end/src/app/(main)/accounts/social/[provider]/actions.ts +++ b/front_end/src/app/(main)/accounts/social/[provider]/actions.ts @@ -5,13 +5,16 @@ import { cookies } from "next/headers"; import ServerAuthApi from "@/services/api/auth/auth.server"; import { getAuthCookieManager } from "@/services/auth_tokens"; import { SocialProviderType } from "@/types/auth"; +import { GatedActionInput } from "@/types/gated_actions"; import { assertValidCsrfNonce, CSRF_COOKIE_NAME } from "@/utils/csrf"; +import { mapGatedActionToWire } from "@/utils/gated_actions"; import { getPublicSettings } from "@/utils/public_settings.server"; export async function exchangeSocialOauthCode( provider: SocialProviderType, code: string, - nonce: string + nonce: string, + gatedAction?: GatedActionInput | null ) { const cookieStore = await cookies(); assertValidCsrfNonce(cookieStore.get(CSRF_COOKIE_NAME)?.value, nonce); @@ -20,7 +23,8 @@ export async function exchangeSocialOauthCode( const response = await ServerAuthApi.exchangeSocialOauthCode( provider, code, - `${PUBLIC_APP_URL}/accounts/social/${provider}` + `${PUBLIC_APP_URL}/accounts/social/${provider}`, + gatedAction ? mapGatedActionToWire(gatedAction) : null ); if (response?.tokens) { diff --git a/front_end/src/app/(main)/accounts/social/[provider]/client.tsx b/front_end/src/app/(main)/accounts/social/[provider]/client.tsx index 46108f3835..2f43ec5547 100644 --- a/front_end/src/app/(main)/accounts/social/[provider]/client.tsx +++ b/front_end/src/app/(main)/accounts/social/[provider]/client.tsx @@ -5,6 +5,10 @@ import { FC, useEffect } from "react"; import { useErrorBoundary } from "react-error-boundary"; import { exchangeSocialOauthCode } from "@/app/(main)/accounts/social/[provider]/actions"; +import { + clearPending, + takeSocialGatedAction, +} from "@/components/email_capture/pending_store"; import LoadingIndicator from "@/components/ui/loading_indicator"; import { SocialProviderType } from "@/types/auth"; import { rotateCsrfToken } from "@/utils/csrf"; @@ -26,11 +30,16 @@ const SocialAuthClient: FC = ({ const { showBoundary } = useErrorBoundary(); useEffect(() => { - exchangeSocialOauthCode(provider, code, nonce) + // A gated action stashed before the OAuth redirect rides along with the + // code exchange; the backend applies it best-effort after sign-in. + const stash = takeSocialGatedAction(); + exchangeSocialOauthCode(provider, code, nonce, stash?.gatedAction ?? null) .then(() => { // Invalidate the nonce now that it has served its purpose (and been // logged as a `state` param) — bounds any replay to the flow duration. rotateCsrfToken(); + // Signed in now; any pending email-confirmation reminder is obsolete + clearPending(); router.push(redirectUrl); }) .catch(showBoundary); From aa83bb1c219e958c3835edea870678603fa27614 Mon Sep 17 00:00:00 2001 From: Atakan Seckin Date: Thu, 30 Jul 2026 13:08:06 +0200 Subject: [PATCH 06/15] Fall back to getRandomValues for CSRF tokens in insecure contexts crypto.randomUUID only exists in secure contexts, so building an OAuth URL crashed the app when the site is accessed over plain http (e.g. LAN device testing against the dev server). getRandomValues has no such restriction. Co-Authored-By: Claude Fable 5 --- front_end/src/utils/csrf.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/front_end/src/utils/csrf.ts b/front_end/src/utils/csrf.ts index 111638e812..4fe2e986bc 100644 --- a/front_end/src/utils/csrf.ts +++ b/front_end/src/utils/csrf.ts @@ -17,6 +17,17 @@ function writeCsrfToken(token: string): void { document.cookie = `${CSRF_COOKIE_NAME}=${token}; Path=/; Secure; SameSite=Lax; Max-Age=${CSRF_COOKIE_MAX_AGE_SECONDS}`; } +// crypto.randomUUID only exists in secure contexts; getRandomValues does not +// have that restriction, so http:// dev sessions don't crash on render +function randomToken(): string { + if (typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + /** * Read the CSRF cookie, minting one only if absent. Client-side only. Called * when an OAuth flow starts, so the nonce embedded in `state` matches the @@ -28,7 +39,7 @@ export function getOrMintCsrfToken(): string { const existing = readCsrfToken(); if (existing) return existing; - const token = crypto.randomUUID(); + const token = randomToken(); writeCsrfToken(token); return token; } @@ -39,7 +50,7 @@ export function getOrMintCsrfToken(): string { * request logs), bounding its usefulness to the duration of the flow. */ export function rotateCsrfToken(): void { - writeCsrfToken(crypto.randomUUID()); + writeCsrfToken(randomToken()); } export function assertValidCsrfNonce( From ce09ebaebe6a0965a5481bd214410410258914b7 Mon Sep 17 00:00:00 2001 From: Atakan Seckin Date: Wed, 5 Aug 2026 09:10:01 +0200 Subject: [PATCH 07/15] Polish mobile drawers and consumer question page spacing Mobile capture drawer gets a unified header row: back button or wrapping title inline with the close button at consistent padding (desktop modal unchanged). BinaryCPBar's lg size now draws the SVG at real dimensions instead of a CSS transform, so the layout box matches the visual and the scale-compensation hacks at both call sites are gone. Tighter mobile spacing on consumer question pages (action row, prediction block, notify CTA, drawer paddings) and a consolidated drawer handle gap. Co-Authored-By: Claude Fable 5 --- .../components/question_page_shell/index.tsx | 4 +- .../components/question_view/action_row.tsx | 2 +- .../continuous_question_prediction.tsx | 2 +- .../question_header_cp_status.tsx | 2 +- .../consumer_post_card/binary_cp_bar.tsx | 33 ++++++---- .../email_capture/email_capture_drawer.tsx | 66 ++++++++++++++----- .../question_tile/prediction_binary_info.tsx | 6 +- front_end/src/components/ui/drawer.tsx | 4 +- 8 files changed, 76 insertions(+), 43 deletions(-) diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx index 1347021a49..a1dc7e8b71 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx @@ -301,7 +301,7 @@ export const ConsumerShell: FC<{
-
+
{showClosedMessageMultipleChoice && (

{t("predictionClosedMessage")} @@ -432,7 +432,7 @@ export const ConsumerShell: FC<{ )}

-
+
{shouldShowKeyFactorsSection && ( diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/action_row.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/action_row.tsx index c6b1d9b00d..7b1caee1bf 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/action_row.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/action_row.tsx @@ -71,7 +71,7 @@ const ActionRow: FC = ({ post, variant }) => { return (
= ({ } return ( -
+
= ({ question={question} className={cn("mx-auto pb-1 text-center", { "w-max max-w-32": size === "md", - "mt-6": size === "lg", + "mt-2": size === "lg", })} size="sm" unit={"%"} diff --git a/front_end/src/components/consumer_post_card/binary_cp_bar.tsx b/front_end/src/components/consumer_post_card/binary_cp_bar.tsx index 0d3d44e440..c23f82cfaa 100644 --- a/front_end/src/components/consumer_post_card/binary_cp_bar.tsx +++ b/front_end/src/components/consumer_post_card/binary_cp_bar.tsx @@ -49,10 +49,14 @@ const BinaryCPBar: FC = ({ ? Math.round((questionCP as number) * 1000) / 10 : null; - const width = isEmbed ? 85 : 112; - const height = isEmbed ? 50 : 66; - const strokeWidth = isEmbed ? 8 : 12; - const strokeCursorWidth = isEmbed ? 12 : 17; + // lg draws at real size so the layout box matches the visual; xs/sm keep + // their CSS-transform shrink (their card layouts assume the md-sized box) + const isLg = size === "lg" && !isEmbed; + const width = isEmbed ? 85 : isLg ? 140 : 112; + const height = isEmbed ? 50 : isLg ? 82.5 : 66; + const strokeWidth = isEmbed ? 8 : isLg ? 15 : 12; + const strokeCursorWidth = isEmbed ? 12 : isLg ? 21 : 17; + const tickHalfLength = isLg ? 2.5 : 2; const radius = (width - strokeWidth) / 2; const arcAngle = Math.PI * 1.1; const center = { x: width / 2, y: height - strokeWidth }; @@ -99,8 +103,6 @@ const BinaryCPBar: FC = ({ { "scale-[0.5]": size === "xs", "scale-[0.85]": size === "sm", - "scale-100": size === "md", - "scale-[1.25]": size === "lg", }, isEmbed && "scale-100", className @@ -150,19 +152,19 @@ const BinaryCPBar: FC = ({ = ({
= ({ ? "text-[18px] leading-[24px]" : size === "xs" ? "text-[12px] leading-4" - : "text-xl leading-8" + : isLg + ? "text-[25px] leading-10" + : "text-xl leading-8" )} > {cpPercentage != null ? `${cpPercentage}%` : "%"} @@ -197,7 +202,9 @@ const BinaryCPBar: FC = ({ ? "text-[9px] leading-[9px]" : size === "xs" ? "text-[6px] leading-[6px]" - : "text-xs" + : isLg + ? "text-[15px]" + : "text-xs" )} > {t("chance")} diff --git a/front_end/src/components/email_capture/email_capture_drawer.tsx b/front_end/src/components/email_capture/email_capture_drawer.tsx index abf2c77e6b..f8a33ff5e5 100644 --- a/front_end/src/components/email_capture/email_capture_drawer.tsx +++ b/front_end/src/components/email_capture/email_capture_drawer.tsx @@ -406,15 +406,26 @@ const EmailCaptureDrawer: FC = ({
); + // On mobile the shell renders a header row (back or title, plus close); the + // titles below only render inline on desktop, where BaseModal has no header + const backInHeader = view === "input" && hasOptionsStep; + const headerTitle = + view === "options" + ? t("emailCaptureOptionsTitle") + : view === "sent" + ? t("emailCaptureSentTitle") + : inputCopy.title; + + const goBackToOptions = () => { + setError(null); + setView("options"); + }; + const content = (
- {/* Header row: back (subscribe email step only); close is provided by the shell */} - {view === "input" && hasOptionsStep && ( + {isDesktop && backInHeader && ( + ) : ( +

+ {headerTitle} +

+ )} diff --git a/front_end/src/components/post_card/question_tile/prediction_binary_info.tsx b/front_end/src/components/post_card/question_tile/prediction_binary_info.tsx index f762c9b596..98497596e4 100644 --- a/front_end/src/components/post_card/question_tile/prediction_binary_info.tsx +++ b/front_end/src/components/post_card/question_tile/prediction_binary_info.tsx @@ -44,11 +44,7 @@ const PredictionBinaryInfo: FC = ({ return (
-
+
{!hideCP && } {!hideCP && ( = ({ @@ -54,7 +54,7 @@ const BottomDrawer: FC = ({ {label && ( {label} )} -
+
{children} From aba76bf1486371bb9538db06bbe844cdd7c2d2ea Mon Sep 17 00:00:00 2001 From: Atakan Seckin Date: Wed, 5 Aug 2026 09:32:05 +0200 Subject: [PATCH 08/15] Add mobile share drawer on a reusable action-grid pattern The Share button opens a bottom sheet on mobile (desktop keeps the dropdown): a 2x2 grid of Copy Link / X / Facebook / Embed tiles with pressed-state feedback, toasts on action, and the drawer staying open (Embed hands off to the embed modal). BottomDrawer gains a standard title-plus-close header used by the predict drawer too, and the new DrawerActionButton tile is the building block for future mobile drawers, with share_post_drawer as the reference example. useCopyUrl falls back to execCommand where navigator.clipboard is unavailable (insecure contexts). Co-Authored-By: Claude Fable 5 --- front_end/messages/en.json | 3 +- .../components/question_view/action_row.tsx | 45 +++++++--- .../question_predict_button.tsx | 15 +--- .../post_actions/share_post_drawer.tsx | 83 +++++++++++++++++++ front_end/src/components/ui/drawer.tsx | 40 ++++++++- .../components/ui/drawer_action_button.tsx | 32 +++++++ front_end/src/hooks/share.ts | 34 ++++++-- 7 files changed, 215 insertions(+), 37 deletions(-) create mode 100644 front_end/src/components/post_actions/share_post_drawer.tsx create mode 100644 front_end/src/components/ui/drawer_action_button.tsx diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 9f240f8e33..a9f64f1377 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -2437,5 +2437,6 @@ "emailLinkNewSentWithAction": "We sent a new link to {email}. It carries the same action.", "notifyMeCtaLabel": "Notify me when this resolves", "notifyMeCtaFollowing": "You're following this question", - "emailCaptureSubscribeBodyRepeat": "We'll send a new link to {email}. It turns on these updates instead." + "emailCaptureSubscribeBodyRepeat": "We'll send a new link to {email}. It turns on these updates instead.", + "shareOpenedInNewTab": "Opened {target} in a new tab." } diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/action_row.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/action_row.tsx index 7b1caee1bf..5aa5c3e084 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/action_row.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/action_row.tsx @@ -5,14 +5,16 @@ import { faEllipsis, faBell, faCode } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { cva, type VariantProps } from "class-variance-authority"; import { useTranslations } from "next-intl"; -import { ComponentProps, FC } from "react"; +import { ComponentProps, FC, useState } from "react"; import ShareIcon from "@/components/icons/share"; import { MetaculusWordmark } from "@/components/logos"; import { PostDropdownMenu, SharePostMenu } from "@/components/post_actions"; +import SharePostDrawer from "@/components/post_actions/share_post_drawer"; import Button from "@/components/ui/button"; import useEmbedModalContext from "@/contexts/embed_modal_context"; import { usePostSubscriptionContext } from "@/contexts/post_subscription_context"; +import { useBreakpoint } from "@/hooks/tailwind"; import { PostWithForecasts, PostStatus, QuestionStatus } from "@/types/post"; import cn from "@/utils/core/cn"; import { @@ -56,6 +58,8 @@ const ActionRow: FC = ({ post, variant }) => { const { updateIsOpen: openEmbedModal } = useEmbedModalContext(); const { isSubscribed, isLoading, handleSubscribe, handleCustomize } = usePostSubscriptionContext(); + const isDesktop = useBreakpoint("sm"); + const [shareDrawerOpen, setShareDrawerOpen] = useState(false); const isPredictable = (isQuestionPost(post) && @@ -105,17 +109,34 @@ const ActionRow: FC = ({ post, variant }) => { {isSubscribed ? t("followingButton") : t("followButton")} - {/* Share */} - - - - {t("share")} - - + {/* Share: dropdown on desktop, bottom drawer with big targets on mobile */} + {isDesktop ? ( + + + + {t("share")} + + + ) : ( + <> + setShareDrawerOpen(true)} + > + + {t("share")} + + + + )} {/* Embed — hidden on mobile for consumer */} = ({ post, className }) => { onOpenChange={(open) => { if (!open) setIsOpen(false); }} - label={post.question?.title ?? ""} + title={post.question?.title ?? ""} + titleClassName="text-lg font-semibold leading-6" > -
-

- {post.question?.title ?? ""} -

- -
void; + questionTitle: string; +}; + +/** + * Mobile counterpart of SharePostMenu: same actions as the dropdown, as a + * bottom sheet with big tap targets. Gate at the call site with + * useBreakpoint("sm"); reference example for the mobile-drawer pattern. + */ +const SharePostDrawer: FC = ({ open, onOpenChange, questionTitle }) => { + const t = useTranslations(); + const copyUrl = useCopyUrl(); + const shareOnTwitterLink = useShareOnTwitterLink( + `${questionTitle} #metaculus` + ); + const shareOnFacebookLink = useShareOnFacebookLink(); + const { updateIsOpen: openEmbedModal } = useEmbedModalContext(); + + const shareToTarget = (link: string, targetName: string) => { + window.open(link, "_blank", "noopener"); + toast(t("shareOpenedInNewTab", { target: targetName })); + }; + + return ( + +
+ } + label={t("copyLink")} + onClick={copyUrl} + /> + } + label={t("xTwitter")} + onClick={() => shareToTarget(shareOnTwitterLink, t("xTwitter"))} + /> + } + label={t("facebook")} + onClick={() => shareToTarget(shareOnFacebookLink, t("facebook"))} + /> + } + label={t("embed")} + className="capitalize" + onClick={() => { + // The embed modal stacks below the drawer, so hand off to it + // (one active surface at a time) + onOpenChange(false); + openEmbedModal(true); + }} + /> +
+
+ ); +}; + +export default SharePostDrawer; diff --git a/front_end/src/components/ui/drawer.tsx b/front_end/src/components/ui/drawer.tsx index 123e863a77..1fffccfa30 100644 --- a/front_end/src/components/ui/drawer.tsx +++ b/front_end/src/components/ui/drawer.tsx @@ -1,6 +1,7 @@ "use client"; import { Drawer } from "@base-ui/react/drawer"; +import { useTranslations } from "next-intl"; import { FC, PropsWithChildren, useEffect, useState } from "react"; import cn from "@/utils/core/cn"; @@ -9,23 +10,35 @@ type Props = PropsWithChildren<{ open: boolean; onOpenChange: (open: boolean) => void; onOpenChangeComplete?: (open: boolean) => void; + /** Renders the standard header row: wrapping title on the left, close + * button on the right. Most drawers should use this. */ + title?: string; + /** Screen-reader-only label for drawers that render a custom header + * instead of `title` (see email_capture_drawer). */ label?: string; className?: string; + titleClassName?: string; }>; /** - * Bottom-sheet drawer for mobile flows, built on Base UI. Renders children - * inside a swipe-down-dismissable sheet with a drag handle; header controls - * (back/close) are the consumer's responsibility. + * Bottom-sheet drawer for mobile-only flows, built on Base UI. This is the + * house pattern for turning a desktop interaction (dropdown, modal) into a + * mobile sheet: gate on `useBreakpoint("sm")` at the call site, pass `title` + * for the standard header, and put the content in children (for action + * grids, compose DrawerActionButton in a `grid grid-cols-2 gap-2`). + * share_post_drawer.tsx is the reference example. */ const BottomDrawer: FC = ({ open, onOpenChange, onOpenChangeComplete, + title, label, className, + titleClassName, children, }) => { + const t = useTranslations(); // Base UI skips the enter transition when the root mounts already open // (the global-modal registry mounts drawers on demand), so echo `open` // through state one tick later to always get the slide-up @@ -51,12 +64,31 @@ const BottomDrawer: FC = ({ )} > - {label && ( + {!title && label && ( {label} )}
+ {title && ( +
+ + {title} + + +
+ )} {children} diff --git a/front_end/src/components/ui/drawer_action_button.tsx b/front_end/src/components/ui/drawer_action_button.tsx new file mode 100644 index 0000000000..eadd7c506e --- /dev/null +++ b/front_end/src/components/ui/drawer_action_button.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { FC, ReactNode } from "react"; + +import cn from "@/utils/core/cn"; + +type Props = { + icon: ReactNode; + label: string; + onClick: () => void; + className?: string; +}; + +/** + * Big tappable tile for action grids inside BottomDrawer (styled to match + * the capture drawer's option cards). Compose inside a + * `grid grid-cols-2 gap-2` container; see share_post_drawer.tsx. + */ +const DrawerActionButton: FC = ({ icon, label, onClick, className }) => ( + +); + +export default DrawerActionButton; diff --git a/front_end/src/hooks/share.ts b/front_end/src/hooks/share.ts index 5a836ac208..c604626a7c 100644 --- a/front_end/src/hooks/share.ts +++ b/front_end/src/hooks/share.ts @@ -70,16 +70,36 @@ export const useCopyUrl = (options: CurrentUrlOptions = {}) => { const url = useCurrentUrl(options); return useCallback(() => { - if (url) { + if (!url) return; + + const notify = () => + toast("URL is now copied to your clipboard", { + className: "dark:bg-blue-700-dark dark:text-gray-0-dark", + }); + + if (navigator.clipboard) { navigator.clipboard .writeText(url) - .then(() => { - toast("URL is now copied to your clipboard", { - className: "dark:bg-blue-700-dark dark:text-gray-0-dark", - }); - // Optionally, show a notification to the user that the link was copied. - }) + .then(notify) .catch((err) => console.error("Error copying link: ", err)); + return; + } + + // navigator.clipboard only exists in secure contexts; fall back to the + // legacy textarea trick so http:// dev sessions still copy + const textarea = document.createElement("textarea"); + textarea.value = url; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + try { + document.execCommand("copy"); + notify(); + } catch (err) { + console.error("Error copying link: ", err); + } finally { + textarea.remove(); } }, [url]); }; From 72f6482d78d99583e5a7d149dece43f8905edb2f Mon Sep 17 00:00:00 2001 From: Atakan Seckin Date: Wed, 5 Aug 2026 10:41:56 +0200 Subject: [PATCH 09/15] Add subscribe-capture flow experiment (PostHog A/B) Two coherent bundles behind the subscribe_capture_experiment flag: control shows the options step with 'Notify me of updates' copy, test goes straight to the email input under 'Notify me when this resolves' and subscribes to resolution only. Enrollment reuses the anonymous-experiment rails from the autotranslation experiment (middleware evaluation, first-party cookie, same-request header, shared distinct_id) with the variant resolved server-side on the already-dynamic question route so static pages stay static; the root-level drawer falls back to a synchronous cookie read. Exposure registers on surface show, not page load, and capture events carry a captureVariant property. Unenrolled, signed-in, and flag-off all serve the status quo. Co-Authored-By: Claude Fable 5 --- front_end/messages/en.json | 1 + .../questions/[id]/[[...slug]]/page.tsx | 11 ++- .../question_page_shell/notify_me_cta.tsx | 24 +++++- .../email_capture/email_capture_drawer.tsx | 25 +++++- front_end/src/constants/experiments.ts | 14 +++ .../src/contexts/experiments_context.tsx | 76 +++++++++++++++++ front_end/src/contexts/posthog_context.tsx | 42 ++++++--- front_end/src/proxy.ts | 29 ++++++- .../services/autotranslation_experiment.ts | 2 +- .../services/subscribe_capture_experiment.ts | 85 +++++++++++++++++++ .../subscribe_capture_variant.server.ts | 41 +++++++++ 11 files changed, 328 insertions(+), 22 deletions(-) create mode 100644 front_end/src/contexts/experiments_context.tsx create mode 100644 front_end/src/services/subscribe_capture_experiment.ts create mode 100644 front_end/src/services/subscribe_capture_variant.server.ts diff --git a/front_end/messages/en.json b/front_end/messages/en.json index a9f64f1377..bae0a665ab 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -2436,6 +2436,7 @@ "emailLinkNewSent": "We sent a new link to {email}.", "emailLinkNewSentWithAction": "We sent a new link to {email}. It carries the same action.", "notifyMeCtaLabel": "Notify me when this resolves", + "notifyMeCtaLabelUpdates": "Notify me of updates", "notifyMeCtaFollowing": "You're following this question", "emailCaptureSubscribeBodyRepeat": "We'll send a new link to {email}. It turns on these updates instead.", "shareOpenedInNewTab": "Opened {target} in a new tab." diff --git a/front_end/src/app/(main)/questions/[id]/[[...slug]]/page.tsx b/front_end/src/app/(main)/questions/[id]/[[...slug]]/page.tsx index dcf5fc6267..fb809ab1be 100644 --- a/front_end/src/app/(main)/questions/[id]/[[...slug]]/page.tsx +++ b/front_end/src/app/(main)/questions/[id]/[[...slug]]/page.tsx @@ -1,6 +1,8 @@ import { Metadata } from "next"; import { defaultDescription } from "@/constants/metadata"; +import { ExperimentsProvider } from "@/contexts/experiments_context"; +import { getSubscribeCaptureVariantForRequest } from "@/services/subscribe_capture_variant.server"; import { SearchParams } from "@/types/navigation"; import { getValidString } from "@/utils/formatters/string"; import { getPostTitle } from "@/utils/questions/helpers"; @@ -60,6 +62,13 @@ export async function generateMetadata(props: Props): Promise { export default async function IndividualQuestionRoute(props: Props) { const searchParams = await props.searchParams; const params = await props.params; + // This route is dynamic already (per-request post data), so reading the + // experiment variant here costs nothing, unlike in a shared layout + const subscribeCaptureVariant = await getSubscribeCaptureVariantForRequest(); - return ; + return ( + + + + ); } diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/notify_me_cta.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/notify_me_cta.tsx index e9d18ebe46..b6277b0b01 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/notify_me_cta.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/notify_me_cta.tsx @@ -6,6 +6,10 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useTranslations } from "next-intl"; import { FC, useEffect, useRef } from "react"; +import { + registerSubscribeCaptureExposure, + useSubscribeCaptureVariant, +} from "@/contexts/experiments_context"; import { usePostSubscriptionContext } from "@/contexts/post_subscription_context"; import { sendAnalyticsEvent } from "@/utils/analytics"; import cn from "@/utils/core/cn"; @@ -23,14 +27,21 @@ const NotifyMeCta: FC = ({ className }) => { const t = useTranslations(); const { isSubscribed, isLoading, handleSubscribe, handleCustomize } = usePostSubscriptionContext(); + const captureVariant = useSubscribeCaptureVariant(); const shownTrackedRef = useRef(false); useEffect(() => { if (!shownTrackedRef.current) { shownTrackedRef.current = true; - sendAnalyticsEvent("notifyCtaShown", { surface: "questionPageCta" }); + sendAnalyticsEvent("notifyCtaShown", { + surface: "questionPageCta", + captureVariant: captureVariant ?? "none", + }); + if (captureVariant) { + registerSubscribeCaptureExposure(); + } } - }, []); + }, [captureVariant]); if (isSubscribed) { return ( @@ -59,7 +70,10 @@ const NotifyMeCta: FC = ({ className }) => { return ( ); }; diff --git a/front_end/src/components/email_capture/email_capture_drawer.tsx b/front_end/src/components/email_capture/email_capture_drawer.tsx index f8a33ff5e5..b2a9700c6b 100644 --- a/front_end/src/components/email_capture/email_capture_drawer.tsx +++ b/front_end/src/components/email_capture/email_capture_drawer.tsx @@ -17,6 +17,10 @@ import { import Button from "@/components/ui/button"; import BottomDrawer from "@/components/ui/drawer"; import { Input } from "@/components/ui/form_field"; +import { + registerSubscribeCaptureExposure, + useSubscribeCaptureVariant, +} from "@/contexts/experiments_context"; import { useModal } from "@/contexts/modal_context"; import { usePublicSettings } from "@/contexts/public_settings_context"; import { useBreakpoint } from "@/hooks/tailwind"; @@ -72,11 +76,15 @@ const EmailCaptureDrawer: FC = ({ const pathname = usePathname(); const searchParams = useSearchParams(); + const captureVariant = useSubscribeCaptureVariant(); const openedAsRecap = initialView === "sent"; + // Experiment test arm skips the options step: straight to email, and the + // default selection (resolution only) becomes the subscription payload const hasOptionsStep = trigger === "post_subscribe" && !subscribePost?.isNotebook && - !openedAsRecap; + !openedAsRecap && + captureVariant !== "test"; const [view, setView] = useState( initialView ?? (hasOptionsStep ? "options" : "input") @@ -117,7 +125,14 @@ const EmailCaptureDrawer: FC = ({ useEffect(() => { if (!isOpen) return; sentThisSessionRef.current = false; - sendAnalyticsEvent("emailCaptureShown", { trigger, surface }); + sendAnalyticsEvent("emailCaptureShown", { + trigger, + surface, + captureVariant: captureVariant ?? "none", + }); + if (trigger === "post_subscribe" && captureVariant) { + registerSubscribeCaptureExposure(); + } if (hasOptionsStep) { sendAnalyticsEvent("subscribeOptionsShown", { trigger, surface }); } @@ -222,7 +237,11 @@ const EmailCaptureDrawer: FC = ({ setSentEmail(email); setLastSendAt(sendAt); sentThisSessionRef.current = true; - sendAnalyticsEvent("emailSubmitted", { trigger, surface }); + sendAnalyticsEvent("emailSubmitted", { + trigger, + surface, + captureVariant: captureVariant ?? "none", + }); return true; }; diff --git a/front_end/src/constants/experiments.ts b/front_end/src/constants/experiments.ts index ca77027555..f23b76fbb3 100644 --- a/front_end/src/constants/experiments.ts +++ b/front_end/src/constants/experiments.ts @@ -15,6 +15,20 @@ export type AutotranslationAssignment = { export const AUTOTRANSLATION_TARGET_LOCALES = ["cs", "es", "pt", "zh", "zh-TW"]; +// Subscribe-capture flow experiment: control shows the options step with +// "Notify me of updates" copy, test goes straight to the email input with +// "Notify me when this resolves" and subscribes to resolution only. +// Shares the "distinctId:variant" cookie format (parse/serializeAssignment) +// and the control/test variant set with the auto-translation experiment. +export const SUBSCRIBE_CAPTURE_FLAG_KEY = "subscribe_capture_experiment"; +export const SUBSCRIBE_CAPTURE_COOKIE_NAME = "metaculus_subscribe_capture_ab"; +export const SUBSCRIBE_CAPTURE_HEADER = "x-subscribe-capture-variant"; +export const SUBSCRIBE_CAPTURE_COOKIE_MAX_AGE = 60 * 60 * 24 * 182; // 26 weeks + +export const EXPERIMENT_VARIANTS = AUTOTRANSLATION_VARIANTS; +export type ExperimentVariant = AutotranslationVariant; +export type ExperimentAssignment = AutotranslationAssignment; + export function parseAssignment( raw: string | undefined ): AutotranslationAssignment | null { diff --git a/front_end/src/contexts/experiments_context.tsx b/front_end/src/contexts/experiments_context.tsx new file mode 100644 index 0000000000..76450b0b10 --- /dev/null +++ b/front_end/src/contexts/experiments_context.tsx @@ -0,0 +1,76 @@ +"use client"; + +import posthog from "posthog-js"; +import { + createContext, + FC, + PropsWithChildren, + useContext, + useState, +} from "react"; + +import { + ExperimentVariant, + parseAssignment, + SUBSCRIBE_CAPTURE_COOKIE_NAME, + SUBSCRIBE_CAPTURE_FLAG_KEY, +} from "@/constants/experiments"; +import { useAuth } from "@/contexts/auth_context"; + +type ExperimentsContextValue = { + subscribeCaptureVariant: ExperimentVariant | null; +}; + +// Server-resolved experiment assignment, provided from the (main) layout so +// SSR renders variant copy without a flicker. Null means not enrolled +// (logged in, bot, flag off, or evaluation failed): serve the status quo. +const ExperimentsContext = createContext({ + subscribeCaptureVariant: null, +}); + +export const ExperimentsProvider: FC< + PropsWithChildren +> = ({ subscribeCaptureVariant, children }) => ( + + {children} + +); + +function readAssignmentCookieVariant(): ExperimentVariant | null { + const raw = document.cookie + .split("; ") + .find((cookie) => cookie.startsWith(`${SUBSCRIBE_CAPTURE_COOKIE_NAME}=`)) + ?.slice(SUBSCRIBE_CAPTURE_COOKIE_NAME.length + 1); + if (!raw) return null; + + try { + return parseAssignment(decodeURIComponent(raw))?.variant ?? null; + } catch { + return null; + } +} + +export const useSubscribeCaptureVariant = (): ExperimentVariant | null => { + const { subscribeCaptureVariant } = useContext(ExperimentsContext); + const { user } = useAuth(); + // Cookie fallback for consumers mounted outside the question-page provider + // (the capture drawer lives in root-level GlobalModals and only mounts + // client-side). Read synchronously so the first render is already correct; + // an SSR'd consumer outside the provider would risk a hydration mismatch + // here, so keep such components inside ExperimentsProvider. + const [cookieVariant] = useState(() => + typeof document === "undefined" ? null : readAssignmentCookieVariant() + ); + + if (user) return null; + return subscribeCaptureVariant ?? cookieVariant; +}; + +/** + * Registers $feature_flag_called so PostHog counts this visitor as exposed. + * Call when an experiment surface is actually shown (not on page load), so + * the exposed population matches people who could be affected. + */ +export const registerSubscribeCaptureExposure = () => { + posthog.getFeatureFlag(SUBSCRIBE_CAPTURE_FLAG_KEY); +}; diff --git a/front_end/src/contexts/posthog_context.tsx b/front_end/src/contexts/posthog_context.tsx index 027f0c19e6..5d5e3ff836 100644 --- a/front_end/src/contexts/posthog_context.tsx +++ b/front_end/src/contexts/posthog_context.tsx @@ -9,17 +9,19 @@ import { getPublicSetting } from "@/components/public_settings_script"; import { AUTOTRANSLATION_COOKIE_NAME, AUTOTRANSLATION_FLAG_KEY, - AutotranslationAssignment, + ExperimentAssignment, parseAssignment, + SUBSCRIBE_CAPTURE_COOKIE_NAME, + SUBSCRIBE_CAPTURE_FLAG_KEY, } from "@/constants/experiments"; -// The auto-translation experiment assignment is pinned in a first-party -// cookie by the middleware (proxy.ts) when an eligible visitor is enrolled -function getAutotranslationAssignment(): AutotranslationAssignment | null { +// Experiment assignments are pinned in first-party cookies by the +// middleware (proxy.ts) when an eligible visitor is enrolled +function getAssignmentCookie(cookieName: string): ExperimentAssignment | null { const raw = document.cookie .split("; ") - .find((cookie) => cookie.startsWith(`${AUTOTRANSLATION_COOKIE_NAME}=`)) - ?.slice(AUTOTRANSLATION_COOKIE_NAME.length + 1); + .find((cookie) => cookie.startsWith(`${cookieName}=`)) + ?.slice(cookieName.length + 1); if (!raw) return null; try { @@ -43,7 +45,15 @@ function CSPostHogProvider({ const PUBLIC_POSTHOG_BASE_URL = getPublicSetting("PUBLIC_POSTHOG_BASE_URL"); if (PUBLIC_POSTHOG_KEY) { - const autotranslationAssignment = getAutotranslationAssignment(); + const autotranslationAssignment = getAssignmentCookie( + AUTOTRANSLATION_COOKIE_NAME + ); + const subscribeCaptureAssignment = getAssignmentCookie( + SUBSCRIBE_CAPTURE_COOKIE_NAME + ); + // Both enrollments share one identity by construction (proxy.ts) + const bootstrapAssignment = + autotranslationAssignment ?? subscribeCaptureAssignment; posthog.init(PUBLIC_POSTHOG_KEY, { api_host: PUBLIC_POSTHOG_BASE_URL, @@ -58,20 +68,28 @@ function CSPostHogProvider({ : "memory", // Reuse the server-side experiment assignment: the same distinct_id // keeps identity stable across visits under memory persistence, and - // the bootstrapped flag stamps $feature/... on events from the start - ...(autotranslationAssignment && { + // the bootstrapped flags stamp $feature/... on events from the start + ...(bootstrapAssignment && { bootstrap: { - distinctID: autotranslationAssignment.distinctId, + distinctID: bootstrapAssignment.distinctId, isIdentifiedID: false, featureFlags: { - [AUTOTRANSLATION_FLAG_KEY]: autotranslationAssignment.variant, + ...(autotranslationAssignment && { + [AUTOTRANSLATION_FLAG_KEY]: autotranslationAssignment.variant, + }), + ...(subscribeCaptureAssignment && { + [SUBSCRIBE_CAPTURE_FLAG_KEY]: + subscribeCaptureAssignment.variant, + }), }, }, }), }); if (autotranslationAssignment) { - // Captures $feature_flag_called so PostHog registers exposure + // Captures $feature_flag_called so PostHog registers exposure. + // The subscribe-capture experiment registers exposure only when + // one of its surfaces is shown (registerSubscribeCaptureExposure) posthog.getFeatureFlag(AUTOTRANSLATION_FLAG_KEY); } } diff --git a/front_end/src/proxy.ts b/front_end/src/proxy.ts index 75a5b12998..a31355bd20 100644 --- a/front_end/src/proxy.ts +++ b/front_end/src/proxy.ts @@ -1,6 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; -import { AUTOTRANSLATION_HEADER } from "@/constants/experiments"; +import { + AUTOTRANSLATION_HEADER, + SUBSCRIBE_CAPTURE_HEADER, +} from "@/constants/experiments"; import ServerAuthApi from "@/services/api/auth/auth.server"; import { AuthCookieManager, AuthCookieReader } from "@/services/auth_tokens"; import { @@ -12,6 +15,10 @@ import { LOCALE_COOKIE_NAME, } from "@/services/language_service"; import { getAlphaTokenSession } from "@/services/session"; +import { + getSubscribeCaptureEnrollment, + setSubscribeCaptureCookieInResponse, +} from "@/services/subscribe_capture_experiment"; import { getAlphaAccessToken } from "@/utils/alpha_access"; import { ApiError } from "@/utils/core/errors"; import { applyCspHeaders, buildCsp } from "@/utils/csp"; @@ -116,6 +123,23 @@ export async function proxy(request: NextRequest) { requestHeaders.delete(AUTOTRANSLATION_HEADER); } + // Subscribe-capture flow experiment: same enrollment mechanism, sharing + // the distinct_id if the auto-translation enrollment just minted one + const subscribeCaptureEnrollment = await getSubscribeCaptureEnrollment( + request, + requestAuth, + shouldApplyCsp, + autotranslationEnrollment?.distinctId ?? null + ); + if (subscribeCaptureEnrollment) { + requestHeaders.set( + SUBSCRIBE_CAPTURE_HEADER, + subscribeCaptureEnrollment.variant + ); + } else { + requestHeaders.delete(SUBSCRIBE_CAPTURE_HEADER); + } + const response = NextResponse.next({ request: { headers: requestHeaders } }); if (cspHeader) { applyCspHeaders(response, cspHeader); @@ -125,6 +149,9 @@ export async function proxy(request: NextRequest) { if (autotranslationEnrollment) { setAssignmentCookieInResponse(response, autotranslationEnrollment); } + if (subscribeCaptureEnrollment) { + setSubscribeCaptureCookieInResponse(response, subscribeCaptureEnrollment); + } let hasSession = false; const accessToken = requestAuth.getAccessToken(); diff --git a/front_end/src/services/autotranslation_experiment.ts b/front_end/src/services/autotranslation_experiment.ts index 50e1494a31..7278fbe0c0 100644 --- a/front_end/src/services/autotranslation_experiment.ts +++ b/front_end/src/services/autotranslation_experiment.ts @@ -46,7 +46,7 @@ export function isBotUserAgent(userAgent: string): boolean { return BOT_UA_REGEX.test(userAgent); } -function getPostHogDistinctIdFromCookie( +export function getPostHogDistinctIdFromCookie( request: NextRequest, posthogKey: string ): string | null { diff --git a/front_end/src/services/subscribe_capture_experiment.ts b/front_end/src/services/subscribe_capture_experiment.ts new file mode 100644 index 0000000000..703f7717be --- /dev/null +++ b/front_end/src/services/subscribe_capture_experiment.ts @@ -0,0 +1,85 @@ +import "server-only"; + +import { NextRequest, NextResponse } from "next/server"; + +import { + AUTOTRANSLATION_COOKIE_NAME, + ExperimentAssignment, + parseAssignment, + serializeAssignment, + SUBSCRIBE_CAPTURE_COOKIE_MAX_AGE, + SUBSCRIBE_CAPTURE_COOKIE_NAME, + SUBSCRIBE_CAPTURE_FLAG_KEY, +} from "@/constants/experiments"; +import { AuthCookieReader } from "@/services/auth_tokens"; +import { + getPostHogDistinctIdFromCookie, + isBotUserAgent, +} from "@/services/autotranslation_experiment"; +import { getFeatureFlagVariantForDistinctId } from "@/utils/posthog.server"; +import { getPublicSettings } from "@/utils/public_settings.server"; + +export function setSubscribeCaptureCookieInResponse( + response: NextResponse, + assignment: ExperimentAssignment +): void { + response.cookies.set( + SUBSCRIBE_CAPTURE_COOKIE_NAME, + serializeAssignment(assignment), + { + maxAge: SUBSCRIBE_CAPTURE_COOKIE_MAX_AGE, + // Readable by the PostHog provider, which bootstraps from it client-side + httpOnly: false, + secure: true, + sameSite: "lax", + path: "/", + } + ); +} + +/** + * Enrolls eligible anonymous document requests into the subscribe-capture + * flow experiment. Returns null when ineligible, already enrolled, or when + * flag evaluation fails (fail open: the status-quo flow is served and + * enrollment is retried on the next visit). + * + * Identity: reuses, in order, the id the auto-translation enrollment minted + * on this same request, the PostHog cookie id, or the other experiment's + * cookie id, so one visitor never carries two distinct_ids. + */ +export async function getSubscribeCaptureEnrollment( + request: NextRequest, + requestAuth: AuthCookieReader, + isDocumentRequest: boolean, + sharedDistinctId?: string | null +): Promise { + const { PUBLIC_AUTHENTICATION_REQUIRED, PUBLIC_POSTHOG_KEY } = + getPublicSettings(); + + if ( + PUBLIC_AUTHENTICATION_REQUIRED || + !isDocumentRequest || + requestAuth.hasAuthSession() || + isBotUserAgent(request.headers.get("user-agent") ?? "") || + parseAssignment(request.cookies.get(SUBSCRIBE_CAPTURE_COOKIE_NAME)?.value) + ) { + return null; + } + + const distinctId = + sharedDistinctId ?? + getPostHogDistinctIdFromCookie(request, PUBLIC_POSTHOG_KEY) ?? + parseAssignment(request.cookies.get(AUTOTRANSLATION_COOKIE_NAME)?.value) + ?.distinctId ?? + crypto.randomUUID(); + + const variant = await getFeatureFlagVariantForDistinctId( + SUBSCRIBE_CAPTURE_FLAG_KEY, + distinctId + ); + if (variant !== "control" && variant !== "test") { + return null; + } + + return { distinctId, variant }; +} diff --git a/front_end/src/services/subscribe_capture_variant.server.ts b/front_end/src/services/subscribe_capture_variant.server.ts new file mode 100644 index 0000000000..afef52dba0 --- /dev/null +++ b/front_end/src/services/subscribe_capture_variant.server.ts @@ -0,0 +1,41 @@ +import "server-only"; + +import { cookies, headers } from "next/headers"; + +import { + EXPERIMENT_VARIANTS, + ExperimentVariant, + parseAssignment, + SUBSCRIBE_CAPTURE_COOKIE_NAME, + SUBSCRIBE_CAPTURE_HEADER, +} from "@/constants/experiments"; +import { COOKIE_NAME_REFRESH_TOKEN } from "@/services/auth_tokens"; + +// Kept separate from subscribe_capture_experiment.ts on purpose: that module +// is imported by the middleware (proxy.ts), and pulling next/headers into the +// middleware bundle marks unrelated static routes as dynamic at build time. + +/** + * Resolves the request's variant for server rendering. Call from routes that + * are already dynamic (it reads headers/cookies): the header carries the + * variant on the enrollment request itself, the cookie on later visits. + * Null for signed-in visitors and the unenrolled: serve the status quo. + */ +export async function getSubscribeCaptureVariantForRequest(): Promise { + const requestHeaders = await headers(); + const cookieStore = await cookies(); + + if (cookieStore.has(COOKIE_NAME_REFRESH_TOKEN)) { + return null; + } + + const headerVariant = requestHeaders.get(SUBSCRIBE_CAPTURE_HEADER); + if (headerVariant && EXPERIMENT_VARIANTS.includes(headerVariant as never)) { + return headerVariant as ExperimentVariant; + } + + return ( + parseAssignment(cookieStore.get(SUBSCRIBE_CAPTURE_COOKIE_NAME)?.value) + ?.variant ?? null + ); +} From 5ef922c28c5ba24dcc4f4a8a1f0fb048f0631ec2 Mon Sep 17 00:00:00 2001 From: Atakan Seckin Date: Wed, 5 Aug 2026 10:52:50 +0200 Subject: [PATCH 10/15] Translate lightweight-accounts copy into all locales Adds es/cs/pt/zh/zh-TW translations for the 62 new keys (email capture drawer, confirm banner, dead-link recovery, notify CTA, share drawer, experiment copy). Placeholders and rich-text tags preserved; the compositional subscribe phrases translated to read grammatically inside the 'get updates when {a}' sentences. Co-Authored-By: Claude Fable 5 --- front_end/messages/cs.json | 64 ++++++++++++++++++++++++++++++++++- front_end/messages/es.json | 64 ++++++++++++++++++++++++++++++++++- front_end/messages/pt.json | 64 ++++++++++++++++++++++++++++++++++- front_end/messages/zh-TW.json | 64 ++++++++++++++++++++++++++++++++++- front_end/messages/zh.json | 64 ++++++++++++++++++++++++++++++++++- 5 files changed, 315 insertions(+), 5 deletions(-) diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 392fa27674..3829a29bb5 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -2387,5 +2387,67 @@ "laborHubJobsChartA11y": "Předpověď zaměstnanosti podle roku", "laborHubJobsChartBaseline": "Základ", "predictorsMentionWarning": "Pouze kurátoři a administrátoři mohou upozornit @predictors. Vaše zmínka neodešle oznámení.", - "feedTileQuestionsRecentlyResolved": "{count, plural, one {# otázka nedávno vyřešena} other {# otázek nedávno vyřešeno}}" + "feedTileQuestionsRecentlyResolved": "{count, plural, one {# otázka nedávno vyřešena} other {# otázek nedávno vyřešeno}}", + "emailCaptureOptionsTitle": "Dostávejte novinky k této otázce.", + "emailCaptureOptionsSubtitle": "Vyberte, o čem chcete být informováni.", + "emailCaptureOptionResolve": "Když se vyřeší", + "emailCaptureOptionForecast": "Změny prognózy", + "emailCaptureOptionDiscussion": "Nová diskuse", + "emailCaptureOptionsContinue": "Pokračovat", + "emailCaptureOptionsCaptionNone": "Pro pokračování vyberte alespoň jednu možnost.", + "emailCaptureSubscribeTitle": "Zadejte svůj e-mail pro sledování této otázky", + "emailCaptureSubscribeBody": "Pošleme vám odkaz k dokončení této akce.", + "emailCaptureVoteTitle": "Uložte svůj hlas", + "emailCaptureVoteBody": "Zadejte svůj e-mail. Pošleme vám odkaz, který uloží váš hlas.", + "emailCaptureVoteBodyRepeat": "Pošleme nový odkaz na {email}. Uloží místo toho tento hlas.", + "emailCaptureVoteCaption": "Odkaz vás přihlásí a uloží váš hlas. Platí jeden den.", + "emailCaptureForecastTitle": "Uložte svou prognózu", + "emailCaptureForecastBody": "Zadejte svůj e-mail. Pošleme vám odkaz, který uloží vaši prognózu.", + "emailCaptureForecastBodyRepeat": "Pošleme nový odkaz na {email}. Uloží místo toho tuto prognózu.", + "emailCaptureForecastCaption": "Odkaz vás přihlásí a uloží vaši prognózu. Platí jeden den.", + "emailCaptureForecastBodyNoDraft": "Zadejte svůj e-mail. Pošleme vám odkaz, který vás přihlásí k prognózování.", + "emailCaptureSignInCaption": "Odkaz vás přihlásí. Platí jeden den.", + "emailCaptureBodyRepeat": "Pošleme nový odkaz na {email}.", + "emailCaptureEmailLabel": "E-mail", + "emailCaptureSend": "Odeslat odkaz", + "emailCaptureSendNew": "Odeslat nový odkaz", + "emailCaptureSending": "Odesílání...", + "emailCaptureTryAgain": "Zkusit znovu", + "emailCaptureFormatError": "To nevypadá jako e-mailová adresa. Zkontrolujte ji a zkuste to znovu.", + "emailCaptureServerError": "Na naší straně došlo k chybě. Váš e-mail nebyl odeslán. Zkuste to znovu.", + "emailCaptureSentTitle": "Zkontrolujte svůj e-mail", + "emailCaptureSentBody": "Poslali jsme odkaz na {email}.", + "emailCaptureSentActionVote": "Klikněte na něj a uložte svůj hlas.", + "emailCaptureSentActionForecast": "Klikněte na něj a uložte svou prognózu.", + "emailCaptureSentActionSubscribeAll": "Klikněte na něj a dostávejte novinky k této otázce.", + "emailCaptureSentActionSignIn": "Klikněte na něj, přihlaste se a vytvořte svou prognózu.", + "emailCaptureSentActionSubscribeOne": "Klikněte na něj a dostávejte novinky, když {a}.", + "emailCaptureSentActionSubscribeTwo": "Klikněte na něj a dostávejte novinky, když {a} nebo {b}.", + "emailCapturePhraseResolve": "se to vyřeší", + "emailCapturePhraseForecast": "se prognóza změní", + "emailCapturePhraseDiscussion": "proběhne nová diskuse", + "emailCaptureSentNote": "Odkaz vás přihlásí a platí jeden den. Můžete jej otevřít na jakémkoli zařízení.", + "emailCaptureSentNoteRepeat": "Tento odkaz nahrazuje ten předchozí. Funguje pouze váš nejnovější odkaz.", + "emailCaptureDone": "Hotovo", + "emailCaptureWrongAddress": "Špatná adresa? Použijte jiný e-mail", + "emailCaptureUseDifferentEmail": "Použít jiný e-mail", + "emailCaptureRecapSentTo": "Odesláno na {email}.", + "emailCaptureRecapActionVote": "Klikněte na odkaz v tomto e-mailu a uložte svůj hlas.", + "emailCaptureRecapActionForecast": "Klikněte na odkaz v tomto e-mailu a uložte svou prognózu.", + "emailCaptureRecapActionSubscribe": "Klikněte na odkaz v tomto e-mailu a zapněte si odběr novinek.", + "emailCaptureResend": "Odeslat e-mail znovu", + "emailCaptureResendIn": "Znovu odeslat za {seconds}s", + "emailCaptureResendFeedback": "Odesláno. Zkontrolujte {email}.", + "emailCaptureGoogle": "Přihlásit se přes Google", + "emailCapturePassword": "Přihlásit se heslem", + "emailCaptureBack": "Zpět", + "emailConfirmBannerText": "Poslali jsme potvrzovací e-mail na .", + "emailLinkRequestNew": "Odeslat nový odkaz", + "emailLinkNewSent": "Poslali jsme nový odkaz na {email}.", + "emailLinkNewSentWithAction": "Poslali jsme nový odkaz na {email}. Nese stejnou akci.", + "notifyMeCtaLabel": "Upozornit mě, až se vyřeší", + "notifyMeCtaLabelUpdates": "Upozorňovat mě na novinky", + "notifyMeCtaFollowing": "Sledujete tuto otázku", + "emailCaptureSubscribeBodyRepeat": "Pošleme nový odkaz na {email}. Místo toho zapne tyto novinky.", + "shareOpenedInNewTab": "{target} otevřeno v nové kartě." } diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 3671774e5c..9a60af949e 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -2387,5 +2387,67 @@ "laborHubJobsChartA11y": "Pronóstico de empleo por año", "laborHubJobsChartBaseline": "Base", "predictorsMentionWarning": "Solo los curadores y administradores pueden notificar a @predictors. Tu mención no enviará notificaciones.", - "feedTileQuestionsRecentlyResolved": "{count, plural, one {# pregunta resuelta recientemente} other {# preguntas resueltas recientemente}}" + "feedTileQuestionsRecentlyResolved": "{count, plural, one {# pregunta resuelta recientemente} other {# preguntas resueltas recientemente}}", + "emailCaptureOptionsTitle": "Recibe novedades sobre esta pregunta.", + "emailCaptureOptionsSubtitle": "Elige sobre qué quieres recibir avisos.", + "emailCaptureOptionResolve": "Cuando se resuelva", + "emailCaptureOptionForecast": "Cambios en el pronóstico", + "emailCaptureOptionDiscussion": "Nueva discusión", + "emailCaptureOptionsContinue": "Continuar", + "emailCaptureOptionsCaptionNone": "Elige al menos una opción para continuar.", + "emailCaptureSubscribeTitle": "Introduce tu correo para seguir esta pregunta", + "emailCaptureSubscribeBody": "Te enviaremos un enlace para completar esta acción.", + "emailCaptureVoteTitle": "Guarda tu voto", + "emailCaptureVoteBody": "Introduce tu correo. Te enviaremos un enlace que guardará tu voto.", + "emailCaptureVoteBodyRepeat": "Enviaremos un nuevo enlace a {email}. Guardará este voto en su lugar.", + "emailCaptureVoteCaption": "El enlace inicia tu sesión y guarda tu voto. Es válido durante un día.", + "emailCaptureForecastTitle": "Guarda tu pronóstico", + "emailCaptureForecastBody": "Introduce tu correo. Te enviaremos un enlace que guardará tu pronóstico.", + "emailCaptureForecastBodyRepeat": "Enviaremos un nuevo enlace a {email}. Guardará este pronóstico en su lugar.", + "emailCaptureForecastCaption": "El enlace inicia tu sesión y guarda tu pronóstico. Es válido durante un día.", + "emailCaptureForecastBodyNoDraft": "Introduce tu correo. Te enviaremos un enlace para iniciar sesión y pronosticar.", + "emailCaptureSignInCaption": "El enlace inicia tu sesión. Es válido durante un día.", + "emailCaptureBodyRepeat": "Enviaremos un nuevo enlace a {email}.", + "emailCaptureEmailLabel": "Correo electrónico", + "emailCaptureSend": "Enviar enlace", + "emailCaptureSendNew": "Enviar nuevo enlace", + "emailCaptureSending": "Enviando...", + "emailCaptureTryAgain": "Intentar de nuevo", + "emailCaptureFormatError": "Eso no parece una dirección de correo. Revísala e inténtalo de nuevo.", + "emailCaptureServerError": "Algo salió mal por nuestra parte. Tu correo no se envió. Inténtalo de nuevo.", + "emailCaptureSentTitle": "Revisa tu correo", + "emailCaptureSentBody": "Enviamos un enlace a {email}.", + "emailCaptureSentActionVote": "Haz clic en él para guardar tu voto.", + "emailCaptureSentActionForecast": "Haz clic en él para guardar tu pronóstico.", + "emailCaptureSentActionSubscribeAll": "Haz clic en él para recibir novedades sobre esta pregunta.", + "emailCaptureSentActionSignIn": "Haz clic en él para iniciar sesión y hacer tu pronóstico.", + "emailCaptureSentActionSubscribeOne": "Haz clic en él para recibir novedades cuando {a}.", + "emailCaptureSentActionSubscribeTwo": "Haz clic en él para recibir novedades cuando {a} o {b}.", + "emailCapturePhraseResolve": "esto se resuelva", + "emailCapturePhraseForecast": "el pronóstico cambie", + "emailCapturePhraseDiscussion": "haya nueva discusión", + "emailCaptureSentNote": "El enlace inicia tu sesión y es válido durante un día. Puedes abrirlo en cualquier dispositivo.", + "emailCaptureSentNoteRepeat": "Este enlace reemplaza al anterior. Solo funciona tu enlace más reciente.", + "emailCaptureDone": "Listo", + "emailCaptureWrongAddress": "¿Dirección incorrecta? Usa otro correo", + "emailCaptureUseDifferentEmail": "Usa otro correo", + "emailCaptureRecapSentTo": "Enviado a {email}.", + "emailCaptureRecapActionVote": "Haz clic en el enlace de ese correo para guardar tu voto.", + "emailCaptureRecapActionForecast": "Haz clic en el enlace de ese correo para guardar tu pronóstico.", + "emailCaptureRecapActionSubscribe": "Haz clic en el enlace de ese correo para activar tus novedades.", + "emailCaptureResend": "Reenviar correo", + "emailCaptureResendIn": "Reenviar en {seconds}s", + "emailCaptureResendFeedback": "Enviado. Revisa {email}.", + "emailCaptureGoogle": "Iniciar sesión con Google", + "emailCapturePassword": "Iniciar sesión con contraseña", + "emailCaptureBack": "Volver", + "emailConfirmBannerText": "Enviamos un correo de confirmación a .", + "emailLinkRequestNew": "Enviar un nuevo enlace", + "emailLinkNewSent": "Enviamos un nuevo enlace a {email}.", + "emailLinkNewSentWithAction": "Enviamos un nuevo enlace a {email}. Conserva la misma acción.", + "notifyMeCtaLabel": "Notifícame cuando se resuelva", + "notifyMeCtaLabelUpdates": "Notifícame las novedades", + "notifyMeCtaFollowing": "Estás siguiendo esta pregunta", + "emailCaptureSubscribeBodyRepeat": "Enviaremos un nuevo enlace a {email}. Activará estas novedades en su lugar.", + "shareOpenedInNewTab": "Se abrió {target} en una pestaña nueva." } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 4e87829a78..dfe129112f 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -2385,5 +2385,67 @@ "laborHubJobsChartA11y": "Previsão de emprego por ano", "laborHubJobsChartBaseline": "Base", "predictorsMentionWarning": "Somente curadores e administradores podem notificar @predictors. Sua menção não enviará notificações.", - "feedTileQuestionsRecentlyResolved": "{count, plural, one {# pergunta recentemente resolvida} other {# perguntas recentemente resolvidas}}" + "feedTileQuestionsRecentlyResolved": "{count, plural, one {# pergunta recentemente resolvida} other {# perguntas recentemente resolvidas}}", + "emailCaptureOptionsTitle": "Receba novidades sobre esta pergunta.", + "emailCaptureOptionsSubtitle": "Escolha sobre o que você quer ser avisado.", + "emailCaptureOptionResolve": "Quando for resolvida", + "emailCaptureOptionForecast": "Mudanças na previsão", + "emailCaptureOptionDiscussion": "Nova discussão", + "emailCaptureOptionsContinue": "Continuar", + "emailCaptureOptionsCaptionNone": "Escolha pelo menos uma opção para continuar.", + "emailCaptureSubscribeTitle": "Insira seu e-mail para seguir esta pergunta", + "emailCaptureSubscribeBody": "Enviaremos um link para você concluir esta ação.", + "emailCaptureVoteTitle": "Salve seu voto", + "emailCaptureVoteBody": "Insira seu e-mail. Enviaremos um link que salvará seu voto.", + "emailCaptureVoteBodyRepeat": "Enviaremos um novo link para {email}. Ele salvará este voto no lugar.", + "emailCaptureVoteCaption": "O link faz seu login e salva seu voto. Ele é válido por um dia.", + "emailCaptureForecastTitle": "Salve sua previsão", + "emailCaptureForecastBody": "Insira seu e-mail. Enviaremos um link que salvará sua previsão.", + "emailCaptureForecastBodyRepeat": "Enviaremos um novo link para {email}. Ele salvará esta previsão no lugar.", + "emailCaptureForecastCaption": "O link faz seu login e salva sua previsão. Ele é válido por um dia.", + "emailCaptureForecastBodyNoDraft": "Insira seu e-mail. Enviaremos um link para você entrar e fazer previsões.", + "emailCaptureSignInCaption": "O link faz seu login. Ele é válido por um dia.", + "emailCaptureBodyRepeat": "Enviaremos um novo link para {email}.", + "emailCaptureEmailLabel": "E-mail", + "emailCaptureSend": "Enviar link", + "emailCaptureSendNew": "Enviar novo link", + "emailCaptureSending": "Enviando...", + "emailCaptureTryAgain": "Tentar novamente", + "emailCaptureFormatError": "Isso não parece um endereço de e-mail. Verifique e tente novamente.", + "emailCaptureServerError": "Algo deu errado do nosso lado. Seu e-mail não foi enviado. Tente novamente.", + "emailCaptureSentTitle": "Verifique seu e-mail", + "emailCaptureSentBody": "Enviamos um link para {email}.", + "emailCaptureSentActionVote": "Clique nele para salvar seu voto.", + "emailCaptureSentActionForecast": "Clique nele para salvar sua previsão.", + "emailCaptureSentActionSubscribeAll": "Clique nele para receber novidades sobre esta pergunta.", + "emailCaptureSentActionSignIn": "Clique nele para entrar e fazer sua previsão.", + "emailCaptureSentActionSubscribeOne": "Clique nele para receber novidades quando {a}.", + "emailCaptureSentActionSubscribeTwo": "Clique nele para receber novidades quando {a} ou {b}.", + "emailCapturePhraseResolve": "isto for resolvido", + "emailCapturePhraseForecast": "a previsão mudar", + "emailCapturePhraseDiscussion": "houver nova discussão", + "emailCaptureSentNote": "O link faz seu login e é válido por um dia. Você pode abri-lo em qualquer dispositivo.", + "emailCaptureSentNoteRepeat": "Este link substitui o anterior. Apenas o seu link mais recente funciona.", + "emailCaptureDone": "Concluído", + "emailCaptureWrongAddress": "Endereço errado? Use outro e-mail", + "emailCaptureUseDifferentEmail": "Usar outro e-mail", + "emailCaptureRecapSentTo": "Enviado para {email}.", + "emailCaptureRecapActionVote": "Clique no link desse e-mail para salvar seu voto.", + "emailCaptureRecapActionForecast": "Clique no link desse e-mail para salvar sua previsão.", + "emailCaptureRecapActionSubscribe": "Clique no link desse e-mail para ativar suas novidades.", + "emailCaptureResend": "Reenviar e-mail", + "emailCaptureResendIn": "Reenviar em {seconds}s", + "emailCaptureResendFeedback": "Enviado. Verifique {email}.", + "emailCaptureGoogle": "Entrar com Google", + "emailCapturePassword": "Entrar com senha", + "emailCaptureBack": "Voltar", + "emailConfirmBannerText": "Enviamos um e-mail de confirmação para .", + "emailLinkRequestNew": "Enviar um novo link", + "emailLinkNewSent": "Enviamos um novo link para {email}.", + "emailLinkNewSentWithAction": "Enviamos um novo link para {email}. Ele mantém a mesma ação.", + "notifyMeCtaLabel": "Notificar-me quando for resolvida", + "notifyMeCtaLabelUpdates": "Notificar-me sobre novidades", + "notifyMeCtaFollowing": "Você está seguindo esta pergunta", + "emailCaptureSubscribeBodyRepeat": "Enviaremos um novo link para {email}. Ele ativará estas novidades no lugar.", + "shareOpenedInNewTab": "Abrimos {target} em uma nova aba." } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index a8f6e1ac0e..18e2628e0a 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -2384,5 +2384,67 @@ "laborHubJobsChartA11y": "按年份的就業預測", "laborHubJobsChartBaseline": "基準", "predictorsMentionWarning": "只有館長和管理員可以通知 @predictors。您的提及將不會發送通知。", - "feedTileQuestionsRecentlyResolved": "{count, plural, one {最近解決了 # 個問題} other {最近解決了 # 個問題}}" + "feedTileQuestionsRecentlyResolved": "{count, plural, one {最近解決了 # 個問題} other {最近解決了 # 個問題}}", + "emailCaptureOptionsTitle": "取得此問題的更新。", + "emailCaptureOptionsSubtitle": "選擇你想接收哪些通知。", + "emailCaptureOptionResolve": "當其解決時", + "emailCaptureOptionForecast": "預測變化", + "emailCaptureOptionDiscussion": "新討論", + "emailCaptureOptionsContinue": "繼續", + "emailCaptureOptionsCaptionNone": "至少選擇一項以繼續。", + "emailCaptureSubscribeTitle": "輸入你的電子郵件以追蹤此問題", + "emailCaptureSubscribeBody": "我們將向你發送一個連結以完成此操作。", + "emailCaptureVoteTitle": "儲存你的投票", + "emailCaptureVoteBody": "輸入你的電子郵件。我們將發送一個儲存你投票的連結。", + "emailCaptureVoteBodyRepeat": "我們將向 {email} 發送一個新連結。它將改為儲存此投票。", + "emailCaptureVoteCaption": "該連結會為你登入並儲存你的投票。有效期為一天。", + "emailCaptureForecastTitle": "儲存你的預測", + "emailCaptureForecastBody": "輸入你的電子郵件。我們將發送一個儲存你預測的連結。", + "emailCaptureForecastBodyRepeat": "我們將向 {email} 發送一個新連結。它將改為儲存此預測。", + "emailCaptureForecastCaption": "該連結會為你登入並儲存你的預測。有效期為一天。", + "emailCaptureForecastBodyNoDraft": "輸入你的電子郵件。我們將發送一個連結,讓你登入以進行預測。", + "emailCaptureSignInCaption": "該連結會為你登入。有效期為一天。", + "emailCaptureBodyRepeat": "我們將向 {email} 發送一個新連結。", + "emailCaptureEmailLabel": "電子郵件", + "emailCaptureSend": "發送連結", + "emailCaptureSendNew": "發送新連結", + "emailCaptureSending": "發送中...", + "emailCaptureTryAgain": "重試", + "emailCaptureFormatError": "這看起來不像是電子郵件地址。請檢查後重試。", + "emailCaptureServerError": "我們這邊出了點問題。你的郵件未發送。請重試。", + "emailCaptureSentTitle": "查收你的郵件", + "emailCaptureSentBody": "我們已向 {email} 發送了一個連結。", + "emailCaptureSentActionVote": "點擊它以儲存你的投票。", + "emailCaptureSentActionForecast": "點擊它以儲存你的預測。", + "emailCaptureSentActionSubscribeAll": "點擊它以取得此問題的更新。", + "emailCaptureSentActionSignIn": "點擊它以登入並進行預測。", + "emailCaptureSentActionSubscribeOne": "點擊它,即可在{a}時收到更新。", + "emailCaptureSentActionSubscribeTwo": "點擊它,即可在{a}或{b}時收到更新。", + "emailCapturePhraseResolve": "此問題解決", + "emailCapturePhraseForecast": "預測發生變化", + "emailCapturePhraseDiscussion": "有新討論", + "emailCaptureSentNote": "該連結會為你登入,有效期為一天。你可以在任何裝置上打開它。", + "emailCaptureSentNoteRepeat": "此連結將取代你之前的連結。只有最新的連結有效。", + "emailCaptureDone": "完成", + "emailCaptureWrongAddress": "地址有誤?換一個電子郵件", + "emailCaptureUseDifferentEmail": "換一個電子郵件", + "emailCaptureRecapSentTo": "已發送至 {email}。", + "emailCaptureRecapActionVote": "點擊該郵件中的連結以儲存你的投票。", + "emailCaptureRecapActionForecast": "點擊該郵件中的連結以儲存你的預測。", + "emailCaptureRecapActionSubscribe": "點擊該郵件中的連結以開啟你的更新通知。", + "emailCaptureResend": "重新發送郵件", + "emailCaptureResendIn": "{seconds}秒後可重新發送", + "emailCaptureResendFeedback": "已發送。請查收 {email}。", + "emailCaptureGoogle": "使用 Google 登入", + "emailCapturePassword": "使用密碼登入", + "emailCaptureBack": "返回", + "emailConfirmBannerText": "我們已向 發送了一封確認郵件。", + "emailLinkRequestNew": "發送新連結", + "emailLinkNewSent": "我們已向 {email} 發送了一個新連結。", + "emailLinkNewSentWithAction": "我們已向 {email} 發送了一個新連結。它包含相同的操作。", + "notifyMeCtaLabel": "解決時通知我", + "notifyMeCtaLabelUpdates": "有更新時通知我", + "notifyMeCtaFollowing": "你正在追蹤此問題", + "emailCaptureSubscribeBodyRepeat": "我們將向 {email} 發送一個新連結。它將改為開啟這些更新。", + "shareOpenedInNewTab": "已在新分頁中打開 {target}。" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 2925586c19..0ee0eac5c6 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -2388,5 +2388,67 @@ "laborHubJobsChartA11y": "按年份的就业预测", "laborHubJobsChartBaseline": "基准", "predictorsMentionWarning": "只有策展人和管理员可以通知 @predictors。您的提及不会发送通知。", - "feedTileQuestionsRecentlyResolved": "{count, plural, one {# 个问题最近已解决} other {# 个问题最近已解决}}" + "feedTileQuestionsRecentlyResolved": "{count, plural, one {# 个问题最近已解决} other {# 个问题最近已解决}}", + "emailCaptureOptionsTitle": "获取此问题的更新。", + "emailCaptureOptionsSubtitle": "选择你想接收哪些通知。", + "emailCaptureOptionResolve": "当其解决时", + "emailCaptureOptionForecast": "预测变化", + "emailCaptureOptionDiscussion": "新讨论", + "emailCaptureOptionsContinue": "继续", + "emailCaptureOptionsCaptionNone": "至少选择一项以继续。", + "emailCaptureSubscribeTitle": "输入你的邮箱以关注此问题", + "emailCaptureSubscribeBody": "我们将向你发送一个链接以完成此操作。", + "emailCaptureVoteTitle": "保存你的投票", + "emailCaptureVoteBody": "输入你的邮箱。我们将发送一个保存你投票的链接。", + "emailCaptureVoteBodyRepeat": "我们将向 {email} 发送一个新链接。它将改为保存此投票。", + "emailCaptureVoteCaption": "该链接会为你登录并保存你的投票。有效期为一天。", + "emailCaptureForecastTitle": "保存你的预测", + "emailCaptureForecastBody": "输入你的邮箱。我们将发送一个保存你预测的链接。", + "emailCaptureForecastBodyRepeat": "我们将向 {email} 发送一个新链接。它将改为保存此预测。", + "emailCaptureForecastCaption": "该链接会为你登录并保存你的预测。有效期为一天。", + "emailCaptureForecastBodyNoDraft": "输入你的邮箱。我们将发送一个链接,让你登录以进行预测。", + "emailCaptureSignInCaption": "该链接会为你登录。有效期为一天。", + "emailCaptureBodyRepeat": "我们将向 {email} 发送一个新链接。", + "emailCaptureEmailLabel": "邮箱", + "emailCaptureSend": "发送链接", + "emailCaptureSendNew": "发送新链接", + "emailCaptureSending": "发送中...", + "emailCaptureTryAgain": "重试", + "emailCaptureFormatError": "这看起来不像是邮箱地址。请检查后重试。", + "emailCaptureServerError": "我们这边出了点问题。你的邮件未发送。请重试。", + "emailCaptureSentTitle": "查收你的邮件", + "emailCaptureSentBody": "我们已向 {email} 发送了一个链接。", + "emailCaptureSentActionVote": "点击它以保存你的投票。", + "emailCaptureSentActionForecast": "点击它以保存你的预测。", + "emailCaptureSentActionSubscribeAll": "点击它以获取此问题的更新。", + "emailCaptureSentActionSignIn": "点击它以登录并进行预测。", + "emailCaptureSentActionSubscribeOne": "点击它,即可在{a}时收到更新。", + "emailCaptureSentActionSubscribeTwo": "点击它,即可在{a}或{b}时收到更新。", + "emailCapturePhraseResolve": "此问题解决", + "emailCapturePhraseForecast": "预测发生变化", + "emailCapturePhraseDiscussion": "有新讨论", + "emailCaptureSentNote": "该链接会为你登录,有效期为一天。你可以在任何设备上打开它。", + "emailCaptureSentNoteRepeat": "此链接将替换你之前的链接。只有最新的链接有效。", + "emailCaptureDone": "完成", + "emailCaptureWrongAddress": "地址有误?换一个邮箱", + "emailCaptureUseDifferentEmail": "换一个邮箱", + "emailCaptureRecapSentTo": "已发送至 {email}。", + "emailCaptureRecapActionVote": "点击该邮件中的链接以保存你的投票。", + "emailCaptureRecapActionForecast": "点击该邮件中的链接以保存你的预测。", + "emailCaptureRecapActionSubscribe": "点击该邮件中的链接以开启你的更新通知。", + "emailCaptureResend": "重新发送邮件", + "emailCaptureResendIn": "{seconds}秒后可重新发送", + "emailCaptureResendFeedback": "已发送。请查收 {email}。", + "emailCaptureGoogle": "使用 Google 登录", + "emailCapturePassword": "使用密码登录", + "emailCaptureBack": "返回", + "emailConfirmBannerText": "我们已向 发送了一封确认邮件。", + "emailLinkRequestNew": "发送新链接", + "emailLinkNewSent": "我们已向 {email} 发送了一个新链接。", + "emailLinkNewSentWithAction": "我们已向 {email} 发送了一个新链接。它包含相同的操作。", + "notifyMeCtaLabel": "解决时通知我", + "notifyMeCtaLabelUpdates": "有更新时通知我", + "notifyMeCtaFollowing": "你正在关注此问题", + "emailCaptureSubscribeBodyRepeat": "我们将向 {email} 发送一个新链接。它将改为开启这些更新。", + "shareOpenedInNewTab": "已在新标签页中打开 {target}。" } From ee106d47e90ce9726e09ae25c079ecf2f4e1717e Mon Sep 17 00:00:00 2001 From: Atakan Seckin Date: Wed, 5 Aug 2026 13:56:07 +0200 Subject: [PATCH 11/15] Address PR review: Turnstile reuse, silent failures, CTA viewport Turnstile tokens are single-use, but neither the capture drawer nor the dead-link recovery form cleared the spent token or the validated flag after a submit. In the drawer the widget also unmounted with the input view, so Resend posted an already-consumed token and its error rendered nowhere: resend could never succeed with Turnstile enabled. The widget now stays mounted through the sent view, the token/flag reset after every attempt, resend waits for a fresh token, and the failure surfaces. Keyless dev keeps its always-validated behavior. Also: the recovery form now reports invalid-email and server errors instead of looking inert; a stored email that no longer validates reopens the editable input rather than stranding the user behind a hidden field; the notify CTA stamps viewport on its events so desktop (where it mounts but is CSS-hidden) stays enrolled yet analysable apart from mobile; the clipboard toast is localized; and execCommand's boolean result is checked so a refused copy no longer claims success. The server-side variant lookup now uses hasAuthSession() to match the middleware's access-or-refresh check. Co-Authored-By: Claude Fable 5 --- front_end/messages/cs.json | 3 +- front_end/messages/en.json | 3 +- front_end/messages/es.json | 3 +- front_end/messages/pt.json | 3 +- front_end/messages/zh-TW.json | 3 +- front_end/messages/zh.json | 3 +- .../email/components/email_link_verify.tsx | 41 ++++++++++++--- .../question_page_shell/notify_me_cta.tsx | 22 ++++++-- .../email_capture/email_capture_drawer.tsx | 52 ++++++++++++++----- front_end/src/hooks/share.ts | 15 ++++-- .../subscribe_capture_variant.server.ts | 5 +- 11 files changed, 117 insertions(+), 36 deletions(-) diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 3829a29bb5..da0276a4d3 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -2449,5 +2449,6 @@ "notifyMeCtaLabelUpdates": "Upozorňovat mě na novinky", "notifyMeCtaFollowing": "Sledujete tuto otázku", "emailCaptureSubscribeBodyRepeat": "Pošleme nový odkaz na {email}. Místo toho zapne tyto novinky.", - "shareOpenedInNewTab": "{target} otevřeno v nové kartě." + "shareOpenedInNewTab": "{target} otevřeno v nové kartě.", + "copiedUrlMessage": "URL byla zkopírována do schránky" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index bae0a665ab..3b54fa2257 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -2439,5 +2439,6 @@ "notifyMeCtaLabelUpdates": "Notify me of updates", "notifyMeCtaFollowing": "You're following this question", "emailCaptureSubscribeBodyRepeat": "We'll send a new link to {email}. It turns on these updates instead.", - "shareOpenedInNewTab": "Opened {target} in a new tab." + "shareOpenedInNewTab": "Opened {target} in a new tab.", + "copiedUrlMessage": "URL is now copied to your clipboard" } diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 9a60af949e..d331eb6cc6 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -2449,5 +2449,6 @@ "notifyMeCtaLabelUpdates": "Notifícame las novedades", "notifyMeCtaFollowing": "Estás siguiendo esta pregunta", "emailCaptureSubscribeBodyRepeat": "Enviaremos un nuevo enlace a {email}. Activará estas novedades en su lugar.", - "shareOpenedInNewTab": "Se abrió {target} en una pestaña nueva." + "shareOpenedInNewTab": "Se abrió {target} en una pestaña nueva.", + "copiedUrlMessage": "La URL se ha copiado en tu portapapeles" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index dfe129112f..dd357071ef 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -2447,5 +2447,6 @@ "notifyMeCtaLabelUpdates": "Notificar-me sobre novidades", "notifyMeCtaFollowing": "Você está seguindo esta pergunta", "emailCaptureSubscribeBodyRepeat": "Enviaremos um novo link para {email}. Ele ativará estas novidades no lugar.", - "shareOpenedInNewTab": "Abrimos {target} em uma nova aba." + "shareOpenedInNewTab": "Abrimos {target} em uma nova aba.", + "copiedUrlMessage": "A URL foi copiada para sua área de transferência" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 18e2628e0a..748c60125e 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -2446,5 +2446,6 @@ "notifyMeCtaLabelUpdates": "有更新時通知我", "notifyMeCtaFollowing": "你正在追蹤此問題", "emailCaptureSubscribeBodyRepeat": "我們將向 {email} 發送一個新連結。它將改為開啟這些更新。", - "shareOpenedInNewTab": "已在新分頁中打開 {target}。" + "shareOpenedInNewTab": "已在新分頁中打開 {target}。", + "copiedUrlMessage": "連結已複製到剪貼簿" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 0ee0eac5c6..74f9a3d7bb 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -2450,5 +2450,6 @@ "notifyMeCtaLabelUpdates": "有更新时通知我", "notifyMeCtaFollowing": "你正在关注此问题", "emailCaptureSubscribeBodyRepeat": "我们将向 {email} 发送一个新链接。它将改为开启这些更新。", - "shareOpenedInNewTab": "已在新标签页中打开 {target}。" + "shareOpenedInNewTab": "已在新标签页中打开 {target}。", + "copiedUrlMessage": "链接已复制到剪贴板" } diff --git a/front_end/src/app/(main)/auth/email/components/email_link_verify.tsx b/front_end/src/app/(main)/auth/email/components/email_link_verify.tsx index 54220fd1f9..aa9a32b6b0 100644 --- a/front_end/src/app/(main)/auth/email/components/email_link_verify.tsx +++ b/front_end/src/app/(main)/auth/email/components/email_link_verify.tsx @@ -16,6 +16,7 @@ import LoadingIndicator from "@/components/ui/loading_indicator"; import { useAuth } from "@/contexts/auth_context"; import { usePublicSettings } from "@/contexts/public_settings_context"; import { useServerAction } from "@/hooks/use_server_action"; +import cn from "@/utils/core/cn"; import { ensureRelativeRedirect } from "@/utils/navigation"; type Props = { @@ -57,6 +58,9 @@ const EmailLinkVerify: FC = ({ userId, token, redirectUrl }) => { const [draft, setDraft] = useState(""); const [requestSent, setRequestSent] = useState(false); const [sentWithAction, setSentWithAction] = useState(false); + const [requestError, setRequestError] = useState<"format" | "server" | null>( + null + ); const [isTurnstileValidated, setIsTurnstileValidated] = useState( !PUBLIC_TURNSTILE_SITE_KEY ); @@ -98,7 +102,11 @@ const EmailLinkVerify: FC = ({ userId, token, redirectUrl }) => { const requestNewLink = async () => { const email = draft.trim(); - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return; + setRequestError(null); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + setRequestError("format"); + return; + } const pending = readPending(); const response = await requestEmailLinkAction({ email, @@ -106,11 +114,17 @@ const EmailLinkVerify: FC = ({ userId, token, redirectUrl }) => { gatedAction: pending?.gatedAction ?? null, turnstileToken: turnstileTokenRef.current, }); + // Turnstile tokens are single-use: drop the spent one and wait for the + // widget to hand us a fresh one before the button re-enables turnstileRef.current?.reset(); - if (!response.errors) { - setSentWithAction(!!pending?.gatedAction); - setRequestSent(true); + turnstileTokenRef.current = undefined; + setIsTurnstileValidated(!PUBLIC_TURNSTILE_SITE_KEY); + if (response.errors) { + setRequestError("server"); + return; } + setSentWithAction(!!pending?.gatedAction); + setRequestSent(true); }; const [submitRequest, isRequesting] = useServerAction(requestNewLink); @@ -139,9 +153,24 @@ const EmailLinkVerify: FC = ({ userId, token, redirectUrl }) => { aria-label={t("emailCaptureEmailLabel")} placeholder="you@example.com" value={draft} - onChange={(e) => setDraft(e.target.value)} - className="h-12 w-full rounded border-[1.5px] border-gray-400 bg-gray-0 px-3.5 text-center text-base text-gray-900 dark:border-gray-400-dark dark:bg-gray-0-dark dark:text-gray-900-dark" + onChange={(e) => { + setDraft(e.target.value); + if (requestError === "format") setRequestError(null); + }} + className={cn( + "h-12 w-full rounded border-[1.5px] bg-gray-0 px-3.5 text-center text-base text-gray-900 dark:bg-gray-0-dark dark:text-gray-900-dark", + requestError + ? "border-salmon-500 dark:border-salmon-500-dark" + : "border-gray-400 dark:border-gray-400-dark" + )} /> + {requestError && ( + + {requestError === "format" + ? t("emailCaptureFormatError") + : t("emailCaptureServerError")} + + )} - {PUBLIC_TURNSTILE_SITE_KEY && ( - { - turnstileTokenRef.current = token; - setIsTurnstileValidated(true); - }} - onError={() => setIsTurnstileValidated(false)} - onExpire={() => setIsTurnstileValidated(false)} - /> - )} )} @@ -670,10 +672,17 @@ const EmailCaptureDrawer: FC = ({ {t("emailCaptureResendFeedback", { email: sentEmail ?? "" })}
)} + {error === "server" && ( +
+ {t("emailCaptureServerError")} +
+ )}
); diff --git a/front_end/src/hooks/share.ts b/front_end/src/hooks/share.ts index c604626a7c..e760acdfdb 100644 --- a/front_end/src/hooks/share.ts +++ b/front_end/src/hooks/share.ts @@ -1,5 +1,6 @@ "use client"; import { usePathname, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useState } from "react"; import toast from "react-hot-toast"; @@ -68,12 +69,13 @@ const useCurrentUrl = ({ includeHash = true }: CurrentUrlOptions = {}) => { export const useCopyUrl = (options: CurrentUrlOptions = {}) => { const url = useCurrentUrl(options); + const t = useTranslations(); return useCallback(() => { if (!url) return; const notify = () => - toast("URL is now copied to your clipboard", { + toast(t("copiedUrlMessage"), { className: "dark:bg-blue-700-dark dark:text-gray-0-dark", }); @@ -94,14 +96,19 @@ export const useCopyUrl = (options: CurrentUrlOptions = {}) => { document.body.appendChild(textarea); textarea.select(); try { - document.execCommand("copy"); - notify(); + // execCommand reports refusal with `false` instead of throwing, so a + // success toast would otherwise fire on a failed copy + if (document.execCommand("copy")) { + notify(); + } else { + console.error("Error copying link: execCommand returned false"); + } } catch (err) { console.error("Error copying link: ", err); } finally { textarea.remove(); } - }, [url]); + }, [url, t]); }; export const useMetaImageUrl = (tagName: string) => { diff --git a/front_end/src/services/subscribe_capture_variant.server.ts b/front_end/src/services/subscribe_capture_variant.server.ts index afef52dba0..af21d4d296 100644 --- a/front_end/src/services/subscribe_capture_variant.server.ts +++ b/front_end/src/services/subscribe_capture_variant.server.ts @@ -9,7 +9,7 @@ import { SUBSCRIBE_CAPTURE_COOKIE_NAME, SUBSCRIBE_CAPTURE_HEADER, } from "@/constants/experiments"; -import { COOKIE_NAME_REFRESH_TOKEN } from "@/services/auth_tokens"; +import { AuthCookieReader } from "@/services/auth_tokens"; // Kept separate from subscribe_capture_experiment.ts on purpose: that module // is imported by the middleware (proxy.ts), and pulling next/headers into the @@ -25,7 +25,8 @@ export async function getSubscribeCaptureVariantForRequest(): Promise Date: Fri, 7 Aug 2026 06:48:09 +0200 Subject: [PATCH 12/15] Polish capture flow: persistent action-aware toast, optimistic send, magic-link sign-in The post-sign-in toast now names what the link actually did ("you're now following this question") and stays until dismissed with its own X, so the only confirmation the deferred action was applied cannot scroll past unread. The applied action travels in the redirect URL because the confirm-email banner clears the device-local record the instant a user appears, before the destination page mounts; a link opened on another device falls back to the generic wording. Google sign-in now raises the same toast instead of completing silently. Submitting an email is optimistic: the sent state and reminder banner appear immediately and only a failure pulls the user back, with the address intact and an error toast. emailSubmitted still fires on confirmed success only, so the experiment's primary metric cannot count failures. Turnstile switches to interaction-only, so the widget is invisible unless Cloudflare demands a challenge while staying mounted to re-issue the single-use token for resend, which also removes the drawer's overflow. Plus: terms line on the email step, centered secondary links, no-scrollbar on the drawer scroller. Facebook gives up its slot in both auth modals to a magic-link button, the first route back in for accounts created by email link, which have no password; it opens the same capture sheet with sign-in copy. Co-Authored-By: Claude Fable 5 --- front_end/messages/cs.json | 11 +- front_end/messages/en.json | 11 +- front_end/messages/es.json | 11 +- front_end/messages/pt.json | 11 +- front_end/messages/zh-TW.json | 11 +- front_end/messages/zh.json | 11 +- .../accounts/social/[provider]/client.tsx | 5 +- .../email/components/email_link_verify.tsx | 22 ++-- .../src/components/auth/social_buttons.tsx | 45 ++++--- .../email_capture/email_capture_drawer.tsx | 122 +++++++++++++----- .../src/components/email_link_event_toast.tsx | 49 ++++++- front_end/src/components/ui/drawer.tsx | 2 +- front_end/src/contexts/modal_context.tsx | 4 +- front_end/src/types/gated_actions.ts | 9 +- .../src/utils/email_link_confirmation.ts | 24 ++++ 15 files changed, 269 insertions(+), 79 deletions(-) create mode 100644 front_end/src/utils/email_link_confirmation.ts diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index da0276a4d3..2dabf607ed 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -2450,5 +2450,14 @@ "notifyMeCtaFollowing": "Sledujete tuto otázku", "emailCaptureSubscribeBodyRepeat": "Pošleme nový odkaz na {email}. Místo toho zapne tyto novinky.", "shareOpenedInNewTab": "{target} otevřeno v nové kartě.", - "copiedUrlMessage": "URL byla zkopírována do schránky" + "copiedUrlMessage": "URL byla zkopírována do schránky", + "emailLinkSignedInSubscribe": "Jste přihlášeni a nyní sledujete tuto otázku.", + "emailLinkSignedInVote": "Jste přihlášeni a váš hlas je uložen.", + "emailLinkSignedInForecast": "Jste přihlášeni a vaše prognóza je uložena.", + "emailCaptureSignInTitle": "Přihlásit se pomocí kouzelného odkazu", + "emailCaptureSignInBody": "Zadejte svůj e-mail. Pošleme vám odkaz, který vás přihlásí.", + "emailCaptureSentActionSignInOnly": "Klikněte na něj a přihlaste se.", + "emailCaptureRecapActionSignIn": "Klikněte na odkaz v tomto e-mailu a přihlaste se.", + "loginMagicLink": "Přihlásit se pomocí kouzelného odkazu", + "registrationMagicLink": "Zaregistrovat se pomocí kouzelného odkazu" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 3b54fa2257..59d4e4cc83 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -2440,5 +2440,14 @@ "notifyMeCtaFollowing": "You're following this question", "emailCaptureSubscribeBodyRepeat": "We'll send a new link to {email}. It turns on these updates instead.", "shareOpenedInNewTab": "Opened {target} in a new tab.", - "copiedUrlMessage": "URL is now copied to your clipboard" + "copiedUrlMessage": "URL is now copied to your clipboard", + "emailLinkSignedInSubscribe": "You're signed in, and you're now following this question.", + "emailLinkSignedInVote": "You're signed in, and your vote is saved.", + "emailLinkSignedInForecast": "You're signed in, and your forecast is saved.", + "emailCaptureSignInTitle": "Sign in with a magic link", + "emailCaptureSignInBody": "Enter your email. We'll send a link that signs you in.", + "emailCaptureSentActionSignInOnly": "Click it to sign in.", + "emailCaptureRecapActionSignIn": "Click the link in that email to sign in.", + "loginMagicLink": "Sign in with a magic link", + "registrationMagicLink": "Sign up with a magic link" } diff --git a/front_end/messages/es.json b/front_end/messages/es.json index d331eb6cc6..776c24fcd4 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -2450,5 +2450,14 @@ "notifyMeCtaFollowing": "Estás siguiendo esta pregunta", "emailCaptureSubscribeBodyRepeat": "Enviaremos un nuevo enlace a {email}. Activará estas novedades en su lugar.", "shareOpenedInNewTab": "Se abrió {target} en una pestaña nueva.", - "copiedUrlMessage": "La URL se ha copiado en tu portapapeles" + "copiedUrlMessage": "La URL se ha copiado en tu portapapeles", + "emailLinkSignedInSubscribe": "Has iniciado sesión y ahora sigues esta pregunta.", + "emailLinkSignedInVote": "Has iniciado sesión y tu voto está guardado.", + "emailLinkSignedInForecast": "Has iniciado sesión y tu pronóstico está guardado.", + "emailCaptureSignInTitle": "Iniciar sesión con un enlace mágico", + "emailCaptureSignInBody": "Introduce tu correo. Te enviaremos un enlace para iniciar sesión.", + "emailCaptureSentActionSignInOnly": "Haz clic en él para iniciar sesión.", + "emailCaptureRecapActionSignIn": "Haz clic en el enlace de ese correo para iniciar sesión.", + "loginMagicLink": "Iniciar sesión con un enlace mágico", + "registrationMagicLink": "Registrarse con un enlace mágico" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index dd357071ef..5d2d023f95 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -2448,5 +2448,14 @@ "notifyMeCtaFollowing": "Você está seguindo esta pergunta", "emailCaptureSubscribeBodyRepeat": "Enviaremos um novo link para {email}. Ele ativará estas novidades no lugar.", "shareOpenedInNewTab": "Abrimos {target} em uma nova aba.", - "copiedUrlMessage": "A URL foi copiada para sua área de transferência" + "copiedUrlMessage": "A URL foi copiada para sua área de transferência", + "emailLinkSignedInSubscribe": "Você entrou e agora está seguindo esta pergunta.", + "emailLinkSignedInVote": "Você entrou e seu voto foi salvo.", + "emailLinkSignedInForecast": "Você entrou e sua previsão foi salva.", + "emailCaptureSignInTitle": "Entrar com um link mágico", + "emailCaptureSignInBody": "Insira seu e-mail. Enviaremos um link que faz seu login.", + "emailCaptureSentActionSignInOnly": "Clique nele para entrar.", + "emailCaptureRecapActionSignIn": "Clique no link desse e-mail para entrar.", + "loginMagicLink": "Entrar com um link mágico", + "registrationMagicLink": "Cadastrar-se com um link mágico" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 748c60125e..e3c7a58dfe 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -2447,5 +2447,14 @@ "notifyMeCtaFollowing": "你正在追蹤此問題", "emailCaptureSubscribeBodyRepeat": "我們將向 {email} 發送一個新連結。它將改為開啟這些更新。", "shareOpenedInNewTab": "已在新分頁中打開 {target}。", - "copiedUrlMessage": "連結已複製到剪貼簿" + "copiedUrlMessage": "連結已複製到剪貼簿", + "emailLinkSignedInSubscribe": "你已登入,現在正在追蹤此問題。", + "emailLinkSignedInVote": "你已登入,你的投票已儲存。", + "emailLinkSignedInForecast": "你已登入,你的預測已儲存。", + "emailCaptureSignInTitle": "使用魔法連結登入", + "emailCaptureSignInBody": "輸入你的電子郵件。我們將發送一個登入連結。", + "emailCaptureSentActionSignInOnly": "點擊它即可登入。", + "emailCaptureRecapActionSignIn": "點擊該郵件中的連結即可登入。", + "loginMagicLink": "使用魔法連結登入", + "registrationMagicLink": "使用魔法連結註冊" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 74f9a3d7bb..81c5e7a97e 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -2451,5 +2451,14 @@ "notifyMeCtaFollowing": "你正在关注此问题", "emailCaptureSubscribeBodyRepeat": "我们将向 {email} 发送一个新链接。它将改为开启这些更新。", "shareOpenedInNewTab": "已在新标签页中打开 {target}。", - "copiedUrlMessage": "链接已复制到剪贴板" + "copiedUrlMessage": "链接已复制到剪贴板", + "emailLinkSignedInSubscribe": "你已登录,现在正在关注此问题。", + "emailLinkSignedInVote": "你已登录,你的投票已保存。", + "emailLinkSignedInForecast": "你已登录,你的预测已保存。", + "emailCaptureSignInTitle": "使用魔法链接登录", + "emailCaptureSignInBody": "输入你的邮箱。我们将发送一个登录链接。", + "emailCaptureSentActionSignInOnly": "点击它即可登录。", + "emailCaptureRecapActionSignIn": "点击该邮件中的链接即可登录。", + "loginMagicLink": "使用魔法链接登录", + "registrationMagicLink": "使用魔法链接注册" } diff --git a/front_end/src/app/(main)/accounts/social/[provider]/client.tsx b/front_end/src/app/(main)/accounts/social/[provider]/client.tsx index 2f43ec5547..6d40c9077f 100644 --- a/front_end/src/app/(main)/accounts/social/[provider]/client.tsx +++ b/front_end/src/app/(main)/accounts/social/[provider]/client.tsx @@ -12,6 +12,7 @@ import { import LoadingIndicator from "@/components/ui/loading_indicator"; import { SocialProviderType } from "@/types/auth"; import { rotateCsrfToken } from "@/utils/csrf"; +import { withConfirmedEvent } from "@/utils/email_link_confirmation"; type Props = { provider: SocialProviderType; @@ -40,7 +41,9 @@ const SocialAuthClient: FC = ({ rotateCsrfToken(); // Signed in now; any pending email-confirmation reminder is obsolete clearPending(); - router.push(redirectUrl); + // Same confirmation the email-link path shows, so a carried-through + // action is acknowledged rather than applied silently + router.push(withConfirmedEvent(redirectUrl, stash?.trigger ?? null)); }) .catch(showBoundary); }, [provider, code, nonce, redirectUrl, router, showBoundary]); diff --git a/front_end/src/app/(main)/auth/email/components/email_link_verify.tsx b/front_end/src/app/(main)/auth/email/components/email_link_verify.tsx index aa9a32b6b0..b04612163a 100644 --- a/front_end/src/app/(main)/auth/email/components/email_link_verify.tsx +++ b/front_end/src/app/(main)/auth/email/components/email_link_verify.tsx @@ -17,6 +17,7 @@ import { useAuth } from "@/contexts/auth_context"; import { usePublicSettings } from "@/contexts/public_settings_context"; import { useServerAction } from "@/hooks/use_server_action"; import cn from "@/utils/core/cn"; +import { withConfirmedEvent } from "@/utils/email_link_confirmation"; import { ensureRelativeRedirect } from "@/utils/navigation"; type Props = { @@ -34,16 +35,6 @@ function safeRedirect(redirectUrl: string): string { } } -// Add the confirmation marker as a query param. The URL API keeps the query -// before any #fragment (where EmailLinkEventToast reads it), leaving the -// fragment anchor intact. `url` must be a valid relative path - safeRedirect -// guarantees that before we get here. -function withConfirmedEvent(url: string): string { - const parsed = new URL(url, window.location.origin); - parsed.searchParams.set("event", "emailLinkConfirmed"); - return `${parsed.pathname}${parsed.search}${parsed.hash}`; -} - const EmailLinkVerify: FC = ({ userId, token, redirectUrl }) => { const t = useTranslations(); const router = useRouter(); @@ -93,9 +84,16 @@ const EmailLinkVerify: FC = ({ userId, token, redirectUrl }) => { return; } + // Read before setUser: the confirm-email banner clears the pending + // record in an effect as soon as a user appears, so the destination + // page can no longer discover which action the link carried + const appliedTrigger = readPending()?.trigger ?? null; + setUser(result.user); - router.replace(withConfirmedEvent(safeRedirect(redirectUrl))); + router.replace( + withConfirmedEvent(safeRedirect(redirectUrl), appliedTrigger) + ); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -182,6 +180,8 @@ const EmailLinkVerify: FC = ({ userId, token, redirectUrl }) => { { turnstileTokenRef.current = turnstileToken; setIsTurnstileValidated(true); diff --git a/front_end/src/components/auth/social_buttons.tsx b/front_end/src/components/auth/social_buttons.tsx index 63552c94fc..b6986d297c 100644 --- a/front_end/src/components/auth/social_buttons.tsx +++ b/front_end/src/components/auth/social_buttons.tsx @@ -1,6 +1,6 @@ "use client"; -import { faFacebook } from "@fortawesome/free-brands-svg-icons"; +import { faEnvelope } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -9,6 +9,7 @@ import React, { FC } from "react"; import { Google } from "@/components/icons/google"; import Button from "@/components/ui/button"; import LoadingSpinner from "@/components/ui/loading_spiner"; +import { useModal } from "@/contexts/modal_context"; import useSocialAuth from "@/hooks/use_social_auth"; import { SocialProvider } from "@/types/auth"; @@ -20,6 +21,7 @@ const SocialButtons: FC = ({ type }) => { const t = useTranslations(); const pathname = usePathname(); const { socialProviders, getOAuthUrl } = useSocialAuth(); + const { setCurrentModal } = useModal(); const handleSocialLogin = (providerName: SocialProvider["name"]) => { const url = getOAuthUrl(providerName, pathname); @@ -51,28 +53,29 @@ const SocialButtons: FC = ({ type }) => { ); - case "facebook": - return ( - - ); + default: + return null; } })} + {/* Not a social provider, but it belongs with the alternative sign-in + methods: the only route back in for accounts created by email link, + which have no password. */} + ); }; diff --git a/front_end/src/components/email_capture/email_capture_drawer.tsx b/front_end/src/components/email_capture/email_capture_drawer.tsx index d3419697b9..92de9735d6 100644 --- a/front_end/src/components/email_capture/email_capture_drawer.tsx +++ b/front_end/src/components/email_capture/email_capture_drawer.tsx @@ -3,9 +3,11 @@ import { faArrowLeft, faCheck } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Turnstile, TurnstileInstance } from "@marsidev/react-turnstile"; +import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { FC, useEffect, useMemo, useRef, useState } from "react"; +import toast from "react-hot-toast"; import { requestEmailLinkAction } from "@/app/(main)/accounts/actions"; import BaseModal from "@/components/base_modal"; @@ -26,12 +28,13 @@ import { usePublicSettings } from "@/contexts/public_settings_context"; import { useBreakpoint } from "@/hooks/tailwind"; import { useServerAction } from "@/hooks/use_server_action"; import useSocialAuth from "@/hooks/use_social_auth"; -import { GatedActionInput, GatedActionTrigger } from "@/types/gated_actions"; +import { CaptureTrigger, GatedActionInput } from "@/types/gated_actions"; import { PostSubscription, PostSubscriptionType } from "@/types/post"; import { sendAnalyticsEvent } from "@/utils/analytics"; import cn from "@/utils/core/cn"; import { + clearPending, readPending, stashSocialGatedAction, writePending, @@ -46,7 +49,7 @@ type SubscribeOptionId = "resolve" | "forecast" | "discussion"; type Props = { isOpen: boolean; onClose: () => void; - trigger: GatedActionTrigger; + trigger: CaptureTrigger; surface?: string; gatedAction?: GatedActionInput | null; subscribePost?: { postId: number; isNotebook: boolean }; @@ -229,18 +232,10 @@ const EmailCaptureDrawer: FC = ({ }); return false; } - const sendAt = Date.now(); - writePending({ - email, - sentAt: sendAt, - trigger, - surface, - gatedAction: action, - redirectUrl, - }); - setSentEmail(email); - setLastSendAt(sendAt); sentThisSessionRef.current = true; + // Fires only on a confirmed send: this is the subscribe-capture + // experiment's primary metric, so an optimistic fire would count + // failures as conversions sendAnalyticsEvent("emailSubmitted", { trigger, surface, @@ -249,7 +244,7 @@ const EmailCaptureDrawer: FC = ({ return true; }; - const onSubmit = async () => { + const onSubmit = () => { const storedEmail = pending?.email ?? ""; // A stored address that no longer validates would strand the user: the // input and its error are both hidden while prefilled, so switch to the @@ -271,20 +266,45 @@ const EmailCaptureDrawer: FC = ({ return; } setError(null); - const wasRepeat = prefilled || !!pending; - const ok = await performSend(email, resolveGatedAction()); - if (ok) { - setWasRepeatSend(wasRepeat); - setView("sent"); - } + + // Optimistic: the sent state (and the reminder banner behind it) appear + // straight away and the request runs in the background. Only a failure + // pulls the user back, with the address still in the field. + const action = resolveGatedAction(); + const sendAt = Date.now(); + writePending({ + email, + sentAt: sendAt, + trigger, + surface, + gatedAction: action, + redirectUrl, + }); + setSentEmail(email); + setLastSendAt(sendAt); + setWasRepeatSend(prefilled || !!pending); + setView("sent"); + + void performSend(email, action).then((ok) => { + if (ok) return; + clearPending(); + setEditingEmail(true); + setDraft(email); + setView("input"); + toast.error(t("emailCaptureServerError")); + }); }; - const [submit, isPending] = useServerAction(onSubmit); const onResend = async () => { const record = readPending(); if (!record) return; const ok = await performSend(record.email, record.gatedAction); - if (ok) setResendFeedback(true); + if (!ok) return; + // Restart the cooldown from this send, keeping the record's own context + const sentAt = Date.now(); + writePending({ ...record, sentAt }); + setLastSendAt(sentAt); + setResendFeedback(true); }; const [resend, isResending] = useServerAction(onResend); @@ -332,6 +352,14 @@ const EmailCaptureDrawer: FC = ({ const inputCopy = (() => { const email = pending?.email ?? ""; switch (trigger) { + case "sign_in": + return { + title: t("emailCaptureSignInTitle"), + body: prefilled + ? t("emailCaptureBodyRepeat", { email }) + : t("emailCaptureSignInBody"), + caption: t("emailCaptureSignInCaption"), + }; case "post_vote": return { title: t("emailCaptureVoteTitle"), @@ -371,6 +399,15 @@ const EmailCaptureDrawer: FC = ({ const sentAction = (() => { if (trigger === "post_vote") return t("emailCaptureSentActionVote"); + if (trigger === "sign_in") { + // A pending action from an earlier gate still rides along, so name it + const sentType = pending?.gatedAction?.type; + if (sentType === "forecast") return t("emailCaptureSentActionForecast"); + if (sentType === "post_vote") return t("emailCaptureSentActionVote"); + if (sentType === "post_subscribe") + return t("emailCaptureSentActionSubscribeAll"); + return t("emailCaptureSentActionSignInOnly"); + } if (trigger === "forecast") { // What actually went out: the fresh draft, else the still-pending // earlier action (resolveGatedAction keeps it so we never clear it) @@ -407,6 +444,7 @@ const EmailCaptureDrawer: FC = ({ const recapAction = (() => { const recapTrigger = pending?.trigger ?? trigger; + if (recapTrigger === "sign_in") return t("emailCaptureRecapActionSignIn"); if (recapTrigger === "post_vote") return t("emailCaptureRecapActionVote"); if (recapTrigger === "forecast") return t("emailCaptureRecapActionForecast"); @@ -588,16 +626,14 @@ const EmailCaptureDrawer: FC = ({ {prefilled && ( @@ -640,6 +676,20 @@ const EmailCaptureDrawer: FC = ({ > {t("emailCapturePassword")} + + {t.rich("registrationTerms", { + terms: (chunks) => ( + + {chunks} + + ), + privacy: (chunks) => ( + + {chunks} + + ), + })} + )} @@ -695,7 +745,7 @@ const EmailCaptureDrawer: FC = ({ setDraft(""); setView("input"); }} - className={cn(secondaryLink, "self-start")} + className={cn(secondaryLink, "self-center")} > {t("emailCaptureUseDifferentEmail")} @@ -715,7 +765,7 @@ const EmailCaptureDrawer: FC = ({ setDraft(""); setView("input"); }} - className={cn(secondaryLink, "self-start")} + className={cn(secondaryLink, "self-center")} > {t("emailCaptureWrongAddress")} @@ -730,6 +780,10 @@ const EmailCaptureDrawer: FC = ({ { turnstileTokenRef.current = token; setIsTurnstileValidated(true); diff --git a/front_end/src/components/email_link_event_toast.tsx b/front_end/src/components/email_link_event_toast.tsx index 7661da2630..3b21a73ce3 100644 --- a/front_end/src/components/email_link_event_toast.tsx +++ b/front_end/src/components/email_link_event_toast.tsx @@ -1,10 +1,33 @@ "use client"; +import { faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect } from "react"; import toast from "react-hot-toast"; +import { GatedActionTrigger } from "@/types/gated_actions"; +import cn from "@/utils/core/cn"; + +const APPLIED_MESSAGE_KEYS = { + post_subscribe: "emailLinkSignedInSubscribe", + post_vote: "emailLinkSignedInVote", + forecast: "emailLinkSignedInForecast", +} as const satisfies Record; + +type SignedInMessageKey = + | (typeof APPLIED_MESSAGE_KEYS)[GatedActionTrigger] + | "emailLinkSignedIn"; + +function messageKey(applied: string | null): SignedInMessageKey { + // No `applied` param means we could not tell what the link carried (it was + // opened on another device, or no action was attached): stay generic. + return ( + APPLIED_MESSAGE_KEYS[applied as GatedActionTrigger] ?? "emailLinkSignedIn" + ); +} + export default function EmailLinkEventToast() { const t = useTranslations(); const router = useRouter(); @@ -14,10 +37,34 @@ export default function EmailLinkEventToast() { useEffect(() => { if (searchParams.get("event") !== "emailLinkConfirmed") return; - toast.success(t("emailLinkSignedIn")); + const message = t(messageKey(searchParams.get("applied"))); + + // Persists until dismissed: this is the only confirmation that the + // deferred action was applied, so it must not scroll past unread. + toast.custom( + (item) => ( +
+ {message} + +
+ ), + { duration: Infinity } + ); const params = new URLSearchParams(searchParams.toString()); params.delete("event"); + params.delete("applied"); const query = params.toString(); router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false, diff --git a/front_end/src/components/ui/drawer.tsx b/front_end/src/components/ui/drawer.tsx index 1fffccfa30..a994444b4e 100644 --- a/front_end/src/components/ui/drawer.tsx +++ b/front_end/src/components/ui/drawer.tsx @@ -63,7 +63,7 @@ const BottomDrawer: FC = ({ className )} > - + {!title && label && ( {label} )} diff --git a/front_end/src/contexts/modal_context.tsx b/front_end/src/contexts/modal_context.tsx index 1da3ceaf35..86a62a66af 100644 --- a/front_end/src/contexts/modal_context.tsx +++ b/front_end/src/contexts/modal_context.tsx @@ -10,7 +10,7 @@ import { import { QuestionLinkDirection, QuestionLinkStrength } from "@/types/coherence"; import { CommentType } from "@/types/comment"; -import { GatedActionInput, GatedActionTrigger } from "@/types/gated_actions"; +import { CaptureTrigger, GatedActionInput } from "@/types/gated_actions"; import { CurrentUser } from "@/types/users"; export type ModalType = @@ -57,7 +57,7 @@ type ModalDataByType = { onSubmitted?: () => void; }; emailCapture: { - trigger: GatedActionTrigger; + trigger: CaptureTrigger; surface?: string; gatedAction?: GatedActionInput | null; subscribePost?: { postId: number; isNotebook: boolean }; diff --git a/front_end/src/types/gated_actions.ts b/front_end/src/types/gated_actions.ts index 39e77c90db..029b3f625d 100644 --- a/front_end/src/types/gated_actions.ts +++ b/front_end/src/types/gated_actions.ts @@ -3,6 +3,11 @@ import { PostSubscription } from "@/types/post"; export type GatedActionTrigger = "post_vote" | "post_subscribe" | "forecast"; +// What opened the capture flow. "sign_in" is the login/signup modal entry +// point: it carries no gated action of its own, so it is not a +// GatedActionTrigger, but it drives the same drawer. +export type CaptureTrigger = GatedActionTrigger | "sign_in"; + export type GatedActionInput = | { type: "post_vote"; payload: { post: number; direction: 1 | -1 } } | { @@ -14,7 +19,7 @@ export type GatedActionInput = export type EmailCapturePendingRecord = { email: string; sentAt: number; - trigger: GatedActionTrigger; + trigger: CaptureTrigger; surface?: string; gatedAction: GatedActionInput | null; redirectUrl: string; @@ -22,6 +27,6 @@ export type EmailCapturePendingRecord = { export type SocialGatedActionStash = { gatedAction: GatedActionInput; - trigger: GatedActionTrigger; + trigger: CaptureTrigger; stashedAt: number; }; diff --git a/front_end/src/utils/email_link_confirmation.ts b/front_end/src/utils/email_link_confirmation.ts new file mode 100644 index 0000000000..530d144ce5 --- /dev/null +++ b/front_end/src/utils/email_link_confirmation.ts @@ -0,0 +1,24 @@ +import type { CaptureTrigger } from "@/types/gated_actions"; + +export const EMAIL_LINK_CONFIRMED_EVENT = "emailLinkConfirmed"; + +/** + * Tags a redirect URL so EmailLinkEventToast greets the user on arrival, and + * carries which deferred action was applied. The action has to travel in the + * URL because the device-local pending record is cleared the moment the user + * is signed in, before the destination page mounts. + * + * `url` must be a relative path (callers sanitize before calling). The URL API + * keeps the query ahead of any #fragment, so anchors survive. + */ +export function withConfirmedEvent( + url: string, + appliedTrigger?: CaptureTrigger | null +): string { + const parsed = new URL(url || "/", window.location.origin); + parsed.searchParams.set("event", EMAIL_LINK_CONFIRMED_EVENT); + if (appliedTrigger) { + parsed.searchParams.set("applied", appliedTrigger); + } + return `${parsed.pathname}${parsed.search}${parsed.hash}`; +} From f0616661e710e493aacb10498fbf7ba277e5651b Mon Sep 17 00:00:00 2001 From: Atakan Seckin Date: Fri, 7 Aug 2026 07:58:42 +0200 Subject: [PATCH 13/15] Keep the tutorial away from lightweight accounts, fix Enter and terms wrapping Logging out is a client-side navigation, so the root layout and its modal state survive it: an open tutorial stayed on screen for a signed-out visitor on the storefront. GlobalModals now refuses to render it without a user, and AuthProvider syncs the server-provided user during render instead of in an effect, since child effects run before parent ones and a page mounting right after logout would otherwise read the signed-out user as still signed in. Arriving by magic link also marks onboarding complete, so a visitor who came to follow a question is not met with a forecaster tutorial. The update is awaited and revalidating: fired-and-forgotten, staleTimes.dynamic serves the destination page a cached payload carrying the old flag and the tutorial opens anyway. Also: Enter submits the capture and recovery email fields, which sit outside a form and did nothing before; and the terms line wraps with text-balance. Co-Authored-By: Claude Fable 5 --- .../email/components/email_link_verify.tsx | 34 ++++++++++++++++++- .../email_capture/email_capture_drawer.tsx | 9 ++++- .../src/components/email_link_event_toast.tsx | 9 +++-- front_end/src/components/global_modals.tsx | 13 +++++-- front_end/src/contexts/auth_context.tsx | 12 +++++-- 5 files changed, 68 insertions(+), 9 deletions(-) diff --git a/front_end/src/app/(main)/auth/email/components/email_link_verify.tsx b/front_end/src/app/(main)/auth/email/components/email_link_verify.tsx index b04612163a..6923a6ac96 100644 --- a/front_end/src/app/(main)/auth/email/components/email_link_verify.tsx +++ b/front_end/src/app/(main)/auth/email/components/email_link_verify.tsx @@ -9,6 +9,7 @@ import { requestEmailLinkAction, verifyEmailLinkAction, } from "@/app/(main)/accounts/actions"; +import { updateProfileAction } from "@/app/(main)/accounts/profile/actions"; import { readPending } from "@/components/email_capture/pending_store"; import Button from "@/components/ui/button"; import { Input } from "@/components/ui/form_field"; @@ -89,7 +90,27 @@ const EmailLinkVerify: FC = ({ userId, token, redirectUrl }) => { // page can no longer discover which action the link carried const appliedTrigger = readPending()?.trigger ?? null; - setUser(result.user); + // Arriving by magic link means exploring, not enrolling: skip the + // forecaster tutorial for good rather than interrupting the action the + // user actually came to complete. Persisted so it holds on every device. + // Awaited and revalidating, not fire-and-forget: the destination page is + // server-rendered during the redirect below, and staleTimes.dynamic + // would otherwise serve a cached payload carrying the old flag, opening + // the tutorial anyway. Never let this block signing in. + const skipsOnboarding = !result.user.is_onboarding_complete; + if (skipsOnboarding) { + try { + await updateProfileAction({ is_onboarding_complete: true }); + } catch { + // Non-fatal: worst case the tutorial appears once + } + } + + setUser( + skipsOnboarding + ? { ...result.user, is_onboarding_complete: true } + : result.user + ); router.replace( withConfirmedEvent(safeRedirect(redirectUrl), appliedTrigger) @@ -155,6 +176,17 @@ const EmailLinkVerify: FC = ({ userId, token, redirectUrl }) => { setDraft(e.target.value); if (requestError === "format") setRequestError(null); }} + // Not inside a
, so Enter needs wiring + onKeyDown={(e) => { + if ( + e.key === "Enter" && + !isRequesting && + isTurnstileValidated + ) { + e.preventDefault(); + void submitRequest(); + } + }} className={cn( "h-12 w-full rounded border-[1.5px] bg-gray-0 px-3.5 text-center text-base text-gray-900 dark:bg-gray-0-dark dark:text-gray-900-dark", requestError diff --git a/front_end/src/components/email_capture/email_capture_drawer.tsx b/front_end/src/components/email_capture/email_capture_drawer.tsx index 92de9735d6..3b14143a43 100644 --- a/front_end/src/components/email_capture/email_capture_drawer.tsx +++ b/front_end/src/components/email_capture/email_capture_drawer.tsx @@ -609,6 +609,13 @@ const EmailCaptureDrawer: FC = ({ setDraft(e.target.value); if (error === "format") setError(null); }} + // The field is not inside a , so Enter needs wiring + onKeyDown={(e) => { + if (e.key === "Enter" && isTurnstileValidated) { + e.preventDefault(); + onSubmit(); + } + }} className={cn( "h-12 w-full rounded border-[1.5px] bg-gray-0 px-3.5 text-base text-gray-900 dark:bg-gray-0-dark dark:text-gray-900-dark", error === "format" @@ -676,7 +683,7 @@ const EmailCaptureDrawer: FC = ({ > {t("emailCapturePassword")} - + {t.rich("registrationTerms", { terms: (chunks) => ( diff --git a/front_end/src/components/email_link_event_toast.tsx b/front_end/src/components/email_link_event_toast.tsx index 3b21a73ce3..18c37f067b 100644 --- a/front_end/src/components/email_link_event_toast.tsx +++ b/front_end/src/components/email_link_event_toast.tsx @@ -37,7 +37,12 @@ export default function EmailLinkEventToast() { useEffect(() => { if (searchParams.get("event") !== "emailLinkConfirmed") return; - const message = t(messageKey(searchParams.get("applied"))); + const applied = searchParams.get("applied"); + const message = t(messageKey(applied)); + // Only the action-specific wording has to be read to be understood, so + // that sticks until dismissed. A bare "you're signed in" is self-evident + // from the page around it and can see itself out. + const isGenericConfirmation = messageKey(applied) === "emailLinkSignedIn"; // Persists until dismissed: this is the only confirmation that the // deferred action was applied, so it must not scroll past unread. @@ -59,7 +64,7 @@ export default function EmailLinkEventToast() {
), - { duration: Infinity } + { duration: isGenericConfirmation ? 3500 : Infinity } ); const params = new URLSearchParams(searchParams.toString()); diff --git a/front_end/src/components/global_modals.tsx b/front_end/src/components/global_modals.tsx index 36f21d046d..c241a72cff 100644 --- a/front_end/src/components/global_modals.tsx +++ b/front_end/src/components/global_modals.tsx @@ -2,6 +2,7 @@ import dynamic from "next/dynamic"; import React, { FC } from "react"; +import { useAuth } from "@/contexts/auth_context"; import { useModal } from "@/contexts/modal_context"; import type { CurrentModal, ModalType } from "@/contexts/modal_context"; import { usePublicSettings } from "@/contexts/public_settings_context"; @@ -91,6 +92,10 @@ const GlobalModals: FC = () => { const onClose = () => setCurrentModal(null); const { PUBLIC_ALLOW_TUTORIAL } = usePublicSettings(); + // Logging out is a client-side navigation, so the root layout (and this + // modal state) survives it. The tutorial is for signed-in forecasters + // only, so never show it to a signed-out visitor whatever opened it. + const { user } = useAuth(); return ( <> @@ -132,9 +137,11 @@ const GlobalModals: FC = () => { {isModal(currentModal, "contactUs") && ( )} - {PUBLIC_ALLOW_TUTORIAL && isModal(currentModal, "onboarding") && ( - - )} + {PUBLIC_ALLOW_TUTORIAL && + !!user && + isModal(currentModal, "onboarding") && ( + + )} {isModal(currentModal, "confirm") && ( > = ({ user: initialUser, children, locale }) => { const [user, setUser] = useState(initialUser); + const [syncedUser, setSyncedUser] = useState(initialUser); const posthog = usePostHog(); + // Adjust during render rather than in an effect: child effects run before + // parent effects, so a page mounting right after logout would otherwise + // read the signed-out user as still signed in for one commit — which is how + // the tutorial popped up on the storefront after logging out. + if (initialUser !== syncedUser) { + setSyncedUser(initialUser); + setUser(initialUser); + } + useEffect(() => { if (initialUser) { const { id, username, is_superuser, is_staff, language } = initialUser; @@ -42,8 +52,6 @@ const AuthProvider: FC< posthog.reset(); } } - - setUser(initialUser); // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialUser, posthog]); From ca304190c63b142eac9a37f599b3f46a86bdf4a2 Mon Sep 17 00:00:00 2001 From: Atakan Seckin Date: Fri, 7 Aug 2026 09:23:18 +0200 Subject: [PATCH 14/15] Skip the tutorial for Google sign-ins from the capture drawer too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tutorial fires for any signed-in user whose onboarding flag is unset, and only the magic-link path cleared it: arriving through Google still produced a brand-new un-onboarded account, so the tutorial ambushed the user on the next page that mounted OnboardingCheck. The social callback now marks onboarding complete the same way, awaited and revalidating so the destination page is not server-rendered from a cached payload carrying the old flag. Telling a lightweight sign-in from an ordinary one needs a signal, and the sessionStorage stash was nearly it — except it was only written when a gated action existed, so Google from the sign-in drawer stashed nothing and would have kept the tutorial. The drawer now stashes unconditionally, with a null action when there is none, and the stash's presence is the discriminator. Co-Authored-By: Claude Opus 5 --- .../accounts/social/[provider]/client.tsx | 38 ++++++++++++++++--- .../email_capture/email_capture_drawer.tsx | 7 ++-- .../components/email_capture/pending_store.ts | 2 +- front_end/src/types/gated_actions.ts | 5 ++- 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/front_end/src/app/(main)/accounts/social/[provider]/client.tsx b/front_end/src/app/(main)/accounts/social/[provider]/client.tsx index 6d40c9077f..2c06eab30e 100644 --- a/front_end/src/app/(main)/accounts/social/[provider]/client.tsx +++ b/front_end/src/app/(main)/accounts/social/[provider]/client.tsx @@ -4,6 +4,7 @@ import { useRouter } from "next/navigation"; import { FC, useEffect } from "react"; import { useErrorBoundary } from "react-error-boundary"; +import { updateProfileAction } from "@/app/(main)/accounts/profile/actions"; import { exchangeSocialOauthCode } from "@/app/(main)/accounts/social/[provider]/actions"; import { clearPending, @@ -34,18 +35,45 @@ const SocialAuthClient: FC = ({ // A gated action stashed before the OAuth redirect rides along with the // code exchange; the backend applies it best-effort after sign-in. const stash = takeSocialGatedAction(); - exchangeSocialOauthCode(provider, code, nonce, stash?.gatedAction ?? null) - .then(() => { + void (async () => { + try { + await exchangeSocialOauthCode( + provider, + code, + nonce, + stash?.gatedAction ?? null + ); // Invalidate the nonce now that it has served its purpose (and been // logged as a `state` param) — bounds any replay to the flow duration. rotateCsrfToken(); // Signed in now; any pending email-confirmation reminder is obsolete clearPending(); + + // Coming through the capture drawer means exploring, not enrolling, so + // skip the forecaster tutorial the same way the email-link path does. + // Awaited and revalidating, not fire-and-forget: the destination page is + // server-rendered during the push below, and staleTimes.dynamic would + // otherwise hand it a cached payload carrying the old flag. + if (stash) { + try { + await updateProfileAction({ is_onboarding_complete: true }); + } catch { + // Non-fatal: worst case the tutorial appears once + } + } + // Same confirmation the email-link path shows, so a carried-through // action is acknowledged rather than applied silently - router.push(withConfirmedEvent(redirectUrl, stash?.trigger ?? null)); - }) - .catch(showBoundary); + router.push( + withConfirmedEvent( + redirectUrl, + stash?.gatedAction ? stash.trigger : null + ) + ); + } catch (error) { + showBoundary(error); + } + })(); }, [provider, code, nonce, redirectUrl, router, showBoundary]); return ( diff --git a/front_end/src/components/email_capture/email_capture_drawer.tsx b/front_end/src/components/email_capture/email_capture_drawer.tsx index 3b14143a43..077357d15f 100644 --- a/front_end/src/components/email_capture/email_capture_drawer.tsx +++ b/front_end/src/components/email_capture/email_capture_drawer.tsx @@ -342,10 +342,9 @@ const EmailCaptureDrawer: FC = ({ const handleGoogle = () => { if (!googleUrl) return; - const action = resolveGatedAction(); - if (action) { - stashSocialGatedAction({ gatedAction: action, trigger }); - } + // Stashed even without an action: its presence is how the callback knows + // the user arrived through the capture drawer rather than the full signup. + stashSocialGatedAction({ gatedAction: resolveGatedAction(), trigger }); window.location.href = googleUrl; }; diff --git a/front_end/src/components/email_capture/pending_store.ts b/front_end/src/components/email_capture/pending_store.ts index 70b8bab234..2312c192cf 100644 --- a/front_end/src/components/email_capture/pending_store.ts +++ b/front_end/src/components/email_capture/pending_store.ts @@ -112,7 +112,7 @@ export const takeSocialGatedAction = (): SocialGatedActionStash | null => { try { const stash = JSON.parse(raw) as SocialGatedActionStash; if ( - !stash?.gatedAction || + !stash?.trigger || typeof stash.stashedAt !== "number" || Date.now() - stash.stashedAt > SOCIAL_STASH_TTL_MS ) { diff --git a/front_end/src/types/gated_actions.ts b/front_end/src/types/gated_actions.ts index 029b3f625d..77bb64e745 100644 --- a/front_end/src/types/gated_actions.ts +++ b/front_end/src/types/gated_actions.ts @@ -25,8 +25,11 @@ export type EmailCapturePendingRecord = { redirectUrl: string; }; +// Written whenever the capture drawer hands off to Google, so the callback can +// tell a lightweight sign-in from an ordinary one. The action is null when the +// drawer was opened without one (the "sign_in" entry point). export type SocialGatedActionStash = { - gatedAction: GatedActionInput; + gatedAction: GatedActionInput | null; trigger: CaptureTrigger; stashedAt: number; }; From fba01529c3a80b69b464b9ea4795a099671ea360 Mon Sep 17 00:00:00 2001 From: Atakan Seckin Date: Fri, 7 Aug 2026 16:08:25 +0200 Subject: [PATCH 15/15] Drop the onboarding request on logout instead of only hiding it Refusing to render the tutorial without a user leaves the request itself sitting in modal state, and SimplifiedSignupModal signs a visitor in with a bare setUser while owning its own open state: a stale tutorial would surface the moment a user reappeared, with nothing having asked for it. Clear it when the user goes away. The render guard stays, since the effect runs after paint and would otherwise let a frame of tutorial through. Co-Authored-By: Claude Opus 5 --- front_end/src/components/global_modals.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/front_end/src/components/global_modals.tsx b/front_end/src/components/global_modals.tsx index c241a72cff..edb8544233 100644 --- a/front_end/src/components/global_modals.tsx +++ b/front_end/src/components/global_modals.tsx @@ -1,6 +1,6 @@ "use client"; import dynamic from "next/dynamic"; -import React, { FC } from "react"; +import React, { FC, useEffect } from "react"; import { useAuth } from "@/contexts/auth_context"; import { useModal } from "@/contexts/modal_context"; @@ -97,6 +97,16 @@ const GlobalModals: FC = () => { // only, so never show it to a signed-out visitor whatever opened it. const { user } = useAuth(); + // Hiding it is not enough: the request has to be dropped too. A sign-in that + // does not itself open a modal (SimplifiedSignupModal owns its own state and + // only calls setUser) would otherwise reveal the stale tutorial the moment a + // user reappears, with nothing having asked for it. + useEffect(() => { + if (!user && isModal(currentModal, "onboarding")) { + setCurrentModal(null); + } + }, [user, currentModal, setCurrentModal]); + return ( <> {isModal(currentModal, "signin") && (