mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 01:19:40 +02:00
✨ 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:
parent
51ec8f8419
commit
6d0d9d4f67
4 changed files with 396 additions and 15 deletions
118
apps/questions/apps/web/src/lib/data/demo-questions.ts
Normal file
118
apps/questions/apps/web/src/lib/data/demo-questions.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue