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>
379 lines
12 KiB
Svelte
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. 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}
|