From 3a3cd126cfd45cc6d740a91ca3e10531eaeac607 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 9 Apr 2026 01:11:54 +0200 Subject: [PATCH] feat(mana/web): unified guest-mode prompt in the bottom-stack notification slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a single, deduped notification mechanism for the moments when the app needs to nudge a signed-out user toward registration / login — replacing the silent failures that used to happen when a guest hit a server-only feature. ──── New: lib/stores/guest-prompt.svelte.ts ──── A mana-web-local singleton with a tiny API: guestPrompt.requireAccount(featureKey, message?, actionLabel?) guestPrompt.dismiss(id) guestPrompt.clear() Notifications are deduped by featureKey, so three failed LLM calls in a row don't stack three identical "you need an account" stripes. The "Anmelden" button uses an injectable navigator (via setGuestPromptNavigator) so the layout can wire SvelteKit's goto for client-side routing instead of the default window.location.assign fallback. ──── Hooked into api/base-client.ts ──── fetchWithRetry now short-circuits BEFORE the network call when the user is unauthenticated — surfaces guestPrompt.requireAccount('api') and returns "Anmeldung erforderlich" instead of burning the round-trip to a server that's just going to 401. Saves latency, log noise, and gives a better German error message than "Sitzung abgelaufen". When a 401 does come back from the server (token expired mid-session), fetchWithRetry calls guestPrompt.requireAccount('api:401', ..., 'Neu anmelden') in addition to the existing return-with-error path. Both hooks live in one central place so every feature that uses createApiClient(...) — LLM, subscriptions, profile, credits, events, gifts, etc. — gets the prompt for free without per-module changes. ──── Rendered in routes/(app)/+layout.svelte ──── The bottom-stack already had a NotificationBar slot for the time-based guest nudge from createGuestMode (3-min trigger). The new event-driven prompts merge into the SAME bar via array spread — one stripe, no visual stacking — so the user only ever sees one band even when both sources have something to say. handleAuthReady() also calls guestPrompt.clear() when the user signs in, so leftover guest prompts don't carry into the authenticated session. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mana/apps/web/src/lib/api/base-client.ts | 22 ++++ .../web/src/lib/stores/guest-prompt.svelte.ts | 102 ++++++++++++++++++ .../apps/web/src/routes/(app)/+layout.svelte | 23 +++- 3 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/stores/guest-prompt.svelte.ts diff --git a/apps/mana/apps/web/src/lib/api/base-client.ts b/apps/mana/apps/web/src/lib/api/base-client.ts index 686b47990..d6c26ccbf 100644 --- a/apps/mana/apps/web/src/lib/api/base-client.ts +++ b/apps/mana/apps/web/src/lib/api/base-client.ts @@ -5,6 +5,7 @@ */ import { authStore } from '$lib/stores/auth.svelte'; +import { guestPrompt } from '$lib/stores/guest-prompt.svelte'; /** * Retry configuration @@ -87,6 +88,19 @@ export async function fetchWithRetry( const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig }; let lastError: string | null = null; + // Short-circuit for guest mode: skip the entire fetch ladder, surface + // the unified bottom-bar prompt instead. Without this, every server- + // only feature (LLM, media upload, search, …) would still hit the + // network and return a 401 — costing latency, log noise, and a worse + // error message ("Sitzung abgelaufen") than what we can show locally. + if (!authStore.isAuthenticated) { + guestPrompt.requireAccount( + 'api', + 'Diese Funktion braucht ein Konto. Melde dich an, um sie zu nutzen.' + ); + return { data: null, error: 'Anmeldung erforderlich' }; + } + for (let attempt = 0; attempt <= config.maxRetries; attempt++) { try { // Get fresh token for each attempt @@ -104,6 +118,14 @@ export async function fetchWithRetry( if (!response.ok) { // Don't retry on auth errors if (response.status === 401) { + // Token has expired or been revoked server-side. Surface + // the unified prompt so the user has a one-click path + // back into the auth flow instead of a silent failure. + guestPrompt.requireAccount( + 'api:401', + 'Sitzung abgelaufen — bitte neu anmelden, um fortzufahren.', + 'Neu anmelden' + ); return { data: null, error: 'Sitzung abgelaufen — bitte neu anmelden', diff --git a/apps/mana/apps/web/src/lib/stores/guest-prompt.svelte.ts b/apps/mana/apps/web/src/lib/stores/guest-prompt.svelte.ts new file mode 100644 index 000000000..2fd43fcec --- /dev/null +++ b/apps/mana/apps/web/src/lib/stores/guest-prompt.svelte.ts @@ -0,0 +1,102 @@ +/** + * Unified guest-mode prompt singleton. + * + * The (app) layout already renders a NotificationBar in the bottom stack + * for the time-based guest nudge from `createGuestMode` (shared-stores). + * This singleton lets the rest of the app push *event-driven* prompts + * into the same visual slot — e.g. when an API call returns 401 or a + * server-only feature is invoked while signed out. + * + * Why a separate store from `createGuestMode`? + * - The shared-stores GuestMode is cross-app and only knows about its + * own internal nudge timer. Adding a "push notification from anywhere" + * mutator there would force every host app to wire it up. + * - This file is mana-web-local and reads from authStore directly, so + * no cross-package surface change is needed. + * + * Dedup: each prompt is keyed by `featureKey`. Calling `requireAccount` + * twice with the same key while the first prompt is still visible is a + * no-op — we don't want a stack of "you need an account" toasts after + * three failed LLM calls in a row. + * + * Auto-clear: when authStore flips to authenticated, all pending guest + * prompts vanish. The (app) layout's auth effect calls `clear()` on + * sign-in. + */ + +import type { BottomNotification } from '@mana/shared-ui'; + +let prompts = $state([]); + +/** Default action target — the login page already exposes both login + * and register flows so we point at one URL and let the user pick. */ +const DEFAULT_LOGIN_HREF = '/login'; + +/** Navigates the browser. Kept as a small wrapper so unit tests can + * swap it out without pulling SvelteKit's `goto`. */ +let navigate: (href: string) => void = (href) => { + if (typeof window !== 'undefined') window.location.assign(href); +}; + +/** Test / boot hook: lets the layout swap in SvelteKit's `goto` so + * the prompt action does a client-side transition instead of a full + * page reload. */ +export function setGuestPromptNavigator(fn: (href: string) => void): void { + navigate = fn; +} + +export const guestPrompt = { + get notifications(): BottomNotification[] { + return prompts; + }, + + /** + * Push a "this needs an account" notification into the bottom stack. + * Deduped by `featureKey` — repeat calls while the same prompt is + * still on screen are a no-op so the bar doesn't grow. + * + * @param featureKey Stable id (`'llm'`, `'api:401'`, `'media-upload'`, …) + * @param message User-facing copy. Defaults to a generic message + * — pass a specific one when you can ("KI-Antworten + * brauchen ein Konto.") for better UX. + * @param actionLabel Button label. Defaults to "Anmelden". + */ + requireAccount( + featureKey: string, + message: string = 'Diese Funktion braucht ein Konto. Melde dich an oder registriere dich, um sie zu nutzen.', + actionLabel: string = 'Anmelden' + ): void { + const id = `guest-prompt:${featureKey}`; + if (prompts.some((p) => p.id === id)) return; + + prompts = [ + ...prompts, + { + id, + message, + type: 'warning', + dismissible: true, + action: { + label: actionLabel, + onClick: () => { + navigate(DEFAULT_LOGIN_HREF); + guestPrompt.dismiss(id); + }, + }, + onDismiss: () => guestPrompt.dismiss(id), + }, + ]; + }, + + /** Drop a single prompt by id. Safe to call for unknown ids. */ + dismiss(id: string): void { + prompts = prompts.filter((p) => p.id !== id); + }, + + /** Drop every prompt. Called by the (app) layout on sign-in so the + * bar doesn't carry stale "you need an account" warnings into the + * authenticated session. */ + clear(): void { + prompts = []; + }, +}; diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index fd24b1f6a..2bb7aa9cf 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -31,6 +31,7 @@ import { createFallbackAdapter } from '$lib/quick-input/fallback-adapter'; import { AuthGate, GuestWelcomeModal } from '@mana/shared-auth-ui'; import { createGuestMode, type GuestMode } from '$lib/stores/guest-mode.svelte'; + import { guestPrompt, setGuestPromptNavigator } from '$lib/stores/guest-prompt.svelte'; import { NotificationBar } from '@mana/shared-ui'; import { tagLocalStore, tagMutations, useAllTags } from '@mana/shared-stores'; import { linkLocalStore, linkMutations } from '@mana/shared-links'; @@ -286,6 +287,15 @@ // ── Auth Ready (replaces monolith onMount) ────────────── async function handleAuthReady() { + // Wire the unified guest-prompt singleton to SvelteKit's `goto` + // so the "Anmelden" button does a client-side transition instead + // of the default full-page reload. Idempotent — safe to call on + // every auth-ready cycle. If the user signs in successfully, + // drop any leftover guest prompts so the bottom bar starts the + // authenticated session clean. + setGuestPromptNavigator((href) => goto(href)); + if (authStore.isAuthenticated) guestPrompt.clear(); + // Phase A: Auth-independent — guests + authenticated await Promise.all([ manaStore.initialize(), @@ -454,10 +464,17 @@ - - {#if guestMode && guestMode.notifications.length > 0} + + {#if (guestMode && guestMode.notifications.length > 0) || guestPrompt.notifications.length > 0}
- +
{/if}