feat(onboarding): M2 — route guard + shell + Screen 1 (name)

- PATCH /api/v1/me/profile in mana-auth (name, image with 1–80 char
  validation) — powers the Screen-1 save
- (app)/+layout.svelte:
  * isOnboarding derived from pathname
  * handleAuthReady loads onboardingStatus, redirects brand-new users
    to /onboarding/name (fire-and-forget so sync/data-layer init keeps
    running in parallel)
  * chrome (PillNav, wallpaper, bottom-stack) hidden in onboarding mode;
    AuthGate still wraps so the flow enforces authentication
- /onboarding/+layout.svelte: full-viewport shell with progress dots
  (1/3, 2/3, 3/3) and a skip-all that marks the flow complete and
  sends the user home
- /onboarding/+page.svelte: redirects bare entry to /onboarding/name
- /onboarding/name/+page.svelte: text input (1–40 chars), Enter = Weiter,
  skip falls back to email local-part so Screen 2's greeting is never
  empty

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 22:49:52 +02:00
parent 5a92e1168b
commit 5aecf8b90d
6 changed files with 689 additions and 229 deletions

View file

@ -82,6 +82,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { isNavCollapsed as collapsedStore } from '$lib/stores/navigation';
import { onboardingStatus } from '$lib/stores/onboarding-status.svelte';
import { getPillAppItems } from '@mana/shared-branding';
import { STORAGE_KEYS } from '$lib/config/storage-keys';
import { SearchRegistry } from '$lib/search/registry';
@ -109,6 +110,14 @@
else setTimeout(cb, 0);
}
// ── Onboarding mode ─────────────────────────────────────
// When the user is on any /onboarding/* route the main chrome
// (PillNav, wallpaper, bottom-stack) is hidden so the three
// onboarding screens get the full viewport. The route guard
// below also reads this flag to avoid redirecting a user who
// is already inside the flow.
let isOnboarding = $derived($page.url.pathname.startsWith('/onboarding'));
// ── App switcher ────────────────────────────────────────
// Prefer the active Space's tier for gating — falls back to the user
// tier only during the bootstrap window where no space has loaded.
@ -311,15 +320,25 @@
// apps now — no standalone routes. The user-menu dropdown links via
// `spiralHref` / `creditsHref` / `profileHref` etc., all pointing to
// `/?app=<id>` deep-links.
let isOnWorkbench = $derived($page.url.pathname === '/');
let baseNavItems = $derived<PillNavItem[]>([
{
href: '/',
label: 'Workbench-Tabs',
icon: 'tabs',
iconOnly: true,
onClick: handleBottomBarToggle,
active: isBottomBarVisible,
},
isOnWorkbench
? {
href: '/',
label: 'Workbench-Tabs',
icon: 'tabs',
iconOnly: true,
onClick: handleBottomBarToggle,
active: isBottomBarVisible,
}
: {
href: '/',
label: 'Home',
icon: 'home',
iconOnly: true,
onClick: () => goto('/'),
active: false,
},
{
href: '/',
label: 'Suche',
@ -626,6 +645,22 @@
// value (0 on a fresh tab) until a sync actually runs.
refreshPendingCount();
// Onboarding guard: brand-new users land on `/` after signup
// but have `onboardingCompletedAt === null`. Redirect them
// into the 3-screen flow. Fired non-blocking because the
// earlier Phase A already initialized the data layer, so
// leaving sync running in parallel during onboarding is
// harmless (and useful — templates/+page.svelte writes a
// scene at the end). Self-skips when already inside the
// flow so the screens don't bounce each other.
if (!isOnboarding) {
void onboardingStatus.load().then(() => {
if (onboardingStatus.needsOnboarding) {
goto('/onboarding/name', { replaceState: true });
}
});
}
// Phase B-idle: settings + return-visit telemetry. Non-gating.
idle(async () => {
trackReturnVisit();
@ -769,270 +804,278 @@
appName="Mana"
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
>
<div class="min-h-screen" class:bg-background={!wallpaperStore.hasWallpaper}>
<WallpaperLayer config={wallpaperStore.effective} />
{#if isOnboarding}
<!-- Onboarding mode: clean slate — the onboarding shell layout
renders a full-viewport container with its own progress dots
and skip-all. Keep AuthGate wrapping so the screens still
enforce the authenticated-user requirement. -->
{@render children()}
{:else}
<div class="min-h-screen" class:bg-background={!wallpaperStore.hasWallpaper}>
<WallpaperLayer config={wallpaperStore.effective} />
<!-- Bottom Stack: all fixed-bottom elements in one flex container.
<!-- Bottom Stack: all fixed-bottom elements in one flex container.
Hidden entirely when fullscreen mode is active (press "f"). -->
{#if !isFullscreen}
<div class="bottom-stack" style:--bottom-chrome-height="{bottomChromeHeight}px">
<!-- Page-injected bottom bar (e.g. workbench scene+app tabs).
{#if !isFullscreen}
<div class="bottom-stack" style:--bottom-chrome-height="{bottomChromeHeight}px">
<!-- Page-injected bottom bar (e.g. workbench scene+app tabs).
Gated by isBottomBarVisible so the "workbench tabs" pill can
toggle it without unmounting the owning page. -->
{#if isBottomBarVisible && bottomBarStore.component}
{@const BarComponent = bottomBarStore.component}
<BarComponent {...bottomBarStore.props} />
{/if}
{#if isBottomBarVisible && bottomBarStore.component}
{@const BarComponent = bottomBarStore.component}
<BarComponent {...bottomBarStore.props} />
{/if}
<!-- One-time encryption intro — sits at the top of the stack so
<!-- One-time encryption intro — sits at the top of the stack so
it can't be obscured by the QuickInputBar / TagStrip / PillNav.
Self-gates on isVaultUnlocked() so guests never see it.
Lazy-loaded after idle (see $effects above). -->
{#if EncryptionIntroBannerC}
{@const EncryptionIntroBanner = EncryptionIntroBannerC}
<div class="bottom-stack-notification">
<EncryptionIntroBanner />
</div>
{/if}
{#if EncryptionIntroBannerC}
{@const EncryptionIntroBanner = EncryptionIntroBannerC}
<div class="bottom-stack-notification">
<EncryptionIntroBanner />
</div>
{/if}
<!-- Sync pause banner — shown when sync was paused due to insufficient credits -->
{#if syncBilling.paused}
<div class="bottom-stack-notification">
<div
class="flex items-center justify-between gap-3 rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:bg-amber-900/20 dark:text-amber-200"
>
<span>Cloud Sync pausiert — Credits reichen nicht aus.</span>
<div class="flex gap-2">
<a
href="/?app=credits&tab=packages"
class="font-medium underline hover:no-underline"
>
Credits aufladen
</a>
<a
href="/?app=settings#cloud-sync"
class="font-medium underline hover:no-underline"
>
Sync-Einstellungen
</a>
<!-- Sync pause banner — shown when sync was paused due to insufficient credits -->
{#if syncBilling.paused}
<div class="bottom-stack-notification">
<div
class="flex items-center justify-between gap-3 rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:bg-amber-900/20 dark:text-amber-200"
>
<span>Cloud Sync pausiert — Credits reichen nicht aus.</span>
<div class="flex gap-2">
<a
href="/?app=credits&tab=packages"
class="font-medium underline hover:no-underline"
>
Credits aufladen
</a>
<a
href="/?app=settings#cloud-sync"
class="font-medium underline hover:no-underline"
>
Sync-Einstellungen
</a>
</div>
</div>
</div>
</div>
{/if}
{/if}
<!-- Guest notifications — combines the time-based nudge from
<!-- Guest notifications — combines the time-based nudge from
createGuestMode (one-shot after N minutes) with the
event-driven prompts pushed by guestPrompt.requireAccount
(e.g. server feature called while signed out, 401 from
the auth-aware fetch). Both flow into the same bar so
the user only ever sees one stripe instead of stacking. -->
{#if (guestMode && guestMode.notifications.length > 0) || guestPrompt.notifications.length > 0}
<div class="bottom-stack-notification">
<NotificationBar
notifications={[...(guestMode?.notifications ?? []), ...guestPrompt.notifications]}
/>
</div>
{/if}
{#if (guestMode && guestMode.notifications.length > 0) || guestPrompt.notifications.length > 0}
<div class="bottom-stack-notification">
<NotificationBar
notifications={[...(guestMode?.notifications ?? []), ...guestPrompt.notifications]}
/>
</div>
{/if}
<!-- Session expiry warning (auth only). Self-gates on the
<!-- Session expiry warning (auth only). Self-gates on the
secondsLeft countdown and only renders inside the stack
when actually warning, so the wrapper is no-op otherwise.
Lazy-loaded after idle. -->
{#if authStore.isAuthenticated && SessionWarningC}
{@const SessionWarning = SessionWarningC}
<div class="bottom-stack-notification">
<SessionWarning />
</div>
{/if}
{#if authStore.isAuthenticated && SessionWarningC}
{@const SessionWarning = SessionWarningC}
<div class="bottom-stack-notification">
<SessionWarning />
</div>
{/if}
<!-- Cross-module automation suggestions. Lives in the (app)
<!-- Cross-module automation suggestions. Lives in the (app)
stack because automationsStore is an (app)-only module
and the toast doesn't make sense on auth/landing pages
anyway. Self-gates on visible state. Lazy-loaded after idle. -->
{#if SuggestionToastC}
{@const SuggestionToast = SuggestionToastC}
<div class="bottom-stack-notification">
<SuggestionToast />
</div>
{/if}
{#if SuggestionToastC}
{@const SuggestionToast = SuggestionToastC}
<div class="bottom-stack-notification">
<SuggestionToast />
</div>
{/if}
<!-- Companion Brain pulse nudges — water reminders, streak
<!-- Companion Brain pulse nudges — water reminders, streak
warnings, morning summary etc. Self-gates on active nudges.
Lazy-loaded after idle. -->
{#if NudgeToastC}
{@const NudgeToast = NudgeToastC}
<div class="bottom-stack-notification">
<NudgeToast />
</div>
{/if}
{#if NudgeToastC}
{@const NudgeToast = NudgeToastC}
<div class="bottom-stack-notification">
<NudgeToast />
</div>
{/if}
<!-- QuickInputBar with inline nav toggle — gated by the "search" pill -->
{#if isQuickInputVisible}
<QuickInputBar
onSearch={inputBarAdapter.onSearch}
onSelect={inputBarAdapter.onSelect}
onParseCreate={inputBarAdapter.onParseCreate}
onCreate={inputBarAdapter.onCreate}
onSearchChange={inputBarAdapter.onSearchChange}
placeholder={inputBarAdapter.placeholder}
appIcon={inputBarAdapter.appIcon}
emptyText={inputBarAdapter.emptyText}
createText={inputBarAdapter.createText}
deferSearch={inputBarAdapter.deferSearch}
locale={$locale || 'de'}
defaultOptions={inputBarAdapter.defaultOptions}
selectedDefaultId={inputBarAdapter.selectedDefaultId}
defaultOptionLabel={inputBarAdapter.defaultOptionLabel}
onDefaultChange={inputBarAdapter.onDefaultChange}
highlightPatterns={inputBarAdapter.highlightPatterns}
positioning="static"
injectedText={sttInjectedText}
>
{#snippet leftAction()}
<button
class="stt-mic-btn"
class:recording={localStt.state === 'recording'}
class:busy={localStt.state === 'loading' || localStt.state === 'transcribing'}
onclick={() => localStt.toggle()}
disabled={localStt.state === 'loading' || localStt.state === 'transcribing'}
title={localStt.state === 'recording'
? 'Aufnahme beenden'
: localStt.state === 'transcribing'
? 'Wird transkribiert…'
: localStt.state === 'loading'
? 'Modell wird geladen…'
: 'Spracheingabe'}
>
{#if localStt.state === 'recording'}
<Stop size={16} weight="fill" />
{:else}
<Microphone size={16} weight={localStt.state === 'idle' ? 'regular' : 'fill'} />
{/if}
</button>
{/snippet}
</QuickInputBar>
{/if}
<!-- QuickInputBar with inline nav toggle — gated by the "search" pill -->
{#if isQuickInputVisible}
<QuickInputBar
onSearch={inputBarAdapter.onSearch}
onSelect={inputBarAdapter.onSelect}
onParseCreate={inputBarAdapter.onParseCreate}
onCreate={inputBarAdapter.onCreate}
onSearchChange={inputBarAdapter.onSearchChange}
placeholder={inputBarAdapter.placeholder}
appIcon={inputBarAdapter.appIcon}
emptyText={inputBarAdapter.emptyText}
createText={inputBarAdapter.createText}
deferSearch={inputBarAdapter.deferSearch}
locale={$locale || 'de'}
defaultOptions={inputBarAdapter.defaultOptions}
selectedDefaultId={inputBarAdapter.selectedDefaultId}
defaultOptionLabel={inputBarAdapter.defaultOptionLabel}
onDefaultChange={inputBarAdapter.onDefaultChange}
highlightPatterns={inputBarAdapter.highlightPatterns}
positioning="static"
injectedText={sttInjectedText}
>
{#snippet leftAction()}
<button
class="stt-mic-btn"
class:recording={localStt.state === 'recording'}
class:busy={localStt.state === 'loading' || localStt.state === 'transcribing'}
onclick={() => localStt.toggle()}
disabled={localStt.state === 'loading' || localStt.state === 'transcribing'}
title={localStt.state === 'recording'
? 'Aufnahme beenden'
: localStt.state === 'transcribing'
? 'Wird transkribiert…'
: localStt.state === 'loading'
? 'Modell wird geladen…'
: 'Spracheingabe'}
>
{#if localStt.state === 'recording'}
<Stop size={16} weight="fill" />
{:else}
<Microphone size={16} weight={localStt.state === 'idle' ? 'regular' : 'fill'} />
{/if}
</button>
{/snippet}
</QuickInputBar>
{/if}
<!-- TagStrip (between QuickInputBar and PillNav) -->
{#if isTagStripVisible}
<TagStrip
tags={(allTags.value ?? []).map((t) => ({
id: t.id,
name: t.name,
color: t.color || '#3b82f6',
}))}
selectedIds={[]}
onToggle={() => {}}
onClear={() => {}}
onTagDrop={tagDropHandler ?? undefined}
managementHref="/tags"
loading={allTags.loading}
positioning="static"
/>
{/if}
<!-- TagStrip (between QuickInputBar and PillNav) -->
{#if isTagStripVisible}
<TagStrip
tags={(allTags.value ?? []).map((t) => ({
id: t.id,
name: t.name,
color: t.color || '#3b82f6',
}))}
selectedIds={[]}
onToggle={() => {}}
onClear={() => {}}
onTagDrop={tagDropHandler ?? undefined}
managementHref="/tags"
loading={allTags.loading}
positioning="static"
/>
{/if}
<!-- Dropdown-as-bar: shows the items of the currently opened
<!-- Dropdown-as-bar: shows the items of the currently opened
PillNavigation dropdown (theme / AI / sync / user) as
horizontal pills directly above the PillNav. -->
{#if activeBar}
<PillDropdownBar
items={activeBar.items}
label={activeBar.label}
icon={activeBar.icon}
{#if activeBar}
<PillDropdownBar
items={activeBar.items}
label={activeBar.label}
icon={activeBar.icon}
positioning="static"
/>
{/if}
<!-- PillNav (bottom of stack) -->
<PillNavigation
onOpenBar={handleOpenBar}
activeBarId={activeBar?.id ?? null}
items={navItems}
currentPath={$page.url.pathname}
appName="Mana"
homeRoute="/"
onLogout={handleSignOut}
onToggleTheme={handleToggleTheme}
{isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
loginHref="/login"
primaryColor="hsl(var(--color-primary))"
showAppSwitcher={false}
showAiTierSelector={true}
aiTierItems={aiTier.items}
currentAiTierLabel={aiTier.label}
currentAiTierIcon={aiTier.icon}
showSyncStatus={authStore.isAuthenticated}
syncStatusItems={syncStatus.items}
currentSyncLabel={syncStatus.label}
{appItems}
{userEmail}
profileHref="/?app=profile"
spiralHref="/?app=spiral"
creditsHref="/?app=credits"
themesHref="/?app=themes"
helpHref="/?app=help"
{spotlightActions}
{contentSearcher}
positioning="static"
/>
{/if}
>
{#snippet startSlot()}
{#if authStore.isAuthenticated}
<SpaceSwitcher locale={$locale === 'en' ? 'en' : 'de'} />
{/if}
{/snippet}
</PillNavigation>
</div>
{/if}
<!-- PillNav (bottom of stack) -->
<PillNavigation
onOpenBar={handleOpenBar}
activeBarId={activeBar?.id ?? null}
items={navItems}
currentPath={$page.url.pathname}
appName="Mana"
homeRoute="/"
onLogout={handleSignOut}
onToggleTheme={handleToggleTheme}
{isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
loginHref="/login"
primaryColor="hsl(var(--color-primary))"
showAppSwitcher={false}
showAiTierSelector={true}
aiTierItems={aiTier.items}
currentAiTierLabel={aiTier.label}
currentAiTierIcon={aiTier.icon}
showSyncStatus={authStore.isAuthenticated}
syncStatusItems={syncStatus.items}
currentSyncLabel={syncStatus.label}
{appItems}
{userEmail}
profileHref="/?app=profile"
spiralHref="/?app=spiral"
creditsHref="/?app=credits"
themesHref="/?app=themes"
helpHref="/?app=help"
{spotlightActions}
{contentSearcher}
positioning="static"
>
{#snippet startSlot()}
{#if authStore.isAuthenticated}
<SpaceSwitcher locale={$locale === 'en' ? 'en' : 'de'} />
{/if}
{/snippet}
</PillNavigation>
</div>
{/if}
<!-- DnD: floating preview -->
<DragPreview />
<!-- DnD: floating preview -->
<DragPreview />
<!-- Main content.
<!-- Main content.
Publish layout offsets as CSS variables so descendants (esp.
ModuleShell in the carousel) can compute their available
height against viewport + bottom chrome without prop
drilling. `--workbench-top-offset` must match the vertical
padding on the inner max-w-7xl wrapper below. -->
<main
style="padding-bottom: {bottomChromeHeight +
8}px; --bottom-chrome-height: {bottomChromeHeight}px; --workbench-reserved-y: 1.5rem;"
class="pt-2"
>
<div class="mx-auto max-w-7xl px-3 py-2 sm:px-6 sm:py-3 lg:px-8">
{#if routeBlocked && routeAppId}
<RouteTierGate
appName={routeAppId.name}
userTierLabel={routeTierLabels.user}
requiredTierLabel={routeTierLabels.required}
/>
{:else}
{@render children()}
{/if}
</div>
</main>
<main
style="padding-bottom: {bottomChromeHeight +
8}px; --bottom-chrome-height: {bottomChromeHeight}px; --workbench-reserved-y: 1.5rem;"
class="pt-2"
>
<div class="mx-auto max-w-7xl px-3 py-2 sm:px-6 sm:py-3 lg:px-8">
{#if routeBlocked && routeAppId}
<RouteTierGate
appName={routeAppId.name}
userTierLabel={routeTierLabels.user}
requiredTierLabel={routeTierLabels.required}
/>
{:else}
{@render children()}
{/if}
</div>
</main>
<!-- Session expiry warning lives inside .bottom-stack now (see above)
<!-- Session expiry warning lives inside .bottom-stack now (see above)
so it doesn't end up obscured by the QuickInputBar like
EncryptionIntroBanner used to be. -->
<!-- Keyboard shortcuts modal — loaded on first `?` press -->
{#if KeyboardShortcutsModalC}
{@const KeyboardShortcutsModal = KeyboardShortcutsModalC}
<KeyboardShortcutsModal open={showShortcuts} onclose={() => (showShortcuts = false)} />
{/if}
</div>
<!-- Keyboard shortcuts modal — loaded on first `?` press -->
{#if KeyboardShortcutsModalC}
{@const KeyboardShortcutsModal = KeyboardShortcutsModalC}
<KeyboardShortcutsModal open={showShortcuts} onclose={() => (showShortcuts = false)} />
{/if}
</div>
{/if}
<!-- Navigation Context Menu -->
<ContextMenu

View file

@ -0,0 +1,140 @@
<!--
Onboarding shell — wraps the three onboarding screens with a
centered layout, progress dots, and a skip-all affordance.
Lives under (app)/ so the AuthGate in the parent layout keeps
unauthenticated users out. The parent layout hides its own chrome
(PillNav, bottom-stack) when the pathname starts with /onboarding,
so this shell renders into a clean full-viewport container.
The skip-all button writes `onboardingCompletedAt = now()` via the
shared store and navigates home. Individual screens can also call
`onboardingStatus.markComplete()` themselves (templates/+page.svelte
does this as part of its finish handler).
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { X } from '@mana/shared-icons';
import { onboardingStatus } from '$lib/stores/onboarding-status.svelte';
let { children } = $props();
// Map pathname → step index (0-based) so the progress dots light up.
// Any unknown /onboarding/* path falls back to step 0.
let currentStep = $derived.by(() => {
const path = $page.url.pathname;
if (path.startsWith('/onboarding/templates')) return 2;
if (path.startsWith('/onboarding/look')) return 1;
return 0; // /onboarding, /onboarding/name, or anything else
});
async function handleSkipAll() {
try {
await onboardingStatus.markComplete();
} catch (err) {
console.warn('[onboarding] skip-all markComplete failed:', err);
}
await goto('/');
}
</script>
<div class="onboarding-shell">
<header class="onboarding-header">
<div
class="progress-dots"
role="progressbar"
aria-valuemin={1}
aria-valuemax={3}
aria-valuenow={currentStep + 1}
>
{#each [0, 1, 2] as step (step)}
<span class="dot" class:active={step === currentStep} class:done={step < currentStep}
></span>
{/each}
</div>
<button
type="button"
class="skip-all"
onclick={handleSkipAll}
aria-label="Onboarding überspringen"
>
<X size={14} />
<span>Überspringen</span>
</button>
</header>
<main class="onboarding-body">
{@render children()}
</main>
</div>
<style>
.onboarding-shell {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
flex-direction: column;
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
}
.onboarding-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
}
.progress-dots {
display: flex;
gap: 0.5rem;
}
.dot {
width: 28px;
height: 4px;
border-radius: 2px;
background: hsl(var(--color-muted-foreground) / 0.25);
transition: background 0.2s ease;
}
.dot.done {
background: hsl(var(--color-primary) / 0.6);
}
.dot.active {
background: hsl(var(--color-primary));
}
.skip-all {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
border-radius: 0.5rem;
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
}
.skip-all:hover {
background: hsl(var(--color-muted) / 0.4);
color: hsl(var(--color-foreground));
}
.onboarding-body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1.5rem 4rem;
overflow-y: auto;
}
</style>

View file

@ -0,0 +1,12 @@
<!--
/onboarding — bare entry point. The real first step is /onboarding/name;
a user who lands here (typo, bookmark of the parent route) is forwarded.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => {
goto('/onboarding/name', { replaceState: true });
});
</script>

View file

@ -0,0 +1,221 @@
<!--
Onboarding — Screen 1: Name.
Persists the user's chosen display name via PATCH /api/v1/me/profile,
then advances to /onboarding/look. Skip falls back to the email's
local-part so the greeting on Screen 2 is never empty.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { authStore } from '$lib/stores/auth.svelte';
import { ArrowRight } from '@mana/shared-icons';
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injected = (window as unknown as { __PUBLIC_MANA_AUTH_URL__?: string })
.__PUBLIC_MANA_AUTH_URL__;
if (injected) return injected;
}
return import.meta.env.DEV ? 'http://localhost:3001' : '';
}
// Prefill: existing name (returning user revisiting) → email local-part
// → empty. Trimmed so whitespace-only values don't count as "filled".
let name = $state((authStore.user?.name ?? '').trim());
let saving = $state(false);
let error = $state<string | null>(null);
let canSubmit = $derived(name.trim().length >= 1 && name.trim().length <= 40 && !saving);
async function saveName(value: string) {
const token = await authStore.getValidToken();
if (!token) throw new Error('Not authenticated');
const res = await fetch(`${getAuthUrl()}/api/v1/me/profile`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: value }),
});
if (!res.ok) throw new Error(`PATCH /me/profile → ${res.status}`);
}
async function handleNext() {
const trimmed = name.trim();
if (!trimmed) return;
saving = true;
error = null;
try {
await saveName(trimmed);
await goto('/onboarding/look');
} catch (err) {
console.error('[onboarding/name] save failed:', err);
error = 'Speichern fehlgeschlagen. Versuch es noch mal.';
} finally {
saving = false;
}
}
async function handleSkip() {
const fallback = (authStore.user?.email ?? '').split('@')[0] || 'du';
saving = true;
error = null;
try {
// Persist the fallback too so the user shows up as something
// other than "User 1234" in admin UIs — cheap, idempotent.
await saveName(fallback);
} catch (err) {
console.warn('[onboarding/name] skip-save failed:', err);
} finally {
saving = false;
}
await goto('/onboarding/look');
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && canSubmit) {
e.preventDefault();
handleNext();
}
}
</script>
<div class="screen">
<div class="hero">
<h1>Wie willst du genannt werden?</h1>
<p class="subtitle">Dein Name erscheint oben in der Navigation und in Nachrichten von Mana.</p>
</div>
<div class="field">
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={name}
onkeydown={handleKeydown}
placeholder="z. B. Till"
maxlength={40}
autocomplete="given-name"
autofocus
aria-label="Dein Name"
/>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
</div>
<div class="actions">
<button type="button" class="btn-ghost" onclick={handleSkip} disabled={saving}>
Überspringen
</button>
<button
type="button"
class="btn-primary"
onclick={handleNext}
disabled={!canSubmit}
aria-label="Weiter zu Theme-Auswahl"
>
<span>Weiter</span>
<ArrowRight size={16} weight="bold" />
</button>
</div>
</div>
<style>
.screen {
width: 100%;
max-width: 440px;
display: flex;
flex-direction: column;
gap: 2rem;
}
.hero h1 {
font-size: 1.75rem;
font-weight: 700;
margin: 0 0 0.5rem 0;
letter-spacing: -0.02em;
}
.subtitle {
font-size: 0.9375rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.5;
margin: 0;
}
.field input {
width: 100%;
padding: 0.875rem 1rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
background: hsl(var(--color-surface, var(--color-background)));
color: hsl(var(--color-foreground));
font-size: 1.0625rem;
transition: border-color 0.15s ease;
}
.field input:focus {
outline: none;
border-color: hsl(var(--color-primary));
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.15);
}
.error {
margin: 0.5rem 0 0 0;
font-size: 0.8125rem;
color: hsl(var(--color-error, 0 84% 60%));
}
.actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-ghost {
padding: 0.625rem 1rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.9375rem;
border-radius: 0.5rem;
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
}
.btn-ghost:hover:not(:disabled) {
background: hsl(var(--color-muted) / 0.4);
color: hsl(var(--color-foreground));
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground, 0 0% 100%));
font-size: 0.9375rem;
font-weight: 600;
border-radius: 0.75rem;
cursor: pointer;
transition:
transform 0.15s ease,
box-shadow 0.15s ease,
opacity 0.15s ease;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px hsl(var(--color-primary) / 0.35);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View file

@ -99,7 +99,7 @@ app.route('/api/v1/api-keys', createApiKeyValidationRoute(apiKeysService));
// ─── Me (GDPR) ──────────────────────────────────────────────
app.use('/api/v1/me/*', jwtAuth(config.baseUrl));
app.route('/api/v1/me', createMeRoutes(userDataService));
app.route('/api/v1/me', createMeRoutes(userDataService, db));
// ─── Encryption vault (per-user master key custody) ────────
// Mounted under /me so it inherits the JWT middleware above and shows

View file

@ -7,11 +7,14 @@
*/
import { Hono } from 'hono';
import { eq } from 'drizzle-orm';
import type { AuthUser } from '../middleware/jwt-auth';
import type { UserDataService } from '../services/user-data';
import type { Database } from '../db/connection';
import { users } from '../db/schema/auth';
import { sendAccountDeletionEmail } from '../email/send';
export function createMeRoutes(userDataService: UserDataService) {
export function createMeRoutes(userDataService: UserDataService, db: Database) {
return (
new Hono<{ Variables: { user: AuthUser } }>()
@ -57,5 +60,46 @@ export function createMeRoutes(userDataService: UserDataService) {
return c.json(result);
})
// ─── Update profile (name, avatar) ──────────────────────
// Minimal patch endpoint used by the onboarding flow and
// Settings → Profile. JWT-based like the rest of /me/*; the
// updated name only lands in the user's JWT on next mint, so
// the caller is responsible for refreshing its in-memory
// representation of authStore.user. See docs/plans/onboarding-flow.md.
.patch('/profile', async (c) => {
const user = c.get('user');
const body = (await c.req.json().catch(() => ({}))) as {
name?: unknown;
image?: unknown;
};
const patch: { name?: string; image?: string; updatedAt: Date } = {
updatedAt: new Date(),
};
if (typeof body.name === 'string') {
const trimmed = body.name.trim();
if (trimmed.length < 1 || trimmed.length > 80) {
return c.json({ error: 'name must be 180 characters' }, 400);
}
patch.name = trimmed;
}
if (typeof body.image === 'string') {
patch.image = body.image;
}
if (!('name' in patch) && !('image' in patch)) {
return c.json({ error: 'no fields to update' }, 400);
}
const [updated] = await db
.update(users)
.set(patch)
.where(eq(users.id, user.userId))
.returning({ id: users.id, name: users.name, image: users.image });
if (!updated) return c.json({ error: 'User not found' }, 404);
return c.json({ name: updated.name, image: updated.image });
})
);
}