* 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:
co-authored by
Paul Nothaft
parent
f18bc568c8
commit
1f3f7e9c02
@@ -139,16 +139,45 @@ export const GuestNamePromptModal: React.FC<GuestNamePromptModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{/* A returning guest who fills the form in again becomes a second
|
||||||
type="button"
|
guest row, and their earlier likes and favourites stop counting
|
||||||
onClick={() => {
|
as theirs (#1210). Recovery has always been here to prevent that,
|
||||||
closePrompt();
|
as a small link under the button that people reasonably read as
|
||||||
openRecovery();
|
fine print and skipped.
|
||||||
}}
|
|
||||||
className="text-sm text-accent hover:underline w-full text-center pt-2"
|
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)' }}
|
||||||
>
|
>
|
||||||
{t('gallery.guestPrompt.alreadyHere', "I've been here before")}
|
<p className="text-sm text-muted-theme">
|
||||||
</button>
|
{t(
|
||||||
|
'gallery.guestPrompt.returningHint',
|
||||||
|
'Been here before? Your earlier picks are still saved.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
closePrompt();
|
||||||
|
openRecovery();
|
||||||
|
}}
|
||||||
|
className="mt-1 text-sm font-medium text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{t('gallery.guestPrompt.recoverPicks', 'Get them back')}
|
||||||
|
</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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -344,7 +344,8 @@
|
|||||||
"emailLabel": "Электронная почта (необязательно)",
|
"emailLabel": "Электронная почта (необязательно)",
|
||||||
"emailPlaceholder": "вы@пример.рф",
|
"emailPlaceholder": "вы@пример.рф",
|
||||||
"submit": "Продолжить",
|
"submit": "Продолжить",
|
||||||
"alreadyHere": "Я уже был здесь раньше"
|
"returningHint": "Уже были здесь? Ваш прежний выбор сохранён.",
|
||||||
|
"recoverPicks": "Вернуть выбор"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"forgetMeConfirm": "Ваше имя и выборки будут удалены из этой галереи.",
|
"forgetMeConfirm": "Ваше имя и выборки будут удалены из этой галереи.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
Reference in New Issue
Block a user