feat(questions): add demo mode for unauthenticated users

- Add demo-questions.ts with sample questions and collection
- Update questionsStore to show demo data when not authenticated
- Update collectionsStore with demo mode support
- Add demo banner and GuestWelcomeModal to layout
- Block create/update/delete operations in demo mode
This commit is contained in:
Till-JS 2026-01-29 13:57:20 +01:00
parent 51ec8f8419
commit 6d0d9d4f67
4 changed files with 396 additions and 15 deletions

View file

@ -0,0 +1,118 @@
/**
* Demo Questions - Static sample questions for unauthenticated users
*
* Shows a realistic set of research questions to demonstrate
* the app's capabilities without requiring login.
*/
import type { Question, Collection } from '$lib/types';
/**
* Demo collection for sample questions
*/
export const DEMO_COLLECTION: Collection = {
id: 'demo-collection',
userId: 'demo',
name: 'Tech Research',
description: 'Sample technology research questions',
color: '#8b5cf6',
icon: 'folder',
isDefault: true,
sortOrder: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
questionCount: 5,
};
/**
* Generate demo questions for unauthenticated users
*/
export function generateDemoQuestions(): Question[] {
const now = new Date();
return [
{
id: 'demo_1',
userId: 'demo',
collectionId: DEMO_COLLECTION.id,
title: 'What are the key differences between React Server Components and traditional SSR?',
description:
'I want to understand how RSC differs from traditional server-side rendering approaches and when to use each.',
status: 'answered',
priority: 'high',
tags: ['react', 'ssr', 'web-development'],
researchDepth: 'deep',
createdAt: new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000).toISOString(),
},
{
id: 'demo_2',
userId: 'demo',
collectionId: DEMO_COLLECTION.id,
title: 'How does vector database similarity search work under the hood?',
description:
'Exploring the algorithms and data structures used in vector databases for semantic search.',
status: 'researching',
priority: 'normal',
tags: ['ai', 'databases', 'embeddings'],
researchDepth: 'standard',
createdAt: new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(now.getTime() - 12 * 60 * 60 * 1000).toISOString(),
},
{
id: 'demo_3',
userId: 'demo',
collectionId: DEMO_COLLECTION.id,
title: 'What are best practices for implementing OAuth 2.0 with PKCE in mobile apps?',
description: 'Security considerations and implementation patterns for mobile authentication.',
status: 'open',
priority: 'urgent',
tags: ['security', 'oauth', 'mobile'],
researchDepth: 'deep',
createdAt: new Date(now.getTime() - 6 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(now.getTime() - 6 * 60 * 60 * 1000).toISOString(),
},
{
id: 'demo_4',
userId: 'demo',
collectionId: DEMO_COLLECTION.id,
title: 'How do transformer models handle context length limitations?',
description:
'Understanding techniques like sliding window attention, sparse attention, and context compression.',
status: 'answered',
priority: 'normal',
tags: ['ai', 'transformers', 'llm'],
researchDepth: 'standard',
createdAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(),
},
{
id: 'demo_5',
userId: 'demo',
collectionId: DEMO_COLLECTION.id,
title: 'What are the trade-offs between monorepo and polyrepo architectures?',
description:
'Comparing build systems, dependency management, and team collaboration patterns.',
status: 'open',
priority: 'low',
tags: ['architecture', 'devops', 'tooling'],
researchDepth: 'quick',
createdAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(),
},
];
}
/**
* Check if an ID belongs to a demo question
*/
export function isDemoQuestion(id: string): boolean {
return id.startsWith('demo_');
}
/**
* Check if an ID belongs to the demo collection
*/
export function isDemoCollection(id: string): boolean {
return id === DEMO_COLLECTION.id;
}

View file

@ -1,9 +1,13 @@
/**
* Collections Store - Manages collections state using Svelte 5 runes
* Authenticated users: collections from API
* Demo mode: static sample collection to showcase the app
*/
import { collectionsApi } from '$lib/api/collections';
import type { Collection, CreateCollectionDto, UpdateCollectionDto } from '$lib/types';
import { authStore } from './auth.svelte';
import { DEMO_COLLECTION, isDemoCollection } from '$lib/data/demo-questions';
let collections = $state<Collection[]>([]);
let loading = $state(false);
@ -27,10 +31,23 @@ export const collectionsStore = {
return selectedId ? collections.find((c) => c.id === selectedId) : null;
},
/**
* Load collections
* Demo mode: shows static sample collection
* Authenticated: fetches from API
*/
async load() {
loading = true;
error = null;
// Demo mode: load demo collection
if (!authStore.isAuthenticated) {
collections = [DEMO_COLLECTION];
loading = false;
return;
}
// Authenticated: fetch from API
try {
collections = await collectionsApi.getAll();
} catch (e) {
@ -41,7 +58,18 @@ export const collectionsStore = {
}
},
/**
* Create a new collection
* Demo mode: returns auth_required error
* Authenticated: creates via API
*/
async create(data: CreateCollectionDto): Promise<Collection | null> {
// Demo mode: require authentication
if (!authStore.isAuthenticated) {
error = 'Login required to create collections';
return null;
}
loading = true;
error = null;
@ -57,7 +85,18 @@ export const collectionsStore = {
}
},
/**
* Update a collection
* Demo mode: returns auth_required error
* Authenticated: updates via API
*/
async update(id: string, data: UpdateCollectionDto): Promise<Collection | null> {
// Demo collection or not authenticated: require authentication
if (isDemoCollection(id) || !authStore.isAuthenticated) {
error = 'Login required to update collections';
return null;
}
error = null;
try {
@ -70,7 +109,18 @@ export const collectionsStore = {
}
},
/**
* Delete a collection
* Demo mode: returns auth_required error
* Authenticated: deletes via API
*/
async delete(id: string): Promise<boolean> {
// Demo collection or not authenticated: require authentication
if (isDemoCollection(id) || !authStore.isAuthenticated) {
error = 'Login required to delete collections';
return false;
}
error = null;
try {
@ -86,7 +136,18 @@ export const collectionsStore = {
}
},
/**
* Reorder collections
* Demo mode: returns auth_required error
* Authenticated: reorders via API
*/
async reorder(orderedIds: string[]): Promise<boolean> {
// Demo mode: require authentication
if (!authStore.isAuthenticated) {
error = 'Login required to reorder collections';
return false;
}
error = null;
try {
@ -111,6 +172,13 @@ export const collectionsStore = {
return collections.find((c) => c.id === id);
},
/**
* Check if a collection is a demo collection
*/
isDemoCollection(id: string): boolean {
return isDemoCollection(id);
},
clear() {
collections = [];
error = null;

View file

@ -1,9 +1,13 @@
/**
* Questions Store - Manages questions state using Svelte 5 runes
* Authenticated users: questions from API
* Demo mode: static sample questions to showcase the app
*/
import { questionsApi, type QuestionFilters } from '$lib/api/questions';
import type { Question, CreateQuestionDto, UpdateQuestionDto } from '$lib/types';
import { authStore } from './auth.svelte';
import { generateDemoQuestions, isDemoQuestion } from '$lib/data/demo-questions';
let questions = $state<Question[]>([]);
let loading = $state(false);
@ -28,11 +32,46 @@ export const questionsStore = {
return currentFilters;
},
/**
* Load questions
* Demo mode: shows static sample questions
* Authenticated: fetches from API
*/
async load(filters?: QuestionFilters) {
loading = true;
error = null;
currentFilters = filters || {};
// Demo mode: load demo questions
if (!authStore.isAuthenticated) {
let demoQuestions = generateDemoQuestions();
// Apply filters
if (filters?.collectionId) {
demoQuestions = demoQuestions.filter(
(q: Question) => q.collectionId === filters.collectionId
);
}
if (filters?.status) {
demoQuestions = demoQuestions.filter((q: Question) => q.status === filters.status);
}
if (filters?.search) {
const search = filters.search.toLowerCase();
demoQuestions = demoQuestions.filter(
(q: Question) =>
q.title.toLowerCase().includes(search) ||
q.description?.toLowerCase().includes(search) ||
q.tags?.some((t: string) => t.toLowerCase().includes(search))
);
}
questions = demoQuestions;
total = demoQuestions.length;
loading = false;
return;
}
// Authenticated: fetch from API
try {
const response = await questionsApi.getAll(filters);
questions = response.data;
@ -46,7 +85,18 @@ export const questionsStore = {
}
},
/**
* Create a new question
* Demo mode: returns auth_required error
* Authenticated: creates via API
*/
async create(data: CreateQuestionDto): Promise<Question | null> {
// Demo mode: require authentication
if (!authStore.isAuthenticated) {
error = 'Login required to create questions';
return null;
}
loading = true;
error = null;
@ -63,7 +113,18 @@ export const questionsStore = {
}
},
/**
* Update a question
* Demo mode: returns auth_required error
* Authenticated: updates via API
*/
async update(id: string, data: UpdateQuestionDto): Promise<Question | null> {
// Demo question or not authenticated: require authentication
if (isDemoQuestion(id) || !authStore.isAuthenticated) {
error = 'Login required to update questions';
return null;
}
error = null;
try {
@ -76,7 +137,18 @@ export const questionsStore = {
}
},
/**
* Delete a question
* Demo mode: returns auth_required error
* Authenticated: deletes via API
*/
async delete(id: string): Promise<boolean> {
// Demo question or not authenticated: require authentication
if (isDemoQuestion(id) || !authStore.isAuthenticated) {
error = 'Login required to delete questions';
return false;
}
error = null;
try {
@ -90,7 +162,18 @@ export const questionsStore = {
}
},
/**
* Update question status
* Demo mode: returns auth_required error
* Authenticated: updates via API
*/
async updateStatus(id: string, status: string): Promise<Question | null> {
// Demo question or not authenticated: require authentication
if (isDemoQuestion(id) || !authStore.isAuthenticated) {
error = 'Login required to update question status';
return null;
}
error = null;
try {
@ -107,6 +190,13 @@ export const questionsStore = {
return questions.find((q) => q.id === id);
},
/**
* Check if a question is a demo question
*/
isDemoQuestion(id: string): boolean {
return isDemoQuestion(id);
},
clear() {
questions = [];
total = 0;

View file

@ -15,6 +15,7 @@
CreatePreview,
} from '@manacore/shared-ui';
import { getPillAppItems } from '@manacore/shared-branding';
import { GuestWelcomeModal, shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
let { children } = $props();
@ -40,16 +41,22 @@
// User email for nav
let userEmail = $derived(authStore.user?.email || 'Menu');
// Guest welcome modal state
let showGuestWelcome = $state(false);
onMount(async () => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
// Set API token if authenticated
if (authStore.isAuthenticated) {
const token = await authStore.getValidToken();
apiClient.setAccessToken(token);
} else {
// Show guest welcome modal for unauthenticated users
if (shouldShowGuestWelcome('questions')) {
showGuestWelcome = true;
}
}
const token = await authStore.getValidToken();
apiClient.setAccessToken(token);
// Load initial data
// Load initial data (works in both guest and authenticated mode)
await collectionsStore.load();
await questionsStore.load();
@ -57,14 +64,16 @@
updateMobileState();
// Restore nav mode from localStorage
const savedSidebar = localStorage.getItem('questions-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
}
if (browser) {
const savedSidebar = localStorage.getItem('questions-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
}
const savedCollapsed = localStorage.getItem('questions-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
const savedCollapsed = localStorage.getItem('questions-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
}
}
});
@ -96,6 +105,17 @@
async function handleSearch(query: string): Promise<QuickInputItem[]> {
if (!query.trim()) return [];
// Demo mode: search from store
if (!authStore.isAuthenticated) {
await questionsStore.load({ search: query });
return questionsStore.questions.slice(0, 10).map((q) => ({
id: q.id,
title: q.title,
subtitle: q.status || 'pending',
}));
}
// Authenticated: search via API
try {
const response = await questionsApi.getAll({ search: query, limit: 10 });
return response.data.map((q) => ({
@ -126,6 +146,12 @@
async function handleCreate(query: string): Promise<void> {
if (!query.trim()) return;
// Demo mode: show login prompt
if (!authStore.isAuthenticated) {
showGuestWelcome = true;
return;
}
const question = await questionsStore.create({
title: query,
collectionId: collectionsStore.selectedId || undefined,
@ -176,11 +202,54 @@
{ href: '/collections', label: 'Collections', icon: 'folder' },
{ href: '/settings', label: 'Settings', icon: 'settings' },
]);
// Guest features for welcome modal
const guestFeatures = [
'Browse sample research questions',
'Explore the app interface',
'See how AI research works',
];
</script>
<svelte:window onresize={updateMobileState} />
<div class="layout-container">
<!-- Demo Mode Banner -->
{#if !authStore.isAuthenticated}
<div
class="guest-banner fixed left-0 right-0 top-0 z-50 flex items-center justify-between border-b border-primary/20 bg-primary/10 px-4 py-2"
>
<div class="flex items-center gap-2 text-sm">
<svg class="h-4 w-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
<span class="text-foreground">
<strong>Demo Mode</strong>
<span class="hidden text-muted-foreground sm:inline">
- Sample questions to explore
</span>
</span>
</div>
<button
onclick={() => goto('/login')}
class="rounded-md bg-primary px-3 py-1 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Sign In
</button>
</div>
{/if}
<!-- Navigation -->
<PillNavigation
items={navItems}
@ -220,13 +289,35 @@
/>
<!-- Main Content -->
<main class="main-content bg-background" class:sidebar-mode={isSidebarMode && !isCollapsed}>
<main
class="main-content bg-background"
class:sidebar-mode={isSidebarMode && !isCollapsed}
class:has-banner={!authStore.isAuthenticated}
>
<div class="content-wrapper">
{@render children()}
</div>
</main>
</div>
<!-- Guest Welcome Modal -->
<GuestWelcomeModal
appId="questions"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => {
showGuestWelcome = false;
goto('/login');
}}
onRegister={() => {
showGuestWelcome = false;
goto('/register');
}}
helpHref="/help"
locale="en"
features={guestFeatures}
/>
<style>
.layout-container {
display: flex;
@ -235,6 +326,12 @@
overflow: hidden;
}
/* Guest banner styling */
.guest-banner {
height: 40px;
min-height: 40px;
}
.main-content {
flex: 1;
display: flex;
@ -244,6 +341,10 @@
transition: all 300ms ease;
}
.main-content.has-banner {
padding-top: 40px;
}
.main-content.sidebar-mode {
padding-left: 180px;
padding-bottom: 0;
@ -272,6 +373,10 @@
padding-bottom: calc(150px + env(safe-area-inset-bottom));
}
.main-content.has-banner {
padding-top: 40px;
}
.content-wrapper {
padding: 0.75rem;
}