From 1f3f7e9c0299806eb6b981669c1ba3ec811ab266 Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:33:12 +0200 Subject: [PATCH] fix(gallery): make the returning-guest recovery findable (#1210) (#1217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gallery): make the returning-guest recovery findable (#1210) A guest who fills the registration form in again becomes a second gallery_guests row, and their earlier likes and favourites stop counting as theirs. Recovery has always existed to prevent exactly that — as a small link under the submit button, which people reasonably read as fine print and skipped, so duplicates kept accumulating even for guests who had given an email the first time and were eligible for it. Given its own block below a divider, and worded around what the guest loses by missing it: 'Been here before? Your earlier picks are still saved.' rather than 'I've been here before', which reads as a greeting rather than a reason to stop. The affordance itself becomes 'Get them back'. Still a choice the guest makes, not a check the server runs. Looking up whether the typed address is already registered would answer 'is this person in this gallery' to anyone who asked — which is why /guest/recover always returns 200 and cannot be used that way. The alreadyHere key is retired rather than reworded: a key by that name holding 'Get them back' would mislead the next translator. Both new strings are in all seven locales that carried the old one. Three tests: the hint is present, the affordance routes into recovery rather than registering, and an ordinary first-time registration is unchanged. * fix(i18n): match the German formality in the returning-guest hint (#1210) The dialog addresses the guest as Sie throughout — "Willkommen — wie heißen Sie?", "Ihre Auswahl wird unter diesem Namen gespeichert" — and the new line came out in du. Mixing the two in one modal reads as sloppy to a German speaker. Caught by looking at the rendered dialog rather than the string, which is the argument for screenshotting a copy change at all. * fix(gallery): theme tokens for the recovery block, formal register in nl (#1210) External review of #1217. **The dark variant never fires in a gallery.** A dark gallery preset is delivered through CSS variables; ThemeProvider does not add Tailwind's .dark class. So `text-neutral-600 dark:text-neutral-400` on a dark surface stayed dark grey on dark, and the divider stayed light. My block was the only place in this modal using neutral-* classes at all — the rest already uses text-theme and text-muted-theme for exactly this reason. The divider now takes --color-surface-border, which is the token index.css actually defines. **Dutch had the same mixed register German did.** The dialog says uw/u throughout — 'wat is uw naam?', 'Uw selecties worden opgeslagen' — and the new hint came out with 'Je'. Same slip, same fix, found the same way. --------- Co-authored-by: Paul Nothaft --- .../gallery/GuestNamePromptModal.tsx | 47 ++++++++--- ...uestNamePromptModal.recoveryNudge.test.tsx | 79 +++++++++++++++++++ frontend/src/i18n/locales/de.json | 3 +- frontend/src/i18n/locales/en.json | 3 +- frontend/src/i18n/locales/fr.json | 3 +- frontend/src/i18n/locales/nl.json | 3 +- frontend/src/i18n/locales/pt.json | 3 +- frontend/src/i18n/locales/ru.json | 3 +- frontend/src/i18n/locales/sl.json | 3 +- 9 files changed, 131 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/gallery/__tests__/GuestNamePromptModal.recoveryNudge.test.tsx diff --git a/frontend/src/components/gallery/GuestNamePromptModal.tsx b/frontend/src/components/gallery/GuestNamePromptModal.tsx index 76dd135a..dfbc5b53 100644 --- a/frontend/src/components/gallery/GuestNamePromptModal.tsx +++ b/frontend/src/components/gallery/GuestNamePromptModal.tsx @@ -139,16 +139,45 @@ export const GuestNamePromptModal: React.FC = ({ )} - +

+ {t( + 'gallery.guestPrompt.returningHint', + 'Been here before? Your earlier picks are still saved.' + )} +

+ + diff --git a/frontend/src/components/gallery/__tests__/GuestNamePromptModal.recoveryNudge.test.tsx b/frontend/src/components/gallery/__tests__/GuestNamePromptModal.recoveryNudge.test.tsx new file mode 100644 index 00000000..2e4b244b --- /dev/null +++ b/frontend/src/components/gallery/__tests__/GuestNamePromptModal.recoveryNudge.test.tsx @@ -0,0 +1,79 @@ +/** + * The returning-guest nudge on the registration form (#1210). + * + * A guest who fills this form in again becomes a second gallery_guests row and + * their earlier likes and favourites stop counting as theirs. Recovery has + * always been here to prevent exactly that — as a small link under the submit + * button, which people read as fine print and skipped, so duplicates kept + * accumulating even for guests who had given an email the first time. + * + * These pin that it is findable and that it routes into recovery rather than + * registering. What they deliberately do NOT pin is any check against the + * typed email: asking the server whether an address is already registered + * would answer "is this person in this gallery" to anyone who asked. + */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +import { GuestNamePromptModal } from '../GuestNamePromptModal'; + +const closePrompt = vi.fn(); +const openRecovery = vi.fn(); +const register = vi.fn(); + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (_key: string, fallback?: any) => (typeof fallback === 'string' ? fallback : _key), + i18n: { language: 'en' } + }) + }; +}); + +vi.mock('../../../contexts/GuestIdentityContext', () => ({ + useGuestIdentity: () => ({ + promptOpen: true, + closePrompt, + register, + openRecovery, + }), +})); + +describe('returning-guest nudge (#1210)', () => { + beforeEach(() => { + closePrompt.mockReset(); + openRecovery.mockReset(); + register.mockReset(); + }); + + it('tells the guest their earlier picks still exist', () => { + render(); + // Worded around what they lose by missing it. "I've been here before" + // reads as a greeting; this reads as a reason to stop and click. + expect(screen.getByText(/your earlier picks are still saved/i)).toBeInTheDocument(); + }); + + it('routes into recovery instead of registering a second time', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: /get them back/i })); + + expect(openRecovery).toHaveBeenCalledTimes(1); + expect(register).not.toHaveBeenCalled(); + }); + + it('leaves the ordinary registration path alone', async () => { + render(); + + await userEvent.type(screen.getByLabelText(/your name/i), 'Tina'); + await userEvent.click(screen.getByRole('button', { name: /^Continue$/i })); + + // A first-time guest still just registers — the nudge is an offer beside + // the form, not a step in front of it. + expect(register).toHaveBeenCalled(); + expect(openRecovery).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 367982bc..bf4bf9c5 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1046,7 +1046,8 @@ "emailLabel": "E-Mail (optional)", "emailPlaceholder": "Sie@beispiel.de", "submit": "Weiter", - "alreadyHere": "Ich war schon einmal hier" + "returningHint": "Schon einmal hier gewesen? Ihre bisherige Auswahl ist noch gespeichert.", + "recoverPicks": "Auswahl zurückholen" }, "footer": { "forgetMeConfirm": "Ihr Name und Ihre Auswahl werden aus dieser Galerie entfernt.", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 2fcf7919..935a8483 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -583,7 +583,8 @@ "emailLabel": "Email (optional)", "emailPlaceholder": "you@example.com", "submit": "Continue", - "alreadyHere": "I've been here before" + "returningHint": "Been here before? Your earlier picks are still saved.", + "recoverPicks": "Get them back" }, "footer": { "forgetMeConfirm": "Your name and selections will be removed from this gallery.", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 229109ba..4bf4a9f0 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -326,7 +326,8 @@ "emailLabel": "E-mail (optionnel)", "emailPlaceholder": "vous@example.com", "submit": "Continuer", - "alreadyHere": "J'ai déjà été ici" + "returningHint": "Déjà venu ? Vos choix précédents sont toujours enregistrés.", + "recoverPicks": "Les récupérer" }, "footer": { "forgetMeConfirm": "Votre nom et vos sélections seront supprimés de cette galerie.", diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 7c0846fd..e47dacab 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -330,7 +330,8 @@ "emailLabel": "E-mail (optioneel)", "emailPlaceholder": "u@voorbeeld.nl", "submit": "Doorgaan", - "alreadyHere": "Ik ben hier al eerder geweest" + "returningHint": "Al eerder hier geweest? Uw eerdere keuzes zijn nog bewaard.", + "recoverPicks": "Keuzes terughalen" }, "footer": { "forgetMeConfirm": "Uw naam en selecties worden verwijderd uit deze galerij.", diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index 52033cad..0ccba946 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -337,7 +337,8 @@ "emailLabel": "E-mail (opcional)", "emailPlaceholder": "voce@exemplo.pt", "submit": "Continuar", - "alreadyHere": "Já estive aqui antes" + "returningHint": "Já esteve aqui? As suas escolhas anteriores continuam guardadas.", + "recoverPicks": "Recuperá-las" }, "footer": { "forgetMeConfirm": "O seu nome e seleções serão removidos desta galeria.", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 36336023..525fe10c 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -344,7 +344,8 @@ "emailLabel": "Электронная почта (необязательно)", "emailPlaceholder": "вы@пример.рф", "submit": "Продолжить", - "alreadyHere": "Я уже был здесь раньше" + "returningHint": "Уже были здесь? Ваш прежний выбор сохранён.", + "recoverPicks": "Вернуть выбор" }, "footer": { "forgetMeConfirm": "Ваше имя и выборки будут удалены из этой галереи.", diff --git a/frontend/src/i18n/locales/sl.json b/frontend/src/i18n/locales/sl.json index 2257fa7f..c63d4896 100644 --- a/frontend/src/i18n/locales/sl.json +++ b/frontend/src/i18n/locales/sl.json @@ -326,7 +326,8 @@ "emailLabel": "E-pošta (neobvezno)", "emailPlaceholder": "vi@example.com", "submit": "Nadaljuj", - "alreadyHere": "Tukaj sem že bil" + "returningHint": "Ste že bili tukaj? Vaša prejšnja izbira je še shranjena.", + "recoverPicks": "Prikliči izbiro" }, "footer": { "forgetMeConfirm": "Vaše ime in izbire bodo odstranjeni iz te galerije.",