Files
EventSnap/frontend/src/routes/join/+page.svelte
Fabian Hamm (Privat) 2b1500e624 fix(ui): make the dashboards agree with each other and with what the code does
Host and admin implement the same four operations with independently written copy, and
admin was stale or wrong in every case. Its release button was always enabled and always
read "Galerie freigeben", so a second tap returned a 409; it showed no release state, no
keepsake progress, no failure reason, no rebuild, and never refreshed after releasing.
It now matches the host page.

Both dashboards subscribed to SSE and never opened the connection — `onSseEvent` only
registers a handler. Every subscription was inert, so the keepsake progress bar sat
frozen after a release and PIN requests appeared only on a manual refresh. It happened
to work when arriving straight from /feed, which connects, and /feed disconnects on
destroy, so navigating to the dashboard killed it again.

The unban confirm named neither of the two things a host most needs to know: unbanning
also restores ALL of that guest's previously hidden photos to the gallery, diashow and
export, and it retires and rebuilds a released keepsake, during which every guest's
download is briefly unavailable. The ban modal warns that uploads vanish; nothing said
they come back. Both now do, gated on the gallery actually being released.

"Event verlassen" implied the account was being deleted, then the dialog said the guest
could log back in. It calls `DELETE /session` — this device only, nothing deleted — so
it is "Abmelden" now. Gallery release now states it locks uploads and is reversible; PIN
reset states the guest is signed out on all devices.

The keepsake download failed silently: nothing inspected the iframe result and the
ticket POST always succeeded, so an over-limit tap did nothing at all. It now surfaces
the (newly visible) 429 and confirms the download started. `/export` rendered "Export
noch nicht verfügbar / Schau nach der Veranstaltung noch einmal vorbei" when the status
request had merely FAILED — telling a guest to come back after an event that already
happened. Both dashboards' error states gained a retry, which a host on a PWA with no
URL bar otherwise has no way to reach.

Modals were centred with no max-height, so on a short viewport the join PIN dialog
clipped equally top and bottom — potentially putting "Weiter zur Galerie" off-screen at
the moment a first-time guest must proceed. The ten moderation buttons were ~28px tall
side by side, on the screen where a mis-tap bans the wrong guest; they are 44px now.
Six German quotation marks paired the opening „ with an ASCII straight quote.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 18:36:57 +02:00

379 lines
12 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api';
import { setAuth } from '$lib/auth';
import { markGuideSeen } from '$lib/onboarding';
import { focusTrap } from '$lib/actions/focus-trap';
// Show which event the guest is joining (USER_JOURNEYS §1). Public, pre-auth.
let eventName = $state('');
onMount(async () => {
try {
const ev = await api.get<{ name: string; slug: string }>('/event');
eventName = ev.name;
} catch {
// Non-fatal — fall back to the generic heading if the lookup fails.
}
});
let displayName = $state('');
let error = $state('');
let loading = $state(false);
let showPinModal = $state(false);
let pin = $state('');
let copied = $state(false);
// Name-taken state — shown instead of the normal form
let nameTaken = $state(false);
let takenName = $state('');
let recoveryPin = $state('');
let recoveryError = $state('');
let recoveryLoading = $state(false);
// Forgot-PIN request state (asks a host to reset it).
let pinRequestSent = $state(false);
let pinRequestLoading = $state(false);
async function handleJoin() {
if (!displayName.trim()) return;
loading = true;
error = '';
try {
const res = await api.post<{
jwt: string;
pin: string;
user_id: string;
is_new: boolean;
}>('/join', { display_name: displayName.trim() });
setAuth(res.jwt, res.pin, res.user_id, displayName.trim());
pin = res.pin;
showPinModal = true;
} catch (e) {
if (e instanceof ApiError && e.code === 'conflict') {
takenName = displayName.trim();
nameTaken = true;
} else if (e instanceof ApiError) {
error = e.message;
} else {
error = 'Ein Fehler ist aufgetreten.';
}
} finally {
loading = false;
}
}
async function handleInlineRecover() {
if (recoveryPin.length < 4) return;
recoveryLoading = true;
recoveryError = '';
try {
const res = await api.post<{ jwt: string; user_id: string }>('/recover', {
display_name: takenName,
pin: recoveryPin.trim()
});
setAuth(res.jwt, recoveryPin.trim(), res.user_id, takenName);
// Same as the dedicated /recover page: this is an EXISTING guest reclaiming their
// account, not a first-timer, so don't replay the onboarding guide at them.
markGuideSeen();
goto('/feed');
} catch (e) {
if (e instanceof ApiError) {
recoveryError = e.message;
} else {
recoveryError = 'Ein Fehler ist aufgetreten.';
}
} finally {
recoveryLoading = false;
}
}
function tryDifferentName() {
nameTaken = false;
recoveryPin = '';
recoveryError = '';
pinRequestSent = false;
// Keep displayName so the user can edit it slightly
}
// Forgot the PIN entirely: ask a host to reset it. The endpoint always 204s (no name
// enumeration), so we optimistically show a confirmation regardless.
async function requestPinReset() {
pinRequestLoading = true;
try {
await api.post('/recover/request', { display_name: takenName });
} catch {
// Non-fatal (rate limit etc.) — still show the confirmation so the user isn't stuck.
} finally {
pinRequestLoading = false;
pinRequestSent = true;
}
}
function copyPin() {
navigator.clipboard.writeText(pin);
copied = true;
setTimeout(() => (copied = false), 2000);
}
function goToFeed() {
goto('/feed');
}
function closePinModal() {
// setAuth has already run on join success — the user is authenticated.
// Closing the PIN reminder while leaving them on /join would render the
// (already-completed) join form again. Honor the dismissal by routing
// them where they actually want to be.
showPinModal = false;
goto('/feed');
}
// Strip non-digits synchronously in the input handler so paste of "1234X"
// never flashes the longer string. Auto-submits on the 4th digit so the
// user doesn't have to chase the (now cosmetic) Anmelden button.
function onRecoveryPinInput(e: Event) {
const el = e.currentTarget as HTMLInputElement;
const cleaned = el.value.replace(/\D/g, '').slice(0, 4);
if (cleaned !== el.value) el.value = cleaned;
recoveryPin = cleaned;
if (recoveryPin.length === 4 && !recoveryLoading) {
handleInlineRecover();
}
}
</script>
<div
class="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-gradient-to-b from-primary-50 via-gray-50 to-gray-50 px-4 py-10 dark:from-gray-900 dark:via-gray-950 dark:to-gray-950"
>
<!-- Soft celebratory glow behind the card — a champagne-gold and a silver wash
for the classic warm/cool wedding tension, kept subtle. -->
<div
aria-hidden="true"
class="pointer-events-none absolute -top-20 h-72 w-72 rounded-full bg-primary-200/30 blur-3xl dark:bg-primary-900/25"
></div>
<div
aria-hidden="true"
class="pointer-events-none absolute -bottom-24 right-0 h-72 w-72 rounded-full bg-gray-200/50 blur-3xl dark:bg-gray-800/40"
></div>
<div class="relative w-full max-w-sm">
<!-- Brand -->
<div class="mb-7 flex flex-col items-center text-center">
<div
class="mb-3 flex h-16 w-16 items-center justify-center rounded-2xl bg-blue-600 text-white shadow-lg shadow-primary-600/30"
>
<svg class="h-8 w-8" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 8.5A2.5 2.5 0 0 1 6.5 6h1.2c.5 0 .95-.28 1.17-.72l.42-.85A1.5 1.5 0 0 1 10.9 3.6h2.2c.57 0 1.09.32 1.34.83l.42.85c.22.44.67.72 1.17.72h1.2A2.5 2.5 0 0 1 20 8.5v7A2.5 2.5 0 0 1 17.5 18h-11A2.5 2.5 0 0 1 4 15.5v-7Z"
stroke="currentColor"
stroke-width="1.6"
/>
<circle cx="12" cy="12" r="3.2" stroke="currentColor" stroke-width="1.6" />
</svg>
</div>
<span
class="font-display text-2xl font-semibold tracking-tight text-gray-900 dark:text-gray-100"
>EventSnap</span
>
</div>
<!-- Invitation card -->
<div class="card p-6 shadow-xl">
{#if nameTaken}
<!-- Name-taken state: sign in with PIN or choose a different name -->
<div
class="mb-5 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800/60 dark:bg-amber-950/30"
>
<p class="font-semibold text-amber-900 dark:text-amber-200">
{takenName}“ ist bereits vergeben.
</p>
<p class="mt-1 text-sm text-amber-800 dark:text-amber-300/90">
Wähle einen anderen Namen, z.&nbsp;B. einen Spitznamen oder füge deinen Nachnamen hinzu
(„{takenName}
M.“ oder „{takenName} aus Berlin“).
</p>
</div>
<p class="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300">
Falls du das bist, melde dich mit deinem PIN an:
</p>
<form
onsubmit={(e) => {
e.preventDefault();
handleInlineRecover();
}}
>
<input
type="text"
value={recoveryPin}
oninput={onRecoveryPinInput}
placeholder="4-stelliger PIN"
maxlength={4}
inputmode="numeric"
pattern="[0-9]*"
data-testid="recovery-pin-input"
class="input mb-3 text-center font-mono text-2xl tracking-widest"
/>
{#if recoveryError}
<p class="mb-3 text-sm text-red-600 dark:text-red-400" data-testid="recovery-error">
{recoveryError}
</p>
{/if}
<button
type="submit"
disabled={recoveryLoading || recoveryPin.length < 4}
data-testid="recovery-submit"
class="btn btn-primary btn-block mb-3"
>
{recoveryLoading ? 'Wird angemeldet...' : 'Anmelden'}
</button>
</form>
<button
onclick={tryDifferentName}
data-testid="try-different-name"
class="btn btn-secondary btn-block"
>
Anderen Namen wählen
</button>
<!-- Forgot the PIN entirely — ask a host to reset it in-app. -->
{#if pinRequestSent}
<p
class="mt-3 rounded-lg bg-green-50 px-4 py-3 text-center text-sm text-green-700 dark:bg-green-950/30 dark:text-green-300"
>
Anfrage gesendet. Bitte einen Host, deine PIN zurückzusetzen — danach kannst du dich mit
der neuen PIN anmelden.
</p>
{:else}
<button
onclick={requestPinReset}
disabled={pinRequestLoading}
data-testid="request-pin-reset"
class="mt-3 w-full text-center text-sm text-blue-600 underline decoration-dotted underline-offset-2 hover:text-blue-700 disabled:opacity-50 dark:text-blue-400"
>
{pinRequestLoading ? 'Wird gesendet…' : 'PIN vergessen? Host um Zurücksetzen bitten'}
</button>
{/if}
{:else}
<!-- Normal join form -->
<p class="mb-1 text-center text-sm font-medium text-gray-500 dark:text-gray-400">
Willkommen bei
</p>
{#if eventName}
<h1
class="mb-3 text-center text-3xl font-semibold text-gray-900 dark:text-gray-100"
data-testid="join-event-name"
>
{eventName}
</h1>
{:else}
<h1 class="mb-3 text-center text-3xl font-semibold text-gray-900 dark:text-gray-100">
dem Event
</h1>
{/if}
<p class="mb-6 text-center text-gray-600 dark:text-gray-400">
Gib deinen Namen ein, um Fotos zu teilen und die Galerie zu sehen.
</p>
<form
onsubmit={(e) => {
e.preventDefault();
handleJoin();
}}
>
<input
type="text"
bind:value={displayName}
placeholder="Dein Name"
maxlength={50}
data-testid="join-name-input"
class="input mb-3 text-lg"
/>
{#if error}
<p class="mb-3 text-sm text-red-600 dark:text-red-400" data-testid="join-error">
{error}
</p>
{/if}
<button
type="submit"
disabled={loading || !displayName.trim()}
data-testid="join-submit"
class="btn btn-primary btn-lg btn-block"
>
{loading ? 'Wird geladen...' : 'Beitreten'}
</button>
</form>
<p class="mt-4 text-center text-sm">
<a
href="/recover"
data-testid="link-to-recover"
class="text-blue-600 hover:underline dark:text-blue-400"
>Ich habe bereits einen Account</a
>
</p>
{/if}
</div>
</div>
</div>
{#if showPinModal}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
data-testid="pin-modal"
>
<!-- Vertically centred, so anything taller than the viewport is clipped equally at both
ends — and this card is ~320 px, which a phone in landscape does not have. The
casualty would be "Weiter zur Galerie" at the very moment a first-time guest has to
get past it, with no visible scrollbar to suggest there is more. Cap the height and
scroll; `overscroll-contain` stops the join page behind from scrolling instead. -->
<div
class="card max-h-full w-full max-w-sm overflow-y-auto overscroll-contain p-6 shadow-xl"
role="dialog"
aria-modal="true"
aria-labelledby="pin-modal-title"
use:focusTrap={{ onclose: closePinModal }}
>
<h2 id="pin-modal-title" class="mb-2 text-xl font-bold text-gray-900 dark:text-gray-100">
Dein Wiederherstellungs-PIN
</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Merke dir diesen PIN! Du brauchst ihn, um dein Konto auf einem anderen Gerät
wiederherzustellen.
</p>
<div class="surface-muted mb-4 flex items-center justify-center gap-3 p-4">
<span
class="font-mono text-4xl font-bold tracking-widest text-gray-900 dark:text-gray-100"
data-testid="pin-display">{pin}</span
>
<button onclick={copyPin} data-testid="pin-copy" class="btn btn-secondary btn-sm min-h-11">
{copied ? 'Kopiert!' : 'Kopieren'}
</button>
</div>
<button
onclick={goToFeed}
data-testid="continue-to-feed"
class="btn btn-primary btn-block mb-2"
>
Weiter zur Galerie
</button>
<button
type="button"
onclick={closePinModal}
class="btn btn-ghost btn-block text-sm text-gray-500 dark:text-gray-400"
>
Schließen
</button>
</div>
</div>
{/if}