diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json
index b5bd213d8b..f06c44ef92 100644
--- a/front_end/messages/cs.json
+++ b/front_end/messages/cs.json
@@ -2401,5 +2401,77 @@
"newsHotnessColCluster": "Klastr",
"newsHotnessColWeight": "Váha",
"newsHotnessColContribution": "Příspěvek",
- "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ě.",
+ "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 3b4b48b1fd..2fc9cdb1e3 100644
--- a/front_end/messages/en.json
+++ b/front_end/messages/en.json
@@ -2395,5 +2395,77 @@
"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",
+ "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.",
+ "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 a3d1f7276f..1a004ad151 100644
--- a/front_end/messages/es.json
+++ b/front_end/messages/es.json
@@ -2401,5 +2401,77 @@
"newsHotnessColCluster": "Clúster",
"newsHotnessColWeight": "Peso",
"newsHotnessColContribution": "Contribución",
- "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.",
+ "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 da0b452e04..00337eb41c 100644
--- a/front_end/messages/pt.json
+++ b/front_end/messages/pt.json
@@ -2399,5 +2399,77 @@
"newsHotnessColCluster": "Cluster",
"newsHotnessColWeight": "Peso",
"newsHotnessColContribution": "Contribuição",
- "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.",
+ "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 11f18ee7bd..bc9d8eace2 100644
--- a/front_end/messages/zh-TW.json
+++ b/front_end/messages/zh-TW.json
@@ -2398,5 +2398,77 @@
"newsHotnessColCluster": "叢集",
"newsHotnessColWeight": "權重",
"newsHotnessColContribution": "貢獻值",
- "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}。",
+ "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 d02e635d54..6fd4d906ca 100644
--- a/front_end/messages/zh.json
+++ b/front_end/messages/zh.json
@@ -2403,5 +2403,77 @@
"newsHotnessColWeight": "权重",
"newsHotnessColContribution": "贡献值",
"viewComment": "查看评论",
- "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}。",
+ "copiedUrlMessage": "链接已复制到剪贴板",
+ "emailLinkSignedInSubscribe": "你已登录,现在正在关注此问题。",
+ "emailLinkSignedInVote": "你已登录,你的投票已保存。",
+ "emailLinkSignedInForecast": "你已登录,你的预测已保存。",
+ "emailCaptureSignInTitle": "使用魔法链接登录",
+ "emailCaptureSignInBody": "输入你的邮箱。我们将发送一个登录链接。",
+ "emailCaptureSentActionSignInOnly": "点击它即可登录。",
+ "emailCaptureRecapActionSignIn": "点击该邮件中的链接即可登录。",
+ "loginMagicLink": "使用魔法链接登录",
+ "registrationMagicLink": "使用魔法链接注册"
}
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/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..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,10 +4,16 @@ 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,
+ takeSocialGatedAction,
+} from "@/components/email_capture/pending_store";
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;
@@ -26,14 +32,48 @@ const SocialAuthClient: FC = ({
const { showBoundary } = useErrorBoundary();
useEffect(() => {
- exchangeSocialOauthCode(provider, code, nonce)
- .then(() => {
+ // 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();
+ 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();
- router.push(redirectUrl);
- })
- .catch(showBoundary);
+ // 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?.gatedAction ? stash.trigger : null
+ )
+ );
+ } catch (error) {
+ showBoundary(error);
+ }
+ })();
}, [provider, code, nonce, redirectUrl, router, showBoundary]);
return (
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..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
@@ -1,13 +1,24 @@
"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 { 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";
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 { withConfirmedEvent } from "@/utils/email_link_confirmation";
import { ensureRelativeRedirect } from "@/utils/navigation";
type Props = {
@@ -25,23 +36,29 @@ 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();
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 [requestError, setRequestError] = useState<"format" | "server" | null>(
+ null
+ );
+ 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 +72,7 @@ const EmailLinkVerify: FC = ({ userId, token, redirectUrl }) => {
if (!userId || !token) {
setFailed(true);
+ setDraft(readPending()?.email ?? "");
return;
}
@@ -63,16 +81,72 @@ const EmailLinkVerify: FC = ({ userId, token, redirectUrl }) => {
if ("errors" in result) {
setFailed(true);
+ setDraft(readPending()?.email ?? "");
return;
}
- setUser(result.user);
+ // 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;
+
+ // 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)));
+ router.replace(
+ withConfirmedEvent(safeRedirect(redirectUrl), appliedTrigger)
+ );
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+ const requestNewLink = async () => {
+ const email = draft.trim();
+ setRequestError(null);
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ setRequestError("format");
+ return;
+ }
+ const pending = readPending();
+ const response = await requestEmailLinkAction({
+ email,
+ redirectUrl: pending?.redirectUrl ?? null,
+ 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();
+ turnstileTokenRef.current = undefined;
+ setIsTurnstileValidated(!PUBLIC_TURNSTILE_SITE_KEY);
+ if (response.errors) {
+ setRequestError("server");
+ return;
+ }
+ setSentWithAction(!!pending?.gatedAction);
+ setRequestSent(true);
+ };
+ const [submitRequest, isRequesting] = useServerAction(requestNewLink);
+
if (failed) {
// redirect_url is deliberately NOT honored on failure.
return (
@@ -80,10 +154,79 @@ const EmailLinkVerify: FC = ({ userId, token, redirectUrl }) => {
{!hideCP && }
{!hideCP && (
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-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
+ const [animatedOpen, setAnimatedOpen] = useState(false);
+ useEffect(() => {
+ setAnimatedOpen(open);
+ }, [open]);
+
+ return (
+
+
+
+
+
+
+ {!title && label && (
+ {label}
+ )}
+
+
+
+ {title && (
+
+
+ {title}
+
+
+
+ )}
+ {children}
+
+
+
+
+
+ );
+};
+
+export default BottomDrawer;
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/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/auth_context.tsx b/front_end/src/contexts/auth_context.tsx
index e64a250d0f..d55eefce49 100644
--- a/front_end/src/contexts/auth_context.tsx
+++ b/front_end/src/contexts/auth_context.tsx
@@ -25,8 +25,18 @@ const AuthProvider: FC<
}>
> = ({ 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]);
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/modal_context.tsx b/front_end/src/contexts/modal_context.tsx
index 64d13dcb4d..86a62a66af 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 { CaptureTrigger, GatedActionInput } 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: CaptureTrigger;
+ surface?: string;
+ gatedAction?: GatedActionInput | null;
+ subscribePost?: { postId: number; isNotebook: boolean };
+ initialView?: "options" | "input" | "sent";
+ };
copyQuestionLink: {
fromQuestionTitle: string;
toQuestionTitle: string;
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;
}
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/hooks/share.ts b/front_end/src/hooks/share.ts
index 5a836ac208..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,20 +69,46 @@ const useCurrentUrl = ({ includeHash = true }: CurrentUrlOptions = {}) => {
export const useCopyUrl = (options: CurrentUrlOptions = {}) => {
const url = useCurrentUrl(options);
+ const t = useTranslations();
return useCallback(() => {
- if (url) {
+ if (!url) return;
+
+ const notify = () =>
+ toast(t("copiedUrlMessage"), {
+ 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;
}
- }, [url]);
+
+ // 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 {
+ // 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, t]);
};
export const useMetaImageUrl = (tagName: string) => {
diff --git a/front_end/src/proxy.ts b/front_end/src/proxy.ts
index 8db984fc30..e7f131a1e2 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/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/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..af21d4d296
--- /dev/null
+++ b/front_end/src/services/subscribe_capture_variant.server.ts
@@ -0,0 +1,42 @@
+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 { 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
+// 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();
+
+ // Access OR refresh token, matching the middleware's enrollment check
+ if (new AuthCookieReader(cookieStore).hasAuthSession()) {
+ 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
+ );
+}
diff --git a/front_end/src/types/gated_actions.ts b/front_end/src/types/gated_actions.ts
new file mode 100644
index 0000000000..77bb64e745
--- /dev/null
+++ b/front_end/src/types/gated_actions.ts
@@ -0,0 +1,35 @@
+import type { ForecastPayload } from "@/services/api/questions/questions.server";
+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 } }
+ | {
+ type: "post_subscribe";
+ payload: { post: number; subscriptions: PostSubscription[] };
+ }
+ | { type: "forecast"; payload: ForecastPayload[] };
+
+export type EmailCapturePendingRecord = {
+ email: string;
+ sentAt: number;
+ trigger: CaptureTrigger;
+ surface?: string;
+ gatedAction: GatedActionInput | null;
+ 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 | null;
+ trigger: CaptureTrigger;
+ stashedAt: number;
+};
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(
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}`;
+}
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 };
+};