fix(ui): localise the confirm screen, drop the brand on the error state
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
The session-less /confirm page defaulted to English, so it showed the English app title. Carry the recipient's language on the deeplink (?lang) and setLocale on load so the page matches the email. Show a localised brand wordmark (Эрудит / Erudit, matching the email) on the success state only; the invalid/expired state now shows just the message, no header.
This commit is contained in:
@@ -114,7 +114,7 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email
|
||||
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil {
|
||||
return err
|
||||
}
|
||||
msg, err := renderConfirmationEmail(purpose, code, s.confirmURL(token), s.baseURL, locale)
|
||||
msg, err := renderConfirmationEmail(purpose, code, s.confirmURL(token, locale), s.baseURL, locale)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -122,13 +122,14 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email
|
||||
return s.mailer.Send(ctx, msg)
|
||||
}
|
||||
|
||||
// confirmURL builds the absolute one-tap confirm deeplink for token, or "" when no
|
||||
// public base URL is configured.
|
||||
func (s *EmailService) confirmURL(token string) string {
|
||||
// confirmURL builds the absolute one-tap confirm deeplink for token in locale, or ""
|
||||
// when no public base URL is configured. The locale rides the fragment as ?lang so the
|
||||
// confirm screen (opened in a browser with no session) renders in the email's language.
|
||||
func (s *EmailService) confirmURL(token, locale string) string {
|
||||
if s.baseURL == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(s.baseURL, "/") + emailConfirmPath + token
|
||||
return strings.TrimRight(s.baseURL, "/") + emailConfirmPath + token + "?lang=" + normalizeLocale(locale)
|
||||
}
|
||||
|
||||
// accountLocale returns the account's preferred UI language for localising email,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export const en = {
|
||||
'app.title': 'Scrabble',
|
||||
'app.brand': 'Erudit',
|
||||
'dict.loading': 'Loading…',
|
||||
'connection.connecting': 'Connecting…',
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MessageKey } from './en';
|
||||
|
||||
export const ru: Record<MessageKey, string> = {
|
||||
'app.title': 'Scrabble',
|
||||
'app.brand': 'Эрудит',
|
||||
'dict.loading': 'Загрузка…',
|
||||
'connection.connecting': 'Подключение…',
|
||||
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { confirmDeeplink } from '../lib/app.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { setLocale, t } from '../lib/i18n/index.svelte';
|
||||
|
||||
let { token }: { token: string } = $props();
|
||||
// The token rides the URL fragment (#/confirm/<token>), which is never sent to the
|
||||
// server, so a plain link prefetch cannot reach it — confirm as soon as the screen
|
||||
// loads. The manual 6-digit code in the same email is the fallback if an aggressive
|
||||
// scanner ever runs the page and consumes the single-use token first.
|
||||
// busy → (a login navigates into the app) | linked | merge | error. The token rides
|
||||
// the URL fragment (#/confirm/<token>), which is never sent to the server, so a plain
|
||||
// link prefetch cannot reach it — confirm on load. The manual six-digit code in the
|
||||
// same email is the fallback if an aggressive scanner runs the page first.
|
||||
let stage = $state<'busy' | 'linked' | 'merge' | 'error'>('busy');
|
||||
|
||||
onMount(async () => {
|
||||
// The email carries the recipient's language on the link (?lang), so this
|
||||
// session-less page renders in it rather than the default.
|
||||
const query = window.location.hash.split('?')[1] ?? '';
|
||||
const lang = new URLSearchParams(query).get('lang');
|
||||
if (lang === 'ru' || lang === 'en') setLocale(lang);
|
||||
|
||||
if (!token) {
|
||||
stage = 'error';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// A login navigates into the app; a link stays here with a result.
|
||||
const r = await confirmDeeplink(token);
|
||||
stage = r === 'merge_required' ? 'merge' : 'linked';
|
||||
} catch {
|
||||
@@ -27,17 +32,14 @@
|
||||
|
||||
<main class="confirm">
|
||||
<div class="card">
|
||||
<div class="brand">{t('app.title')}</div>
|
||||
{#if stage === 'busy'}
|
||||
<p class="muted">{t('confirm.busy')}</p>
|
||||
{:else if stage === 'linked'}
|
||||
<p class="ok">{t('confirm.linked')}</p>
|
||||
<p class="muted">{t('confirm.close')}</p>
|
||||
{:else if stage === 'merge'}
|
||||
<p class="ok">{t('confirm.merge')}</p>
|
||||
<p class="muted">{t('confirm.close')}</p>
|
||||
{:else}
|
||||
{:else if stage === 'error'}
|
||||
<p class="err">{t('confirm.expired')}</p>
|
||||
{:else}
|
||||
<div class="brand">{t('app.brand')}</div>
|
||||
<p class="ok">{stage === 'merge' ? t('confirm.merge') : t('confirm.linked')}</p>
|
||||
<p class="muted">{t('confirm.close')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user