From 1570cc0bb49cb04c013662aa7d9e351f61e21cd5 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 1 Apr 2026 12:16:55 +0200 Subject: [PATCH] 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) --- .../apps/web/src/lib/data/guest-seed.ts | 82 ++++++- .../apps/web/src/lib/data/local-store.ts | 3 +- .../apps/web/src/routes/(app)/+layout.svelte | 14 +- .../apps/web/src/lib/data/guest-seed.ts | 18 +- .../components/GuestRegistrationNudge.svelte | 225 ++++++++++++++++++ .../src/components/GuestWelcomeModal.svelte | 40 ++++ packages/shared-auth-ui/src/index.ts | 8 + .../shared-auth-ui/src/utils/guestNudge.ts | 69 ++++++ 8 files changed, 439 insertions(+), 20 deletions(-) create mode 100644 packages/shared-auth-ui/src/components/GuestRegistrationNudge.svelte create mode 100644 packages/shared-auth-ui/src/utils/guestNudge.ts diff --git a/apps/skilltree/apps/web/src/lib/data/guest-seed.ts b/apps/skilltree/apps/web/src/lib/data/guest-seed.ts index 33f5d7ef2..8dca1792e 100644 --- a/apps/skilltree/apps/web/src/lib/data/guest-seed.ts +++ b/apps/skilltree/apps/web/src/lib/data/guest-seed.ts @@ -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(), }, ]; diff --git a/apps/skilltree/apps/web/src/lib/data/local-store.ts b/apps/skilltree/apps/web/src/lib/data/local-store.ts index 55ae43126..1381ecdcf 100644 --- a/apps/skilltree/apps/web/src/lib/data/local-store.ts +++ b/apps/skilltree/apps/web/src/lib/data/local-store.ts @@ -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: { diff --git a/apps/todo/apps/web/src/routes/(app)/+layout.svelte b/apps/todo/apps/web/src/routes/(app)/+layout.svelte index 5ac610c45..95a74915c 100644 --- a/apps/todo/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/todo/apps/web/src/routes/(app)/+layout.svelte @@ -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} + {:else} + goto('/register')} + locale={($locale || 'de') === 'de' ? 'de' : 'en'} + delayMinutes={3} + /> {/if} diff --git a/apps/zitare/apps/web/src/lib/data/guest-seed.ts b/apps/zitare/apps/web/src/lib/data/guest-seed.ts index a7a5e6805..479b9d159 100644 --- a/apps/zitare/apps/web/src/lib/data/guest-seed.ts +++ b/apps/zitare/apps/web/src/lib/data/guest-seed.ts @@ -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'], }, ]; diff --git a/packages/shared-auth-ui/src/components/GuestRegistrationNudge.svelte b/packages/shared-auth-ui/src/components/GuestRegistrationNudge.svelte new file mode 100644 index 000000000..e06cd3b05 --- /dev/null +++ b/packages/shared-auth-ui/src/components/GuestRegistrationNudge.svelte @@ -0,0 +1,225 @@ + + +{#if visible} +
+
+

{texts.message}

+
+ + +
+
+
+{/if} + + diff --git a/packages/shared-auth-ui/src/components/GuestWelcomeModal.svelte b/packages/shared-auth-ui/src/components/GuestWelcomeModal.svelte index 590d44772..79ec2f077 100644 --- a/packages/shared-auth-ui/src/components/GuestWelcomeModal.svelte +++ b/packages/shared-auth-ui/src/components/GuestWelcomeModal.svelte @@ -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 { diff --git a/packages/shared-auth-ui/src/index.ts b/packages/shared-auth-ui/src/index.ts index 9e7000db2..cc159c6e0 100644 --- a/packages/shared-auth-ui/src/index.ts +++ b/packages/shared-auth-ui/src/index.ts @@ -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 diff --git a/packages/shared-auth-ui/src/utils/guestNudge.ts b/packages/shared-auth-ui/src/utils/guestNudge.ts new file mode 100644 index 000000000..6c5b1e9b5 --- /dev/null +++ b/packages/shared-auth-ui/src/utils/guestNudge.ts @@ -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)); +}