feat(mana/web): unified guest-mode prompt in the bottom-stack notification slot

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 01:11:54 +02:00
parent 752f31bfad
commit 3a3cd126cf
3 changed files with 144 additions and 3 deletions

View file

@ -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<T>(
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<T>(
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',

View file

@ -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<BottomNotification[]>([]);
/** 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 = [];
},
};

View file

@ -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 @@
<EncryptionIntroBanner />
</div>
<!-- Guest nudge — sits above the input bar, fades with the stack -->
{#if guestMode && guestMode.notifications.length > 0}
<!-- 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} />
<NotificationBar
notifications={[...(guestMode?.notifications ?? []), ...guestPrompt.notifications]}
/>
</div>
{/if}