feat(contacts): add session-first guest mode

Users can now use Contacts without signing in.
Data is stored in sessionStorage (lost when tab closes).

Changes:
- Add session-contacts.svelte.ts for temporary local storage
- Add AuthGateModal for login prompts
- Remove auth redirect from app layout
- Add guest mode banner with contact count
- Add sessionStorage return URL handling in login/register

When users sign in, session contacts are migrated to their cloud account.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-25 00:00:09 +01:00
parent 54a6ebc073
commit 753e6fd17f
5 changed files with 545 additions and 28 deletions

View file

@ -0,0 +1,167 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { sessionContactsStore } from '$lib/stores/session-contacts.svelte';
interface Props {
visible: boolean;
onClose: () => void;
action?: 'save' | 'sync' | 'feature';
featureName?: string;
}
let { visible, onClose, action = 'save', featureName = '' }: Props = $props();
// Action-specific messages
const messages = {
save: {
title: 'Anmelden um zu speichern',
description:
'Melde dich an, um deine Kontakte in der Cloud zu speichern und auf allen Geräten zu synchronisieren.',
icon: 'cloud',
},
sync: {
title: 'Anmelden für Cloud-Sync',
description:
'Mit einem Account werden deine Kontakte automatisch synchronisiert und bleiben erhalten.',
icon: 'refresh-cw',
},
feature: {
title: `Anmelden für ${featureName}`,
description: `Diese Funktion erfordert ein Konto. Melde dich an, um ${featureName} zu nutzen.`,
icon: 'lock',
},
};
let currentMessage = $derived(messages[action]);
let sessionContactCount = $derived(sessionContactsStore.count);
function handleLogin() {
// Store return URL for redirect after login
if (typeof sessionStorage !== 'undefined') {
sessionStorage.setItem('auth-return-url', window.location.pathname);
}
goto('/login');
}
function handleRegister() {
if (typeof sessionStorage !== 'undefined') {
sessionStorage.setItem('auth-return-url', window.location.pathname);
}
goto('/register');
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if visible}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onclick={handleBackdropClick}
>
<div
class="bg-card border-border mx-4 w-full max-w-md rounded-xl border p-6 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="auth-gate-title"
>
<!-- Icon -->
<div
class="bg-primary/10 mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full"
>
{#if currentMessage.icon === 'cloud'}
<svg class="text-primary h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
{:else if currentMessage.icon === 'refresh-cw'}
<svg class="text-primary h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
{:else}
<svg class="text-primary h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
{/if}
</div>
<!-- Title -->
<h2 id="auth-gate-title" class="mb-2 text-center text-xl font-semibold">
{currentMessage.title}
</h2>
<!-- Description -->
<p class="text-muted-foreground mb-6 text-center text-sm">
{currentMessage.description}
</p>
<!-- Session contacts info -->
{#if sessionContactCount > 0}
<div class="bg-muted/50 mb-6 rounded-lg p-3 text-center text-sm">
<span class="text-muted-foreground">
Du hast <strong class="text-foreground">{sessionContactCount}</strong>
{sessionContactCount === 1 ? 'Kontakt' : 'Kontakte'} in dieser Sitzung erstellt.
</span>
<br />
<span class="text-muted-foreground text-xs">
Diese werden nach der Anmeldung in deinen Account übernommen.
</span>
</div>
{/if}
<!-- Buttons -->
<div class="flex flex-col gap-3">
<button
onclick={handleLogin}
class="bg-primary text-primary-foreground hover:bg-primary/90 w-full rounded-lg px-4 py-3 font-medium transition-colors"
>
Anmelden
</button>
<button
onclick={handleRegister}
class="bg-secondary text-secondary-foreground hover:bg-secondary/80 w-full rounded-lg px-4 py-3 font-medium transition-colors"
>
Kostenloses Konto erstellen
</button>
<button
onclick={onClose}
class="text-muted-foreground hover:text-foreground w-full py-2 text-sm transition-colors"
>
Später
</button>
</div>
<!-- Info text -->
<p class="text-muted-foreground mt-4 text-center text-xs">
Du kannst weiterhin Kontakte erstellen. Diese werden lokal gespeichert und gehen beim
Schließen des Tabs verloren.
</p>
</div>
</div>
{/if}

View file

@ -0,0 +1,235 @@
/**
* Session Contacts Store - Temporary local contacts for guest users
* Contacts are stored in sessionStorage and lost when the browser tab is closed
*/
import type { Contact } from '$lib/api/contacts';
import { browser } from '$app/environment';
const STORAGE_KEY = 'contacts-session-contacts';
// Generate a unique ID for session contacts
function generateSessionId(): string {
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
// Load contacts from sessionStorage
function loadFromStorage(): Contact[] {
if (!browser) return [];
try {
const stored = sessionStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
// Save contacts to sessionStorage
function saveToStorage(contacts: Contact[]) {
if (!browser) return;
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(contacts));
} catch (e) {
console.warn('Failed to save session contacts:', e);
}
}
// State
let contacts = $state<Contact[]>(loadFromStorage());
export const sessionContactsStore = {
get contacts() {
return contacts;
},
get hasContacts() {
return contacts.length > 0;
},
/**
* Initialize from sessionStorage (call on mount)
*/
initialize() {
contacts = loadFromStorage();
},
/**
* Create a new session contact
*/
createContact(data: Partial<Contact>): Contact {
const now = new Date().toISOString();
const newContact: Contact = {
id: generateSessionId(),
userId: 'guest',
firstName: data.firstName || null,
lastName: data.lastName || null,
displayName: data.displayName || null,
nickname: data.nickname || null,
email: data.email || null,
phone: data.phone || null,
mobile: data.mobile || null,
street: data.street || null,
city: data.city || null,
postalCode: data.postalCode || null,
country: data.country || null,
company: data.company || null,
jobTitle: data.jobTitle || null,
department: data.department || null,
website: data.website || null,
birthday: data.birthday || null,
notes: data.notes || null,
photoUrl: data.photoUrl || null,
customDates: data.customDates || null,
linkedin: data.linkedin || null,
twitter: data.twitter || null,
facebook: data.facebook || null,
instagram: data.instagram || null,
xing: data.xing || null,
github: data.github || null,
youtube: data.youtube || null,
tiktok: data.tiktok || null,
telegram: data.telegram || null,
whatsapp: data.whatsapp || null,
signal: data.signal || null,
discord: data.discord || null,
bluesky: data.bluesky || null,
tags: [],
isFavorite: data.isFavorite || false,
isArchived: data.isArchived || false,
organizationId: null,
teamId: null,
visibility: 'private',
createdAt: now,
updatedAt: now,
};
contacts = [...contacts, newContact];
saveToStorage(contacts);
return newContact;
},
/**
* Update a session contact
*/
updateContact(id: string, data: Partial<Contact>): Contact | null {
const index = contacts.findIndex((c) => c.id === id);
if (index === -1) return null;
const updatedContact = {
...contacts[index],
...data,
updatedAt: new Date().toISOString(),
};
contacts = contacts.map((c) => (c.id === id ? updatedContact : c));
saveToStorage(contacts);
return updatedContact;
},
/**
* Toggle favorite status
*/
toggleFavorite(id: string): Contact | null {
const contact = contacts.find((c) => c.id === id);
if (!contact) return null;
return this.updateContact(id, { isFavorite: !contact.isFavorite });
},
/**
* Toggle archive status
*/
toggleArchive(id: string): Contact | null {
const contact = contacts.find((c) => c.id === id);
if (!contact) return null;
return this.updateContact(id, { isArchived: !contact.isArchived });
},
/**
* Delete a session contact
*/
deleteContact(id: string): boolean {
const hadContact = contacts.some((c) => c.id === id);
contacts = contacts.filter((c) => c.id !== id);
saveToStorage(contacts);
return hadContact;
},
/**
* Get contact by ID
*/
getById(id: string): Contact | undefined {
return contacts.find((c) => c.id === id);
},
/**
* Check if a contact ID is a session contact
*/
isSessionContact(id: string): boolean {
return id.startsWith('session_');
},
/**
* Get all contacts (for migration to cloud on login)
*/
getAllContacts(): Contact[] {
return [...contacts];
},
/**
* Clear all session contacts (after migration or on explicit clear)
*/
clear() {
contacts = [];
if (browser) {
sessionStorage.removeItem(STORAGE_KEY);
}
},
/**
* Get count of session contacts
*/
get count() {
return contacts.length;
},
/**
* Get favorite contacts
*/
get favoriteContacts() {
return contacts.filter((c) => c.isFavorite && !c.isArchived);
},
/**
* Get archived contacts
*/
get archivedContacts() {
return contacts.filter((c) => c.isArchived);
},
/**
* Get active (non-archived) contacts
*/
get activeContacts() {
return contacts.filter((c) => !c.isArchived);
},
/**
* Search session contacts
*/
search(query: string): Contact[] {
if (!query.trim()) return this.activeContacts;
const lower = query.toLowerCase();
return this.activeContacts.filter((c) => {
const searchFields = [
c.firstName,
c.lastName,
c.displayName,
c.email,
c.phone,
c.mobile,
c.company,
];
return searchFields.some((field) => field?.toLowerCase().includes(lower));
});
},
};

View file

@ -46,6 +46,8 @@
formatParsedContactPreview,
} from '$lib/utils/contact-parser';
import ContactsToolbar from '$lib/components/ContactsToolbar.svelte';
import AuthGateModal from '$lib/components/AuthGateModal.svelte';
import { sessionContactsStore } from '$lib/stores/session-contacts.svelte';
// Tags state for Quick-Create
let availableTags = $state<{ id: string; name: string }[]>([]);
@ -211,6 +213,19 @@
goto('/login');
}
// Auth gate modal state
let showAuthGateModal = $state(false);
let authGateAction = $state<'save' | 'sync' | 'feature'>('save');
// Show auth gate modal (can be called from child components)
function showAuthGate(action: 'save' | 'sync' | 'feature' = 'save') {
authGateAction = action;
showAuthGateModal = true;
}
// Session contacts indicator
let sessionContactCount = $derived(sessionContactsStore.count);
async function handleCloseContactModal() {
// Refresh contacts list in case something was changed
await contactsStore.loadContacts();
@ -271,43 +286,68 @@
}
onMount(async () => {
// Redirect to login if not authenticated
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
// Initialize split-panel from URL/localStorage
splitPanel.initialize();
// Load user settings and tags
await userSettings.load();
// Load tags for Quick-Create
try {
const tagsResult = await tagsApi.list();
availableTags = (tagsResult.tags || []).map((t) => ({ id: t.id, name: t.name }));
} catch (e) {
console.error('Failed to load tags:', e);
}
// Initialize contacts settings, view mode, and filter store
contactsSettings.initialize();
viewModeStore.initialize();
contactsFilterStore.initialize();
// Initialize session contacts for guest mode
sessionContactsStore.initialize();
// Only fetch user data if authenticated
if (authStore.isAuthenticated) {
// Load user settings and tags
await userSettings.load();
// Load tags for Quick-Create
try {
const tagsResult = await tagsApi.list();
availableTags = (tagsResult.tags || []).map((t) => ({ id: t.id, name: t.name }));
} catch (e) {
console.error('Failed to load tags:', e);
}
// Check for session contacts to migrate after login
if (sessionContactsStore.hasContacts) {
// Migrate session contacts to cloud
const sessionContacts = sessionContactsStore.getAllContacts();
for (const contact of sessionContacts) {
try {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id, userId, createdAt, updatedAt, ...contactData } = contact;
await contactsStore.createContact(contactData);
} catch (e) {
console.error('Failed to migrate session contact:', e);
}
}
// Clear session contacts after migration
sessionContactsStore.clear();
}
}
// Initialize sidebar mode from localStorage
const savedSidebar = localStorage.getItem('contacts-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
sidebarModeStore.set(true);
try {
const savedSidebar = localStorage?.getItem('contacts-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
sidebarModeStore.set(true);
}
} catch {
// localStorage not available (private browsing, quota exceeded, etc.)
}
// Initialize collapsed state from localStorage
const savedCollapsed = localStorage.getItem('contacts-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
collapsedStore.set(true);
try {
const savedCollapsed = localStorage?.getItem('contacts-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
collapsedStore.set(true);
}
} catch {
// localStorage not available
}
});
</script>
@ -317,6 +357,38 @@
<SplitPaneContainer>
<!-- Navigation Layout -->
<div class="layout-container">
<!-- Guest Mode Banner -->
{#if !authStore.isAuthenticated}
<div
class="guest-banner bg-primary/10 border-primary/20 fixed top-0 right-0 left-0 z-50 flex items-center justify-between border-b px-4 py-2"
>
<div class="flex items-center gap-2 text-sm">
<svg class="text-primary h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-foreground">
<strong>Gast-Modus</strong>
{#if sessionContactCount > 0}
- {sessionContactCount}
{sessionContactCount === 1 ? 'Kontakt' : 'Kontakte'} lokal gespeichert
{:else}
- Kontakte werden nur in diesem Tab gespeichert
{/if}
</span>
</div>
<button
onclick={() => showAuthGate('sync')}
class="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-3 py-1 text-sm font-medium transition-colors"
>
Anmelden
</button>
</div>
{/if}
<!-- UI Elements (hidden in immersive mode) -->
{#if !contactsSettings.immersiveModeEnabled}
<!-- Floating/Sidebar Pill Navigation (at bottom) -->
@ -407,7 +479,25 @@
</div>
</SplitPaneContainer>
<!-- Auth Gate Modal -->
<AuthGateModal
visible={showAuthGateModal}
onClose={() => (showAuthGateModal = false)}
action={authGateAction}
/>
<style>
/* Guest banner styling */
.guest-banner {
height: 40px;
min-height: 40px;
}
/* Offset content when guest banner is visible */
.layout-container:has(.guest-banner) .main-content {
padding-top: 40px;
}
.layout-container {
display: flex;
flex-direction: column;

View file

@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { locale } from 'svelte-i18n';
import { onMount } from 'svelte';
import { LoginPage } from '@manacore/shared-auth-ui';
import { getLoginTranslations } from '@manacore/shared-i18n';
import { ContactsLogo } from '@manacore/shared-branding';
@ -10,8 +11,20 @@
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import '$lib/i18n';
// Get redirect URL from query params
const redirectTo = $derived($page.url.searchParams.get('redirectTo') || '/');
// Get redirect URL from query params or sessionStorage
let redirectTo = $state('/');
onMount(() => {
// Check sessionStorage first (set by AuthGateModal)
const storedReturnUrl = sessionStorage.getItem('auth-return-url');
if (storedReturnUrl) {
redirectTo = storedReturnUrl;
sessionStorage.removeItem('auth-return-url');
} else {
// Fall back to query params
redirectTo = $page.url.searchParams.get('redirectTo') || '/';
}
});
// Get translations based on current locale
const translations = $derived(getLoginTranslations($locale || 'de'));

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { onMount } from 'svelte';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { getRegisterTranslations } from '@manacore/shared-i18n';
import { ContactsLogo } from '@manacore/shared-branding';
@ -9,6 +10,17 @@
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import '$lib/i18n';
// Get redirect URL from sessionStorage (set by AuthGateModal)
let redirectTo = $state('/');
onMount(() => {
const storedReturnUrl = sessionStorage.getItem('auth-return-url');
if (storedReturnUrl) {
redirectTo = storedReturnUrl;
sessionStorage.removeItem('auth-return-url');
}
});
const translations = $derived(getRegisterTranslations($locale || 'de'));
async function handleSignUp(email: string, password: string) {
@ -26,7 +38,7 @@
primaryColor="#3b82f6"
onSignUp={handleSignUp}
{goto}
successRedirect="/"
successRedirect={redirectTo}
loginPath="/login"
lightBackground="#eff6ff"
darkBackground="#1e293b"