feat(i18n): add multilanguage support to waitlist emails and result pages
- Add 'language' column to waitlist_entries (en/it/es/fr/de, default en) - Accept 'lang' field in POST /waitlist request body - Translate confirmation email (subject, badge, heading, body, CTA, footer) - Translate confirm/unsubscribe result HTML pages - Return localized success message in WaitlistResponse - Update language preference on duplicate signups - Alembic migration 003_add_language_column
This commit is contained in:
104
app/routes.py
104
app/routes.py
@@ -36,9 +36,10 @@ async def join_waitlist(
|
||||
"""
|
||||
# Honeypot — bots fill hidden fields; silently "succeed"
|
||||
if body.website:
|
||||
return WaitlistResponse()
|
||||
return WaitlistResponse.for_lang(body.lang)
|
||||
|
||||
email = body.email.lower().strip()
|
||||
lang = body.lang
|
||||
ip = _get_client_ip(request)
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
@@ -49,18 +50,23 @@ async def join_waitlist(
|
||||
entry = existing.scalar_one_or_none()
|
||||
|
||||
if entry is not None:
|
||||
# Update language preference if changed
|
||||
if entry.language != lang:
|
||||
entry.language = lang
|
||||
await db.commit()
|
||||
# Re-send confirmation if not yet confirmed (and not anonymized)
|
||||
if not entry.confirmed and not entry.anonymized_at and settings.brevo_configured:
|
||||
token = generate_token(email)
|
||||
confirm_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/confirm?token={token}"
|
||||
unsub_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/unsubscribe?token={token}"
|
||||
asyncio.create_task(send_confirmation_email(email, confirm_url, unsub_url))
|
||||
return WaitlistResponse()
|
||||
asyncio.create_task(send_confirmation_email(email, confirm_url, unsub_url, lang=lang))
|
||||
return WaitlistResponse.for_lang(lang)
|
||||
|
||||
entry = WaitlistEntry(
|
||||
email=email,
|
||||
ip_address=ip,
|
||||
source="website",
|
||||
language=lang,
|
||||
consent_given_at=now,
|
||||
)
|
||||
db.add(entry)
|
||||
@@ -73,9 +79,9 @@ async def join_waitlist(
|
||||
token = generate_token(email)
|
||||
confirm_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/confirm?token={token}"
|
||||
unsub_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/unsubscribe?token={token}"
|
||||
asyncio.create_task(send_confirmation_email(email, confirm_url, unsub_url))
|
||||
asyncio.create_task(send_confirmation_email(email, confirm_url, unsub_url, lang=lang))
|
||||
|
||||
return WaitlistResponse()
|
||||
return WaitlistResponse.for_lang(lang)
|
||||
|
||||
|
||||
@router.get("/waitlist/confirm", response_class=HTMLResponse)
|
||||
@@ -100,6 +106,8 @@ async def confirm_email(
|
||||
if entry is None:
|
||||
return HTMLResponse(content=_result_page(success=False), status_code=400)
|
||||
|
||||
lang = entry.language or "en"
|
||||
|
||||
if not entry.confirmed:
|
||||
entry.confirmed = True
|
||||
# Clear IP now that consent is confirmed — no longer needed
|
||||
@@ -111,7 +119,7 @@ async def confirm_email(
|
||||
if settings.brevo_configured:
|
||||
asyncio.create_task(add_contact_to_list(email))
|
||||
|
||||
return HTMLResponse(content=_result_page(success=True))
|
||||
return HTMLResponse(content=_result_page(success=True, lang=lang))
|
||||
|
||||
|
||||
@router.get("/waitlist/unsubscribe", response_class=HTMLResponse)
|
||||
@@ -138,11 +146,12 @@ async def unsubscribe(
|
||||
# Already gone — show success anyway (idempotent)
|
||||
return HTMLResponse(content=_result_page(success=True, variant="unsubscribe"))
|
||||
|
||||
lang = entry.language or "en"
|
||||
_anonymize_entry(entry)
|
||||
await db.commit()
|
||||
logger.info("Waitlist entry anonymized (unsubscribe)")
|
||||
|
||||
return HTMLResponse(content=_result_page(success=True, variant="unsubscribe"))
|
||||
return HTMLResponse(content=_result_page(success=True, variant="unsubscribe", lang=lang))
|
||||
|
||||
|
||||
def _anonymize_entry(entry: WaitlistEntry) -> None:
|
||||
@@ -154,37 +163,96 @@ def _anonymize_entry(entry: WaitlistEntry) -> None:
|
||||
entry.anonymized_at = now
|
||||
|
||||
|
||||
def _result_page(*, success: bool, variant: str = "confirm") -> str:
|
||||
def _result_page(*, success: bool, variant: str = "confirm", lang: str = "en") -> str:
|
||||
"""Branded HTML response for confirmation/unsubscribe result."""
|
||||
# ── Translations for result pages ──
|
||||
_PAGE_I18N: dict[str, dict[str, str]] = {
|
||||
"en": {
|
||||
"confirmed_title": "You’re confirmed!",
|
||||
"confirmed_msg": "Your email has been verified. We’ll notify you when adiuvAI is ready.",
|
||||
"invalid_confirm": "This confirmation link is no longer valid. Please sign up again.",
|
||||
"removed_title": "Data removed",
|
||||
"removed_msg": "Your personal data has been anonymized. You will not receive any further emails from us.",
|
||||
"invalid_unsub": "This unsubscribe link is no longer valid. Contact privacy@adiuvai.com if you need help.",
|
||||
"invalid_title": "Invalid or expired link",
|
||||
"btn": "Go to adiuvai.com",
|
||||
},
|
||||
"it": {
|
||||
"confirmed_title": "Sei confermato!",
|
||||
"confirmed_msg": "La tua email è stata verificata. Ti avviseremo quando adiuvAI sarà pronto.",
|
||||
"invalid_confirm": "Questo link di conferma non è più valido. Iscriviti di nuovo.",
|
||||
"removed_title": "Dati rimossi",
|
||||
"removed_msg": "I tuoi dati personali sono stati anonimizzati. Non riceverai più email da noi.",
|
||||
"invalid_unsub": "Questo link di cancellazione non è più valido. Contatta privacy@adiuvai.com per assistenza.",
|
||||
"invalid_title": "Link non valido o scaduto",
|
||||
"btn": "Vai su adiuvai.com",
|
||||
},
|
||||
"es": {
|
||||
"confirmed_title": "¡Estás confirmado!",
|
||||
"confirmed_msg": "Tu correo ha sido verificado. Te avisaremos cuando adiuvAI esté listo.",
|
||||
"invalid_confirm": "Este enlace de confirmación ya no es válido. Regístrate de nuevo.",
|
||||
"removed_title": "Datos eliminados",
|
||||
"removed_msg": "Tus datos personales han sido anonimizados. No recibirás más correos de nuestra parte.",
|
||||
"invalid_unsub": "Este enlace de cancelación ya no es válido. Contacta privacy@adiuvai.com si necesitas ayuda.",
|
||||
"invalid_title": "Enlace no válido o expirado",
|
||||
"btn": "Ir a adiuvai.com",
|
||||
},
|
||||
"fr": {
|
||||
"confirmed_title": "Vous êtes confirmé !",
|
||||
"confirmed_msg": "Votre e‑mail a été vérifié. Nous vous préviendrons quand adiuvAI sera prêt.",
|
||||
"invalid_confirm": "Ce lien de confirmation n'est plus valide. Veuillez vous inscrire à nouveau.",
|
||||
"removed_title": "Données supprimées",
|
||||
"removed_msg": "Vos données personnelles ont été anonymisées. Vous ne recevrez plus d'e‑mails de notre part.",
|
||||
"invalid_unsub": "Ce lien de désinscription n'est plus valide. Contactez privacy@adiuvai.com pour obtenir de l'aide.",
|
||||
"invalid_title": "Lien non valide ou expiré",
|
||||
"btn": "Aller sur adiuvai.com",
|
||||
},
|
||||
"de": {
|
||||
"confirmed_title": "Du bist bestätigt!",
|
||||
"confirmed_msg": "Deine E‑Mail wurde verifiziert. Wir benachrichtigen dich, wenn adiuvAI bereit ist.",
|
||||
"invalid_confirm": "Dieser Bestätigungslink ist nicht mehr gültig. Bitte melde dich erneut an.",
|
||||
"removed_title": "Daten entfernt",
|
||||
"removed_msg": "Deine persönlichen Daten wurden anonymisiert. Du wirst keine weiteren E‑Mails von uns erhalten.",
|
||||
"invalid_unsub": "Dieser Abmeldelink ist nicht mehr gültig. Kontaktiere privacy@adiuvai.com für Hilfe.",
|
||||
"invalid_title": "Ungültiger oder abgelaufener Link",
|
||||
"btn": "Zu adiuvai.com",
|
||||
},
|
||||
}
|
||||
|
||||
t = _PAGE_I18N.get(lang, _PAGE_I18N["en"])
|
||||
html_lang = lang if lang in _PAGE_I18N else "en"
|
||||
|
||||
if variant == "unsubscribe":
|
||||
if success:
|
||||
title = "Data removed"
|
||||
message = "Your personal data has been anonymized. You will not receive any further emails from us."
|
||||
title = t["removed_title"]
|
||||
message = t["removed_msg"]
|
||||
icon_bg = "rgba(251,200,129,0.12)"
|
||||
icon_border = "rgba(251,200,129,0.25)"
|
||||
icon_svg = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e5a94e" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'
|
||||
else:
|
||||
title = "Invalid or expired link"
|
||||
message = "This unsubscribe link is no longer valid. Contact privacy@adiuvai.com if you need help."
|
||||
title = t["invalid_title"]
|
||||
message = t["invalid_unsub"]
|
||||
icon_bg = "rgba(220,38,38,0.08)"
|
||||
icon_border = "rgba(220,38,38,0.18)"
|
||||
icon_svg = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#dc2626" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'
|
||||
elif success:
|
||||
title = "You’re confirmed!"
|
||||
message = "Your email has been verified. We’ll notify you when adiuvAI is ready."
|
||||
title = t["confirmed_title"]
|
||||
message = t["confirmed_msg"]
|
||||
icon_bg = "rgba(251,200,129,0.12)"
|
||||
icon_border = "rgba(251,200,129,0.25)"
|
||||
icon_svg = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e5a94e" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'
|
||||
else:
|
||||
title = "Invalid or expired link"
|
||||
message = "This confirmation link is no longer valid. Please sign up again."
|
||||
title = t["invalid_title"]
|
||||
message = t["invalid_confirm"]
|
||||
icon_bg = "rgba(220,38,38,0.08)"
|
||||
icon_border = "rgba(220,38,38,0.18)"
|
||||
icon_svg = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#dc2626" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'
|
||||
|
||||
btn_label = t["btn"]
|
||||
|
||||
return f"""\
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{html_lang}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
@@ -264,7 +332,7 @@ def _result_page(*, success: bool, variant: str = "confirm") -> str:
|
||||
<div class="icon">{icon_svg}</div>
|
||||
<h1>{title}</h1>
|
||||
<p>{message}</p>
|
||||
<a href="https://adiuvai.com" class="btn">Go to adiuvai.com</a>
|
||||
<a href="https://adiuvai.com" class="btn">{btn_label}</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
Reference in New Issue
Block a user