Some checks failed
Audit / cargo audit (backend) (push) Failing after 9m31s
Audit / npm audit (frontend) (push) Successful in 1m2s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 1m7s
Checks / Frontend — vitest + svelte-check (push) Failing after 5m37s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Failing after 5m13s
Checks / E2E — typecheck + lint (push) Failing after 36s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 9m8s
E2E / Cross-UA smoke matrix (push) Failing after 4m17s
Two event-day failures, both frontend-only.
TRUNCATED UPLOADS PURGED THE PHOTO. An iPhone guest uploading from the gallery
inside WhatsApp's browser got "Error parsing `multipart/form-data` request" and
the item went to "Gesperrt" with no retry. That message is AXUM's own multipart
rejection — a plain-text 400, no JSON envelope — which means the request body
never arrived intact. It is a transport failure, not a verdict on the file.
`classifyUploadStatus` maps every 4xx to `terminal`, and terminal PURGES the blob
from IndexedDB and offers no retry. So a webview hiccup deleted the only copy the
guest had, and told them the photo was rejected.
Every 400 the app itself raises carries `bad_request` in a JSON envelope (too
large, wrong type, caption too long, NUL byte), so an unparseable 400 is
distinguishable and is now a NetworkError: blob kept, retry offered. This is the
same rule the 403 branch already applies — "an unparseable body must NOT purge
the blob, losing a photo is the worst outcome" — extended to the status that was
actually hit. Retrying is safe because nothing was parsed, so nothing was stored
and no quota was charged, and `X-Client-Upload-Id` makes a duplicate impossible.
IN-APP CAMERA SWITCH. `PUBLIC_CAMERA_ENABLED=false` removes the "Kamera — Jetzt
aufnehmen" entry from the upload sheet. On some phones `getUserMedia` fails when
switching front/back ("Kamera konnte nicht gestartet werden") or when asked for
video, and those failures are per-device and undiagnosable mid-event; the switch
removes the broken path rather than leaving guests to find it. Nothing is lost:
the gallery picker reaches the phone's own camera app and handles video.
Read at RUNTIME via `$env/dynamic/public`, so flipping it is a compose variable
and `up -d frontend`, not a rebuild. Deliberately NOT routed through the
backend's event payload like `comments_enabled`: that flag describes the event,
this one describes what the client can do — the backend cannot tell a camera
upload from a gallery upload and has no stake in it. Keeping it off the app image
also means no backend release on the day of the event.
The onboarding step and the in-app-browser hint drop their camera wording when it
is off, so no text promises a button that is not there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
242 lines
9.4 KiB
Svelte
242 lines
9.4 KiB
Svelte
<script lang="ts">
|
|
import { browser } from '$app/environment';
|
|
import { privacyNote } from '$lib/privacy-note-store';
|
|
import { themePreference, type ThemePreference } from '$lib/theme-store';
|
|
import { focusTrap } from '$lib/actions/focus-trap';
|
|
import { scrollLock } from '$lib/actions/scroll-lock';
|
|
import { vibrate } from '$lib/haptics';
|
|
import { hasSeenGuide, markGuideSeen } from '$lib/onboarding';
|
|
import { cameraEnabled } from '$lib/feature-flags';
|
|
|
|
type Step =
|
|
| { kind: 'text'; icon: string; title: string; body: string }
|
|
| { kind: 'theme'; icon: string; title: string };
|
|
|
|
let visible = $state(false);
|
|
let step = $state(0);
|
|
|
|
// The PIN step gets an extra line when the admin has set a Datenschutzhinweis. Done
|
|
// reactively so the nudge appears as soon as the note loads from `/me/context`,
|
|
// even if the user opens the onboarding before the request returns.
|
|
let hasPrivacyNote = $derived($privacyNote.trim().length > 0);
|
|
|
|
let steps: Step[] = $derived([
|
|
{
|
|
kind: 'text',
|
|
icon: '📸',
|
|
title: 'Willkommen bei EventSnap!',
|
|
body: 'Hier kannst du Fotos und Videos mit allen Gästen teilen — in Echtzeit, ganz ohne App-Store.'
|
|
},
|
|
{
|
|
kind: 'text',
|
|
icon: '⬆️',
|
|
title: 'Fotos & Videos hochladen',
|
|
// The second half is conditional: with the in-app camera switched off, promising
|
|
// "direkt mit der Kamera aufnehmen" describes a button that is not there. The
|
|
// gallery picker still reaches the phone's camera app on both iOS and Android, so
|
|
// the capability survives — only the in-app shortcut is gone.
|
|
body: cameraEnabled
|
|
? 'Tippe auf den Kamera-Button unten in der Mitte, um Fotos aus deiner Galerie zu wählen oder direkt mit der Kamera aufzunehmen. Mehrere Dateien auf einmal sind kein Problem!'
|
|
: 'Tippe auf den Kamera-Button unten in der Mitte und wähle Fotos oder Videos aus deiner Galerie. Frisch aufnehmen kannst du direkt in der Auswahl deines Handys. Mehrere Dateien auf einmal sind kein Problem!'
|
|
},
|
|
{
|
|
kind: 'text',
|
|
icon: '#️⃣',
|
|
title: 'Hashtags nutzen',
|
|
body: 'Füge in deiner Bildunterschrift #hashtags ein, um Fotos zu gruppieren — z.B. #tanz, #buffet oder #reden. Du kannst danach filtern.'
|
|
},
|
|
{
|
|
kind: 'text',
|
|
icon: '👆',
|
|
title: 'Mehr zu einem Foto',
|
|
// Names the VISIBLE control first. Long-press was the only affordance mentioned here,
|
|
// and it is invisible — a guest has to already know it exists. The three-dot button
|
|
// is on every card in list view and is what most people will find.
|
|
//
|
|
// Both views are named on purpose: the button exists only in the LIST view
|
|
// (FeedListCard). The grid tiles have no button at all, only `use:longpress`, so a
|
|
// guest browsing in grid mode would be stranded by a menu-only instruction.
|
|
//
|
|
// "anzeigen und speichern", not "herunterladen": the action opens the original in a
|
|
// new tab (`Content-Disposition: inline`, deliberate — it is the only playable video
|
|
// source), so the guest saves it from there rather than getting a download.
|
|
body: 'Tippe oben rechts am Beitrag auf die drei Punkte (⋯), um weitere Aktionen zu öffnen: das Original in voller Auflösung anzeigen und speichern oder eigene Beiträge löschen. In der Kachel-Ansicht tippst du stattdessen lange auf ein Bild.'
|
|
},
|
|
{
|
|
kind: 'theme',
|
|
icon: '🌗',
|
|
title: 'Helles oder dunkles Design?'
|
|
},
|
|
{
|
|
kind: 'text',
|
|
icon: '🔑',
|
|
title: 'Name und PIN merken!',
|
|
// Recovery needs BOTH the display name and the PIN (`POST /recover` takes
|
|
// {display_name, pin}), but this step only ever mentioned the PIN — so a guest who
|
|
// memorised four digits and forgot whether they typed "Anna" or "Anna M." still
|
|
// could not get back in.
|
|
//
|
|
// "solange du auf diesem Gerät angemeldet bleibst" replaces "ist immer … zu finden",
|
|
// which was false in exactly the case that matters: /account renders the PIN from
|
|
// local storage and falls back to "PIN nicht gespeichert", so it is NOT there on a
|
|
// new device — which is the only reason anyone needs it.
|
|
body:
|
|
'Beim Registrieren hast du einen 4-stelligen PIN bekommen. Für die Anmeldung auf einem anderen Gerät brauchst du beides: genau den Namen, den du hier eingegeben hast, und diesen PIN. Notiere dir am besten beides — solange du auf diesem Gerät angemeldet bleibst, findest du sie unter „Mein Konto“.' +
|
|
(hasPrivacyNote ? ' Den Datenschutzhinweis findest du ebenfalls unter „Mein Konto“.' : '')
|
|
}
|
|
]);
|
|
|
|
// Derived in the script so TypeScript can narrow on `.kind` inside the template.
|
|
let currentStep = $derived(steps[step]);
|
|
|
|
const THEME_OPTIONS: Array<{
|
|
value: ThemePreference;
|
|
label: string;
|
|
hint: string;
|
|
icon: string;
|
|
}> = [
|
|
{ value: 'system', label: 'System', hint: 'Folgt der Geräteeinstellung', icon: '🖥️' },
|
|
{ value: 'light', label: 'Hell', hint: 'Heller Hintergrund', icon: '☀️' },
|
|
{ value: 'dark', label: 'Dunkel', hint: 'Dunkler Hintergrund', icon: '🌙' }
|
|
];
|
|
|
|
if (browser && !hasSeenGuide()) {
|
|
visible = true;
|
|
}
|
|
|
|
function next() {
|
|
if (step < steps.length - 1) {
|
|
step++;
|
|
} else {
|
|
dismiss();
|
|
}
|
|
}
|
|
|
|
function dismiss() {
|
|
markGuideSeen();
|
|
vibrate([0, 8, 60, 8]);
|
|
visible = false;
|
|
}
|
|
|
|
function goToStep(i: number) {
|
|
if (i >= 0 && i < steps.length) step = i;
|
|
}
|
|
</script>
|
|
|
|
{#if visible}
|
|
<!-- Backdrop -->
|
|
<div class="fixed inset-0 z-50 flex items-end justify-center bg-black/60 sm:items-center">
|
|
<!-- Same viewport guard as Modal/ConfirmSheet: the theme step (three option rows) is the
|
|
tallest thing a first-time guest sees, and this dialog's only exits are its own
|
|
"Weiter"/"Überspringen" buttons at the very bottom. Clipping them strands the guest
|
|
on step one of a modal they cannot dismiss. -->
|
|
<div
|
|
class="max-h-[90dvh] w-full max-w-sm overflow-y-auto overscroll-contain rounded-t-3xl bg-white p-6 shadow-2xl dark:bg-gray-900 sm:rounded-2xl"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="onboarding-title"
|
|
use:focusTrap={{ onclose: dismiss }}
|
|
use:scrollLock
|
|
>
|
|
<!-- Step indicator — tap a pip to jump back. The visible dot is small but
|
|
the touch target is padded to ~44 px so it remains tappable on mobile. -->
|
|
<div class="mb-3 flex justify-center">
|
|
{#each steps as _, i (i)}
|
|
<button
|
|
type="button"
|
|
onclick={() => goToStep(i)}
|
|
aria-label={`Schritt ${i + 1}`}
|
|
aria-current={i === step ? 'step' : undefined}
|
|
class="flex items-center justify-center p-2.5"
|
|
>
|
|
<span
|
|
class="block rounded-full transition-all {i === step
|
|
? 'h-1.5 w-6 bg-blue-600 dark:bg-blue-500'
|
|
: 'h-1.5 w-1.5 bg-gray-200 dark:bg-gray-700'}"
|
|
></span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
<div class="mb-6 text-center">
|
|
<div class="mb-4 flex justify-center">
|
|
<div
|
|
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary-50 text-3xl dark:bg-primary-900/30"
|
|
>
|
|
{currentStep.icon}
|
|
</div>
|
|
</div>
|
|
<h2
|
|
id="onboarding-title"
|
|
class="mb-2 text-xl font-semibold text-gray-900 dark:text-gray-100"
|
|
>
|
|
{currentStep.title}
|
|
</h2>
|
|
|
|
{#if currentStep.kind === 'text'}
|
|
<p class="text-sm leading-relaxed text-gray-600 dark:text-gray-300">
|
|
{currentStep.body}
|
|
</p>
|
|
{:else}
|
|
<p class="mb-4 text-sm text-gray-600 dark:text-gray-300">
|
|
Du kannst die Wahl jederzeit unter „Mein Konto“ ändern.
|
|
</p>
|
|
<div class="space-y-2 text-left" role="radiogroup" aria-label="Design">
|
|
{#each THEME_OPTIONS as opt (opt.value)}
|
|
{@const selected = $themePreference === opt.value}
|
|
<button
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={selected}
|
|
onclick={() => themePreference.set(opt.value)}
|
|
class="flex w-full cursor-pointer items-center gap-3 rounded-xl border-2 p-3 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-200
|
|
{selected
|
|
? 'border-blue-600 bg-blue-50 dark:border-blue-500 dark:bg-blue-950/40'
|
|
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800'}"
|
|
>
|
|
<span class="text-2xl leading-none">{opt.icon}</span>
|
|
<div class="flex-1">
|
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{opt.label}</p>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">{opt.hint}</p>
|
|
</div>
|
|
<span
|
|
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2
|
|
{selected
|
|
? 'border-blue-600 bg-blue-600 dark:border-blue-500 dark:bg-blue-500'
|
|
: 'border-gray-300 dark:border-gray-600'}"
|
|
>
|
|
{#if selected}
|
|
<svg
|
|
class="h-3 w-3 text-white"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
stroke-width="3"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
{/if}
|
|
</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Buttons -->
|
|
<div class="flex gap-2">
|
|
<button
|
|
onclick={dismiss}
|
|
class="flex-1 rounded-xl border border-gray-200 py-3 text-sm text-gray-600 hover:bg-gray-50 active:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:active:bg-gray-800"
|
|
>
|
|
Überspringen
|
|
</button>
|
|
<button onclick={next} class="btn btn-primary flex-1">
|
|
{step < steps.length - 1 ? 'Weiter' : "Los geht's!"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|