feat(shared-auth-ui): add GuestRegistrationNudge + complete feature texts + improve seed data

- Add GuestRegistrationNudge component: shows a floating banner after
  X minutes of guest usage to encourage sign-up (bottom-center, dismissible)
- Add guestNudge.ts utilities (session tracking, delay, dismiss via localStorage)
- Add feature texts for all 16 missing apps in GuestWelcomeModal
- Integrate nudge in Todo app as reference implementation (3min delay)
- Improve SkillTree seed: 3 skills across branches, 6 activities, 1 achievement
- Improve Zitare seed: 5 favorites, 2 themed lists instead of 1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 12:16:55 +02:00
parent 4d0e9a6a3f
commit 1570cc0bb4
8 changed files with 439 additions and 20 deletions

View file

@ -1,32 +1,45 @@
/**
* Guest seed data for the SkilltTree app.
*
* Provides a demo skill with an activity to showcase the leveling system.
* Provides demo skills across multiple branches with activities and an achievement
* to showcase the leveling system, XP progression, and activity feed.
*/
import type { LocalSkill, LocalActivity } from './local-store';
import type { LocalSkill, LocalActivity, LocalAchievement } from './local-store';
const DEMO_SKILL_ID = 'demo-coding';
const DEMO_CODING_ID = 'demo-coding';
const DEMO_FITNESS_ID = 'demo-fitness';
const DEMO_CREATIVE_ID = 'demo-creative';
export const guestSkills: LocalSkill[] = [
{
id: DEMO_SKILL_ID,
id: DEMO_CODING_ID,
name: 'Programmieren',
description: 'Software-Entwicklung und Coding-Skills',
branch: 'intellect',
icon: '💻',
currentXp: 150,
totalXp: 150,
currentXp: 250,
totalXp: 250,
level: 1,
},
{
id: 'demo-fitness',
id: DEMO_FITNESS_ID,
name: 'Fitness',
description: 'Körperliche Fitness und Training',
branch: 'body',
icon: '💪',
currentXp: 50,
totalXp: 50,
currentXp: 120,
totalXp: 120,
level: 1,
},
{
id: DEMO_CREATIVE_ID,
name: 'Zeichnen',
description: 'Illustration, Skizzen und visuelles Denken',
branch: 'creativity',
icon: '🎨',
currentXp: 60,
totalXp: 60,
level: 0,
},
];
@ -34,18 +47,61 @@ export const guestSkills: LocalSkill[] = [
export const guestActivities: LocalActivity[] = [
{
id: 'activity-1',
skillId: DEMO_SKILL_ID,
skillId: DEMO_CODING_ID,
xpEarned: 100,
description: 'TypeScript-Projekt aufgesetzt',
duration: 60,
timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
timestamp: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(),
},
{
id: 'activity-2',
skillId: DEMO_SKILL_ID,
skillId: DEMO_FITNESS_ID,
xpEarned: 50,
description: '5 km Joggen im Park',
duration: 35,
timestamp: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
},
{
id: 'activity-3',
skillId: DEMO_CODING_ID,
xpEarned: 100,
description: 'REST API mit Hono gebaut',
duration: 90,
timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
},
{
id: 'activity-4',
skillId: DEMO_CREATIVE_ID,
xpEarned: 60,
description: 'Erste Skizzen mit Procreate',
duration: 45,
timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
},
{
id: 'activity-5',
skillId: DEMO_FITNESS_ID,
xpEarned: 70,
description: 'Krafttraining — Oberkörper',
duration: 50,
timestamp: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
},
{
id: 'activity-6',
skillId: DEMO_CODING_ID,
xpEarned: 50,
description: 'Unit Tests geschrieben',
duration: 30,
timestamp: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
timestamp: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(),
},
];
export const guestAchievements: LocalAchievement[] = [
{
id: 'achievement-1',
key: 'first-skill',
name: 'Erste Schritte',
description: 'Deinen ersten Skill erstellt',
icon: '🌱',
unlockedAt: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(),
},
];

View file

@ -6,7 +6,7 @@
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { guestSkills, guestActivities } from './guest-seed';
import { guestSkills, guestActivities, guestAchievements } from './guest-seed';
// ─── Types ──────────────────────────────────────────────────
@ -58,6 +58,7 @@ export const skilltreeStore = createLocalStore({
{
name: 'achievements',
indexes: ['key', 'unlockedAt'],
guestSeed: guestAchievements,
},
],
sync: {

View file

@ -43,7 +43,12 @@
import { parseTaskInput, resolveTaskIds, formatParsedTaskPreview } from '$lib/utils/task-parser';
import { todoOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import {
SessionExpiredBanner,
AuthGate,
GuestWelcomeModal,
GuestRegistrationNudge,
} from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { TodoEvents } from '@manacore/shared-utils/analytics';
import { todoStore, taskCollection } from '$lib/data/local-store';
@ -554,6 +559,13 @@
{#if authStore.isAuthenticated}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{:else}
<GuestRegistrationNudge
appId="todo"
onRegister={() => goto('/register')}
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
delayMinutes={3}
/>
{/if}
</AuthGate>

View file

@ -5,18 +5,26 @@
import type { LocalFavorite, LocalQuoteList } from './local-store';
// Some well-known quote IDs from the content package
// Well-known quote IDs from @zitare/content
export const guestFavorites: LocalFavorite[] = [
{ id: 'fav-1', quoteId: 'mot-1' },
{ id: 'fav-2', quoteId: 'weis-3' },
{ id: 'fav-3', quoteId: 'mot-7' },
{ id: 'fav-4', quoteId: 'weis-1' },
{ id: 'fav-5', quoteId: 'liebe-1' },
];
export const guestLists: LocalQuoteList[] = [
{
id: 'list-onboarding',
name: 'Meine Lieblingszitate',
description: 'Eine Beispiel-Sammlung zum Ausprobieren',
quoteIds: ['mot-1', 'weis-3'],
id: 'list-motivation',
name: 'Motivation & Antrieb',
description: 'Zitate die dich voranbringen',
quoteIds: ['mot-1', 'mot-7', 'mot-3'],
},
{
id: 'list-weisheit',
name: 'Zeitlose Weisheiten',
description: 'Die großen Denker und Dichter',
quoteIds: ['weis-1', 'weis-3', 'weis-5'],
},
];

View file

@ -0,0 +1,225 @@
<script lang="ts">
import { onMount } from 'svelte';
import { UserPlus, X, ArrowRight } from '@manacore/shared-icons';
import { startGuestSession, shouldShowGuestNudge, dismissGuestNudge } from '../utils/guestNudge';
interface Props {
/** App identifier for scoped localStorage */
appId: string;
/** Callback when register is clicked */
onRegister: () => void;
/** Locale for translations (default: 'de') */
locale?: 'de' | 'en';
/** Minutes to wait before showing (default: 5) */
delayMinutes?: number;
}
let { appId, onRegister, locale = 'de', delayMinutes = 5 }: Props = $props();
let visible = $state(false);
const texts = $derived(
locale === 'en'
? {
message: 'Like what you see? Save your data across devices.',
button: 'Create account',
dismiss: 'Close',
}
: {
message: 'Gefällt es dir? Sichere deine Daten geräteübergreifend.',
button: 'Konto erstellen',
dismiss: 'Schließen',
}
);
function handleDismiss() {
dismissGuestNudge(appId);
visible = false;
}
function handleRegister() {
dismissGuestNudge(appId);
onRegister();
}
onMount(() => {
startGuestSession(appId);
// Check periodically if delay has elapsed
const interval = setInterval(() => {
if (shouldShowGuestNudge(appId, delayMinutes)) {
visible = true;
clearInterval(interval);
}
}, 30_000); // check every 30s
// Also check immediately (returning user)
if (shouldShowGuestNudge(appId, delayMinutes)) {
visible = true;
clearInterval(interval);
}
return () => clearInterval(interval);
});
</script>
{#if visible}
<div class="nudge-banner" role="status" aria-live="polite">
<div class="nudge-content">
<p class="nudge-message">{texts.message}</p>
<div class="nudge-actions">
<button class="nudge-register" onclick={handleRegister}>
<UserPlus size={16} weight="bold" />
{texts.button}
<ArrowRight size={14} />
</button>
<button class="nudge-dismiss" onclick={handleDismiss} aria-label={texts.dismiss}>
<X size={16} weight="bold" />
</button>
</div>
</div>
</div>
{/if}
<style>
.nudge-banner {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
z-index: 9998;
padding: 0 1rem;
width: 100%;
max-width: 480px;
animation: slideUp 300ms ease-out;
}
@keyframes slideUp {
from {
transform: translateX(-50%) translateY(100%);
opacity: 0;
}
to {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
}
.nudge-content {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(0, 0, 0, 0.08);
backdrop-filter: blur(12px);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.12),
0 0 0 1px rgba(0, 0, 0, 0.04) inset;
}
:global(.dark) .nudge-content {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.12);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(255, 255, 255, 0.06) inset;
}
.nudge-message {
flex: 1;
margin: 0;
font-size: 0.8125rem;
line-height: 1.4;
color: rgba(0, 0, 0, 0.7);
}
:global(.dark) .nudge-message {
color: rgba(255, 255, 255, 0.75);
}
.nudge-actions {
display: flex;
align-items: center;
gap: 0.375rem;
flex-shrink: 0;
}
.nudge-register {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
font-weight: 600;
color: white;
background: #7c3aed;
border: none;
border-radius: 0.625rem;
cursor: pointer;
transition: background 150ms ease;
white-space: nowrap;
}
.nudge-register:hover {
background: #6d28d9;
}
.nudge-dismiss {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
color: rgba(0, 0, 0, 0.4);
background: transparent;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: background 150ms ease;
}
.nudge-dismiss:hover {
background: rgba(0, 0, 0, 0.06);
color: rgba(0, 0, 0, 0.7);
}
:global(.dark) .nudge-dismiss {
color: rgba(255, 255, 255, 0.4);
}
:global(.dark) .nudge-dismiss:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
}
@media (prefers-reduced-motion: reduce) {
.nudge-banner {
animation: none;
}
* {
transition-duration: 0.01ms !important;
}
}
@media (max-width: 480px) {
.nudge-banner {
bottom: 1rem;
}
.nudge-content {
flex-wrap: wrap;
}
.nudge-message {
flex-basis: calc(100% - 2.5rem);
}
.nudge-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>

View file

@ -48,6 +48,26 @@
zitare: ['Tägliche Inspiration für dich', 'Quelloffen & unabhängig', 'Privat by Design'],
picture: ['Kreativität trifft KI', 'Quelloffen & unabhängig', 'Privat by Design'],
cards: ['Lernen leicht gemacht', 'Quelloffen & unabhängig', 'Privat by Design'],
moodlit: ['Dein Raum, deine Stimmung', 'Quelloffen & unabhängig', 'Privat by Design'],
calc: ['Rechnen ohne Ablenkung', 'Quelloffen & unabhängig', 'Privat by Design'],
guides: ['Anleitungen, die funktionieren', 'Quelloffen & unabhängig', 'Privat by Design'],
citycorners: ['Entdecke deine Stadt', 'Quelloffen & unabhängig', 'Privat by Design'],
planta: ['Pflanzenpflege leicht gemacht', 'Quelloffen & unabhängig', 'Privat by Design'],
photos: ['Deine Fotos, deine Galerie', 'Quelloffen & unabhängig', 'Privat by Design'],
questions: ['Recherche mit System', 'Quelloffen & unabhängig', 'Privat by Design'],
context: ['Dein Wissen, strukturiert', 'Quelloffen & unabhängig', 'Privat by Design'],
presi: ['Präsentationen neu gedacht', 'Quelloffen & unabhängig', 'Privat by Design'],
mukke: ['Musik machen, einfach so', 'Quelloffen & unabhängig', 'Privat by Design'],
storage: ['Deine Dateien, dein Tresor', 'Quelloffen & unabhängig', 'Privat by Design'],
times: ['Zeiterfassung ohne Overhead', 'Quelloffen & unabhängig', 'Privat by Design'],
inventar: ['Alles im Überblick behalten', 'Quelloffen & unabhängig', 'Privat by Design'],
uload: ['Links kürzen & verwalten', 'Quelloffen & unabhängig', 'Privat by Design'],
news: ['Nachrichten, kuratiert für dich', 'Quelloffen & unabhängig', 'Privat by Design'],
arcade: ['Spiele direkt im Browser', 'Quelloffen & unabhängig', 'Privat by Design'],
skilltree: ['Dein Fortschritt, sichtbar', 'Quelloffen & unabhängig', 'Privat by Design'],
nutriphi: ['Ernährung bewusst leben', 'Quelloffen & unabhängig', 'Privat by Design'],
wisekeep: ['Wissen bewahren & teilen', 'Quelloffen & unabhängig', 'Privat by Design'],
memoro: ['Sprache wird zu Wissen', 'Quelloffen & unabhängig', 'Privat by Design'],
};
/** Default features per app (English) */
@ -60,6 +80,26 @@
zitare: ['Daily inspiration for you', 'Open-source & independent', 'Private by design'],
picture: ['Where creativity meets AI', 'Open-source & independent', 'Private by design'],
cards: ['Learning made easy', 'Open-source & independent', 'Private by design'],
moodlit: ['Your space, your mood', 'Open-source & independent', 'Private by design'],
calc: ['Calculate without distraction', 'Open-source & independent', 'Private by design'],
guides: ['Guides that actually work', 'Open-source & independent', 'Private by design'],
citycorners: ['Discover your city', 'Open-source & independent', 'Private by design'],
planta: ['Plant care made simple', 'Open-source & independent', 'Private by design'],
photos: ['Your photos, your gallery', 'Open-source & independent', 'Private by design'],
questions: ['Research with structure', 'Open-source & independent', 'Private by design'],
context: ['Your knowledge, organized', 'Open-source & independent', 'Private by design'],
presi: ['Presentations reimagined', 'Open-source & independent', 'Private by design'],
mukke: ['Make music, just like that', 'Open-source & independent', 'Private by design'],
storage: ['Your files, your vault', 'Open-source & independent', 'Private by design'],
times: ['Time tracking without overhead', 'Open-source & independent', 'Private by design'],
inventar: ['Keep track of everything', 'Open-source & independent', 'Private by design'],
uload: ['Shorten & manage links', 'Open-source & independent', 'Private by design'],
news: ['News, curated for you', 'Open-source & independent', 'Private by design'],
arcade: ['Games right in your browser', 'Open-source & independent', 'Private by design'],
skilltree: ['Your progress, visualized', 'Open-source & independent', 'Private by design'],
nutriphi: ['Mindful nutrition tracking', 'Open-source & independent', 'Private by design'],
wisekeep: ['Preserve & share knowledge', 'Open-source & independent', 'Private by design'],
memoro: ['Voice becomes knowledge', 'Open-source & independent', 'Private by design'],
};
interface Props {

View file

@ -5,6 +5,7 @@ export { default as ForgotPasswordPage } from './pages/ForgotPasswordPage.svelte
// Components
export { default as GuestWelcomeModal } from './components/GuestWelcomeModal.svelte';
export { default as GuestRegistrationNudge } from './components/GuestRegistrationNudge.svelte';
export { default as AuthGateModal } from './components/AuthGateModal.svelte';
export { default as SessionExpiredBanner } from './components/SessionExpiredBanner.svelte';
export { default as AuthGate } from './components/AuthGate.svelte';
@ -23,6 +24,13 @@ export {
resetGuestWelcome,
resetAllGuestWelcome,
} from './utils/guestWelcome';
export {
startGuestSession,
shouldShowGuestNudge,
dismissGuestNudge,
resetGuestNudge,
resetAllGuestNudges,
} from './utils/guestNudge';
export { parseUserAgent, getDeviceType, formatUserAgent } from './utils/userAgent';
// Types

View file

@ -0,0 +1,69 @@
/**
* Utility functions for managing guest registration nudge state.
* Shows a nudge after X minutes of guest usage to encourage sign-up.
*/
const SESSION_PREFIX = 'guest-nudge-session';
const DISMISSED_PREFIX = 'guest-nudge-dismissed';
/**
* Record the start of a guest session for an app (call once on mount).
* Only sets the timestamp if one doesn't already exist.
*/
export function startGuestSession(appId: string): void {
if (typeof localStorage === 'undefined') return;
const key = `${SESSION_PREFIX}-${appId}`;
if (!localStorage.getItem(key)) {
localStorage.setItem(key, Date.now().toString());
}
}
/**
* Check if enough time has passed to show the registration nudge.
* Returns false if already dismissed or not enough time elapsed.
*/
export function shouldShowGuestNudge(appId: string, delayMinutes = 5): boolean {
if (typeof localStorage === 'undefined') return false;
// Already dismissed?
if (localStorage.getItem(`${DISMISSED_PREFIX}-${appId}`) === 'true') return false;
// Check elapsed time
const sessionStart = localStorage.getItem(`${SESSION_PREFIX}-${appId}`);
if (!sessionStart) return false;
const elapsed = Date.now() - parseInt(sessionStart, 10);
return elapsed >= delayMinutes * 60 * 1000;
}
/**
* Permanently dismiss the nudge for an app.
*/
export function dismissGuestNudge(appId: string): void {
if (typeof localStorage === 'undefined') return;
localStorage.setItem(`${DISMISSED_PREFIX}-${appId}`, 'true');
}
/**
* Reset nudge state for an app (will show again after delay).
*/
export function resetGuestNudge(appId: string): void {
if (typeof localStorage === 'undefined') return;
localStorage.removeItem(`${SESSION_PREFIX}-${appId}`);
localStorage.removeItem(`${DISMISSED_PREFIX}-${appId}`);
}
/**
* Reset nudge state for all apps.
*/
export function resetAllGuestNudges(): void {
if (typeof localStorage === 'undefined') return;
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(SESSION_PREFIX) || key?.startsWith(DISMISSED_PREFIX)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
}