fix(gallery): make the returning-guest recovery findable (#1210) (#1217)

* 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 <[email protected]>
This commit is contained in:
Paul Nothaft
2026-08-28 08:33:12 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent f18bc568c8
commit 1f3f7e9c02
9 changed files with 131 additions and 16 deletions
@@ -139,16 +139,45 @@ export const GuestNamePromptModal: React.FC<GuestNamePromptModalProps> = ({
)} )}
</div> </div>
{/* A returning guest who fills the form in again becomes a second
guest row, and their earlier likes and favourites stop counting
as theirs (#1210). Recovery has always been here to prevent that,
as a small link under the button that people reasonably read as
fine print and skipped.
Given its own block and told in terms of what the guest loses by
missing it, rather than "I've been here before" — which reads as
a greeting, not a warning. Still a choice and not a check: asking
the server whether an address is already registered would answer
"is this person in this gallery" to anyone who asked, which is
why /guest/recover deliberately cannot be used that way. */}
{/* Theme tokens, not neutral-* with a dark: variant (#1210 review).
A dark gallery preset is delivered through CSS variables and does
NOT add Tailwind's .dark class, so the dark: half never fires and
this block would render dark grey on a dark surface. The rest of
the modal uses text-theme / text-muted-theme for exactly this
reason. */}
<div
className="pt-3 mt-1 border-t text-center"
style={{ borderColor: 'var(--color-surface-border, #e5e5e5)' }}
>
<p className="text-sm text-muted-theme">
{t(
'gallery.guestPrompt.returningHint',
'Been here before? Your earlier picks are still saved.'
)}
</p>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
closePrompt(); closePrompt();
openRecovery(); openRecovery();
}} }}
className="text-sm text-accent hover:underline w-full text-center pt-2" className="mt-1 text-sm font-medium text-accent hover:underline"
> >
{t('gallery.guestPrompt.alreadyHere', "I've been here before")} {t('gallery.guestPrompt.recoverPicks', 'Get them back')}
</button> </button>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -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<typeof import('react-i18next')>('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(<GuestNamePromptModal />);
// 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(<GuestNamePromptModal />);
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(<GuestNamePromptModal />);
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();
});
});
+2 -1
View File
@@ -1046,7 +1046,8 @@
"emailLabel": "E-Mail (optional)", "emailLabel": "E-Mail (optional)",
"emailPlaceholder": "[email protected]", "emailPlaceholder": "[email protected]",
"submit": "Weiter", "submit": "Weiter",
"alreadyHere": "Ich war schon einmal hier" "returningHint": "Schon einmal hier gewesen? Ihre bisherige Auswahl ist noch gespeichert.",
"recoverPicks": "Auswahl zurückholen"
}, },
"footer": { "footer": {
"forgetMeConfirm": "Ihr Name und Ihre Auswahl werden aus dieser Galerie entfernt.", "forgetMeConfirm": "Ihr Name und Ihre Auswahl werden aus dieser Galerie entfernt.",
+2 -1
View File
@@ -583,7 +583,8 @@
"emailLabel": "Email (optional)", "emailLabel": "Email (optional)",
"emailPlaceholder": "[email protected]", "emailPlaceholder": "[email protected]",
"submit": "Continue", "submit": "Continue",
"alreadyHere": "I've been here before" "returningHint": "Been here before? Your earlier picks are still saved.",
"recoverPicks": "Get them back"
}, },
"footer": { "footer": {
"forgetMeConfirm": "Your name and selections will be removed from this gallery.", "forgetMeConfirm": "Your name and selections will be removed from this gallery.",
+2 -1
View File
@@ -326,7 +326,8 @@
"emailLabel": "E-mail (optionnel)", "emailLabel": "E-mail (optionnel)",
"emailPlaceholder": "[email protected]", "emailPlaceholder": "[email protected]",
"submit": "Continuer", "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": { "footer": {
"forgetMeConfirm": "Votre nom et vos sélections seront supprimés de cette galerie.", "forgetMeConfirm": "Votre nom et vos sélections seront supprimés de cette galerie.",
+2 -1
View File
@@ -330,7 +330,8 @@
"emailLabel": "E-mail (optioneel)", "emailLabel": "E-mail (optioneel)",
"emailPlaceholder": "[email protected]", "emailPlaceholder": "[email protected]",
"submit": "Doorgaan", "submit": "Doorgaan",
"alreadyHere": "Ik ben hier al eerder geweest" "returningHint": "Al eerder hier geweest? Uw eerdere keuzes zijn nog bewaard.",
"recoverPicks": "Keuzes terughalen"
}, },
"footer": { "footer": {
"forgetMeConfirm": "Uw naam en selecties worden verwijderd uit deze galerij.", "forgetMeConfirm": "Uw naam en selecties worden verwijderd uit deze galerij.",
+2 -1
View File
@@ -337,7 +337,8 @@
"emailLabel": "E-mail (opcional)", "emailLabel": "E-mail (opcional)",
"emailPlaceholder": "[email protected]", "emailPlaceholder": "[email protected]",
"submit": "Continuar", "submit": "Continuar",
"alreadyHere": "Já estive aqui antes" "returningHint": "Já esteve aqui? As suas escolhas anteriores continuam guardadas.",
"recoverPicks": "Recuperá-las"
}, },
"footer": { "footer": {
"forgetMeConfirm": "O seu nome e seleções serão removidos desta galeria.", "forgetMeConfirm": "O seu nome e seleções serão removidos desta galeria.",
+2 -1
View File
@@ -344,7 +344,8 @@
"emailLabel": "Электронная почта (необязательно)", "emailLabel": "Электронная почта (необязательно)",
"emailPlaceholder": "вы@пример.рф", "emailPlaceholder": "вы@пример.рф",
"submit": "Продолжить", "submit": "Продолжить",
"alreadyHere": "Я уже был здесь раньше" "returningHint": "Уже были здесь? Ваш прежний выбор сохранён.",
"recoverPicks": "Вернуть выбор"
}, },
"footer": { "footer": {
"forgetMeConfirm": "Ваше имя и выборки будут удалены из этой галереи.", "forgetMeConfirm": "Ваше имя и выборки будут удалены из этой галереи.",
+2 -1
View File
@@ -326,7 +326,8 @@
"emailLabel": "E-pošta (neobvezno)", "emailLabel": "E-pošta (neobvezno)",
"emailPlaceholder": "[email protected]", "emailPlaceholder": "[email protected]",
"submit": "Nadaljuj", "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": { "footer": {
"forgetMeConfirm": "Vaše ime in izbire bodo odstranjeni iz te galerije.", "forgetMeConfirm": "Vaše ime in izbire bodo odstranjeni iz te galerije.",