From 753e6fd17f79646db720c5d9c2a0c55ed8b35816 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 25 Jan 2026 00:00:09 +0100 Subject: [PATCH] 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 --- .../src/lib/components/AuthGateModal.svelte | 167 +++++++++++++ .../src/lib/stores/session-contacts.svelte.ts | 235 ++++++++++++++++++ .../apps/web/src/routes/(app)/+layout.svelte | 140 +++++++++-- .../web/src/routes/(auth)/login/+page.svelte | 17 +- .../src/routes/(auth)/register/+page.svelte | 14 +- 5 files changed, 545 insertions(+), 28 deletions(-) create mode 100644 apps/contacts/apps/web/src/lib/components/AuthGateModal.svelte create mode 100644 apps/contacts/apps/web/src/lib/stores/session-contacts.svelte.ts diff --git a/apps/contacts/apps/web/src/lib/components/AuthGateModal.svelte b/apps/contacts/apps/web/src/lib/components/AuthGateModal.svelte new file mode 100644 index 000000000..9ec55e3f7 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/AuthGateModal.svelte @@ -0,0 +1,167 @@ + + + + +{#if visible} + +
+ +
+{/if} diff --git a/apps/contacts/apps/web/src/lib/stores/session-contacts.svelte.ts b/apps/contacts/apps/web/src/lib/stores/session-contacts.svelte.ts new file mode 100644 index 000000000..0a4a91a26 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/stores/session-contacts.svelte.ts @@ -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(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 { + 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 | 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)); + }); + }, +}; diff --git a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte index d21dd6fc7..c5df82450 100644 --- a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte @@ -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 } }); @@ -317,6 +357,38 @@
+ + {#if !authStore.isAuthenticated} +
+
+ + + + + Gast-Modus + {#if sessionContactCount > 0} + - {sessionContactCount} + {sessionContactCount === 1 ? 'Kontakt' : 'Kontakte'} lokal gespeichert + {:else} + - Kontakte werden nur in diesem Tab gespeichert + {/if} + +
+ +
+ {/if} {#if !contactsSettings.immersiveModeEnabled} @@ -407,7 +479,25 @@
+ + (showAuthGateModal = false)} + action={authGateAction} +/> +