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 }) => {

{t("emailLinkInvalidTitle")}

-

+

{t("emailLinkInvalidDescription")}

- + {requestSent ? ( +
+ {sentWithAction + ? t("emailLinkNewSentWithAction", { email: draft.trim() }) + : t("emailLinkNewSent", { email: draft.trim() })} +
+ ) : ( +
+ { + 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 + ? "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 = 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 && ( 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 c8a8771e76..2f84a927f7 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"; @@ -71,6 +73,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/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx index 2a0627b4a9..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 @@ -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"; @@ -300,7 +301,7 @@ export const ConsumerShell: FC<{
-
+
{showClosedMessageMultipleChoice && (

{t("predictionClosedMessage")} @@ -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..949c885323 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/notify_me_cta.tsx @@ -0,0 +1,107 @@ +"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 { + registerSubscribeCaptureExposure, + useSubscribeCaptureVariant, +} from "@/contexts/experiments_context"; +import { usePostSubscriptionContext } from "@/contexts/post_subscription_context"; +import { useBreakpoint } from "@/hooks/tailwind"; +import useMounted from "@/hooks/use_mounted"; +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 captureVariant = useSubscribeCaptureVariant(); + const isMounted = useMounted(); + // The CTA is md:hidden, so desktop mounts it without ever showing it. Those + // visitors stay in the experiment (they reach it via the Follow pill), but + // the events carry the viewport so the two can be analysed separately. + const isDesktopViewport = useBreakpoint("md"); + const shownTrackedRef = useRef(false); + const exposureTrackedRef = useRef(false); + + useEffect(() => { + // useBreakpoint is false until after mount, so wait for a true reading + if (!isMounted) return; + + if (!shownTrackedRef.current) { + shownTrackedRef.current = true; + sendAnalyticsEvent("notifyCtaShown", { + surface: "questionPageCta", + captureVariant: captureVariant ?? "none", + viewport: isDesktopViewport ? "desktop" : "mobile", + }); + } + if (captureVariant && !exposureTrackedRef.current) { + exposureTrackedRef.current = true; + registerSubscribeCaptureExposure(); + } + }, [captureVariant, isMounted, isDesktopViewport]); + + if (isSubscribed) { + return ( + + ); + } + + return ( + + ); +}; + +export default NotifyMeCta; 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..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) && @@ -71,7 +75,7 @@ const ActionRow: FC = ({ post, variant }) => { return (
= ({ 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 }) => { 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,21 @@ const QuestionPredictButton: React.FC = ({ post, className }) => { {!isDesktop ? ( - setIsOpen(false)} + { + if (!open) setIsOpen(false); + }} title={post.question?.title ?? ""} + titleClassName="text-lg font-semibold leading-6" > - setIsOpen(false)} - /> - +
+ setIsOpen(false)} + /> +
+ ) : ( = ({ } 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/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/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 new file mode 100644 index 0000000000..077357d15f --- /dev/null +++ b/front_end/src/components/email_capture/email_capture_drawer.tsx @@ -0,0 +1,854 @@ +"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 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"; +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 { + registerSubscribeCaptureExposure, + useSubscribeCaptureVariant, +} from "@/contexts/experiments_context"; +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 { 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, +} 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: CaptureTrigger; + 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 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 && + captureVariant !== "test"; + + 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, + captureVariant: captureVariant ?? "none", + }); + if (trigger === "post_subscribe" && captureVariant) { + registerSubscribeCaptureExposure(); + } + 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, + }); + // Turnstile tokens are single-use: drop the spent one so a retry or resend + // waits for the widget to issue a fresh one + turnstileRef.current?.reset(); + turnstileTokenRef.current = undefined; + setIsTurnstileValidated(!PUBLIC_TURNSTILE_SITE_KEY); + 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; + } + 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, + captureVariant: captureVariant ?? "none", + }); + return true; + }; + + 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 + // editable field before surfacing the error + if (prefilled && !validEmail(storedEmail)) { + setEditingEmail(true); + setDraft(storedEmail); + setError("format"); + return; + } + const email = prefilled ? storedEmail : draft.trim(); + if (!validEmail(email)) { + setError("format"); + sendAnalyticsEvent("emailSubmitFailed", { + trigger, + surface, + reason: "invalid_email", + }); + return; + } + setError(null); + + // 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 onResend = async () => { + const record = readPending(); + if (!record) return; + const ok = await performSend(record.email, record.gatedAction); + 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); + + 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; + // 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; + }; + + 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"), + 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 === "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) + 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 === "sign_in") return t("emailCaptureRecapActionSignIn"); + 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 = ( +
+ + + + +
+ ); + + // 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 = ( +
+ {isDesktop && backInHeader && ( + + )} + + {view === "options" && ( + <> +
+ {isDesktop && ( +

+ {t("emailCaptureOptionsTitle")} +

+ )} +

+ {t("emailCaptureOptionsSubtitle")} +

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

+ {inputCopy.title} +

+ )} +

+ {inputCopy.body} +

+
+ {error === "server" && ( +
+ {t("emailCaptureServerError")} +
+ )} + {!prefilled && ( +
+ + { + 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" + ? "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")} + +
+
+ + + )} + + + {t.rich("registrationTerms", { + terms: (chunks) => ( + + {chunks} + + ), + privacy: (chunks) => ( + + {chunks} + + ), + })} + + + )} + + {view === "sent" && ( + <> + {envelopeBadge} +
+ {isDesktop && ( +

+ {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 ?? "" })} +
+ )} + {error === "server" && ( +
+ {t("emailCaptureServerError")} +
+ )} + + + + ) : ( + <> + + + + )} + + )} + + {/* Mounted for both the send and resend paths: tokens are single-use, so + the widget has to stay alive past the input view to re-issue one */} + {PUBLIC_TURNSTILE_SITE_KEY && view !== "options" && ( + { + turnstileTokenRef.current = token; + setIsTurnstileValidated(true); + }} + onError={() => setIsTurnstileValidated(false)} + onExpire={() => setIsTurnstileValidated(false)} + /> + )} +
+ ); + + if (isDesktop) { + return ( + + {content} + + ); + } + + return ( + { + if (!open) setSheetOpen(false); + }} + onOpenChangeComplete={(open) => { + if (!open && !sheetOpen) handleClose(); + }} + label={headerTitle} + > +
+ {backInHeader ? ( + + ) : ( +

+ {headerTitle} +

+ )} + +
+ {content} +
+ ); +}; + +export default EmailCaptureDrawer; 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..2312c192cf --- /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?.trigger || + 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/components/email_link_event_toast.tsx b/front_end/src/components/email_link_event_toast.tsx index 7661da2630..18c37f067b 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,39 @@ export default function EmailLinkEventToast() { useEffect(() => { if (searchParams.get("event") !== "emailLinkConfirmed") return; - toast.success(t("emailLinkSignedIn")); + 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. + toast.custom( + (item) => ( +
+ {message} + +
+ ), + { duration: isGenericConfirmation ? 3500 : 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/forecast_maker/forecast_maker_question/forecast_maker_binary.tsx b/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_binary.tsx index bf9e2f921c..d88b9f413a 100644 --- a/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_binary.tsx +++ b/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_binary.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 { ErrorResponse } from "@/types/fetch"; import { PostWithForecasts } from "@/types/post"; import { QuestionWithNumericForecasts } from "@/types/question"; @@ -107,22 +108,17 @@ const ForecastMakerBinary: FC = ({ 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/global_modals.tsx b/front_end/src/components/global_modals.tsx index 6a7e38ca0c..edb8544233 100644 --- a/front_end/src/components/global_modals.tsx +++ b/front_end/src/components/global_modals.tsx @@ -1,7 +1,8 @@ "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"; import type { CurrentModal, ModalType } from "@/contexts/modal_context"; import { usePublicSettings } from "@/contexts/public_settings_context"; @@ -74,6 +75,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 @@ -86,6 +92,20 @@ 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(); + + // 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 ( <> @@ -127,9 +147,11 @@ const GlobalModals: FC = () => { {isModal(currentModal, "contactUs") && ( )} - {PUBLIC_ALLOW_TUTORIAL && isModal(currentModal, "onboarding") && ( - - )} + {PUBLIC_ALLOW_TUTORIAL && + !!user && + isModal(currentModal, "onboarding") && ( + + )} {isModal(currentModal, "confirm") && ( { onSubmitted={currentModal.data.onSubmitted} /> )} + {isModal(currentModal, "emailCapture") && currentModal.data && ( + + )} {isModal(currentModal, "copyQuestionLink") && currentModal.data && ( 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/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/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 && ( 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 }; +};