Files
EventSnap/frontend/src/routes/recover/+page.svelte
MechaCat02 0737288ed9 a11y: viewport-fit, reduced-motion, like aria-pressed, labels, contrast (M13-M18)
M13: add viewport-fit=cover so env(safe-area-inset-*) resolves on notched phones.
M14: like buttons (list card, grid overlay, lightbox) get aria-pressed + a
descriptive aria-label so AT announces self-state, not just the shared count.
M15: a prefers-reduced-motion media query neutralizes the decorative keyframes
(Ken Burns, crossfade, HeartBurst) and snaps transitions.
M16: join/recover name + PIN inputs and the lightbox comment input get aria-labels.
M17: FeedGrid overlay like/comment buttons get aria-labels and ≥44px hit areas.
M18: bump light-mode secondary text from gray-400 to gray-500 (keeping
dark:text-gray-400) across the app for WCAG AA contrast.

Also raises the PIN inputs to 6 digits (placeholder, maxlength, slice, and the
auto-submit threshold) to match the new 6-digit generated PINs; legacy 4-digit
PINs still submit via the button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:26:21 +02:00

131 lines
4.7 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api';
import { setAuth, getPin, getToken } from '$lib/auth';
import { browser } from '$app/environment';
function goBack() {
// Prefer the actual previous page (most users land here from /join or /account).
// Fall back to a sensible default based on auth state for deep-linked users.
if (browser && window.history.length > 1) {
window.history.back();
return;
}
goto(getToken() ? '/feed' : '/join');
}
let displayName = $state('');
let pin = $state('');
let error = $state('');
let loading = $state(false);
// Pre-fill PIN from localStorage if available
if (browser) {
const savedPin = getPin();
if (savedPin) pin = savedPin;
}
async function handleRecover() {
if (!displayName.trim() || !pin.trim()) return;
loading = true;
error = '';
try {
const res = await api.post<{
jwt: string;
user_id: string;
}>('/recover', { display_name: displayName.trim(), pin: pin.trim() });
setAuth(res.jwt, pin.trim(), res.user_id, displayName.trim());
// Surface a welcome-back toast on /feed after navigation. sessionStorage
// scopes the cue to the next page load so it doesn't replay on refresh.
if (browser) sessionStorage.setItem('eventsnap_just_recovered', displayName.trim());
goto('/feed');
} catch (e) {
if (e instanceof ApiError) {
error = e.message;
} else {
error = 'Ein Fehler ist aufgetreten.';
}
} finally {
loading = false;
}
}
// Strip non-digits synchronously in the input handler (not via $effect on
// bind:value) so a paste of "1234X" doesn't flash the longer string between
// the bind setting `pin` and the reactive cleanup reassigning it. Mutating
// el.value before Svelte's next render means the field never displays the
// invalid intermediate state.
function onPinInput(e: Event) {
const el = e.currentTarget as HTMLInputElement;
const cleaned = el.value.replace(/\D/g, '').slice(0, 6);
if (cleaned !== el.value) el.value = cleaned;
pin = cleaned;
if (pin.length === 6 && displayName.trim() && !loading) {
handleRecover();
}
}
</script>
<div class="flex min-h-screen flex-col bg-gray-50 px-4 dark:bg-gray-950">
<div class="-mx-4 flex items-center px-2 py-3">
<button
type="button"
onclick={goBack}
data-testid="recover-back"
class="flex h-9 w-9 items-center justify-center rounded-full text-gray-500 transition hover:bg-gray-100 active:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-800 dark:active:bg-gray-700"
aria-label="Zurück"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
</button>
</div>
<div class="m-auto w-full max-w-sm">
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900 dark:text-gray-100">Konto wiederherstellen</h1>
<p class="mb-6 text-center text-gray-600 dark:text-gray-400">Gib deinen Namen und deinen PIN ein.</p>
<form onsubmit={(e) => { e.preventDefault(); handleRecover(); }}>
<input
type="text"
bind:value={displayName}
aria-label="Dein Name"
placeholder="Dein Name"
maxlength={50}
data-testid="recover-name-input"
class="mb-3 w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-lg text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500"
/>
<input
type="text"
value={pin}
oninput={onPinInput}
aria-label="Wiederherstellungs-PIN"
placeholder="6-stelliger PIN"
maxlength={6}
inputmode="numeric"
pattern="[0-9]*"
data-testid="recover-pin-input"
class="mb-3 w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-center text-2xl font-mono tracking-widest text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500"
/>
{#if error}
<p class="mb-3 text-sm text-red-600 dark:text-red-400" data-testid="recover-error">{error}</p>
{/if}
<button
type="submit"
disabled={loading || !displayName.trim() || pin.length < 4}
data-testid="recover-submit"
class="w-full rounded-lg bg-blue-600 px-4 py-3 text-lg font-medium text-white transition hover:bg-blue-700 disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400"
>
{loading ? 'Wird geladen...' : 'Wiederherstellen'}
</button>
</form>
<p class="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
Noch kein Konto?
<a href="/join" class="text-blue-600 hover:underline dark:text-blue-400">Neu beitreten</a>
</p>
</div>
</div>