mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 10:46:41 +02:00
✨ feat(manacore): add avatar upload frontend and onboarding wizard
- Add avatar upload to EditProfileModal with presigned URL flow - Create onboarding wizard with 5 steps: Welcome, Profile, Apps, Credits, Complete - Add onboarding store with localStorage persistence - Integrate wizard into app layout (shows for new users) - Update MANACORE-TODOS.md to mark completed tasks
This commit is contained in:
parent
405084b52d
commit
5118235e0b
12 changed files with 1339 additions and 39 deletions
|
|
@ -170,7 +170,7 @@ Archivierte Apps (memoro, storyteller) wurden bereits entfernt.
|
|||
| Profil bearbeiten | ✅ | ✅ | Hoch |
|
||||
| Passwort ändern | ✅ | ✅ | Hoch |
|
||||
| Konto löschen | ✅ | ✅ | Mittel |
|
||||
| Avatar hochladen | ✅ | ❌ | Niedrig |
|
||||
| Avatar hochladen | ✅ | ✅ | Niedrig |
|
||||
| 2FA aktivieren | ❌ | ❌ | Niedrig |
|
||||
|
||||
**Dateien:**
|
||||
|
|
@ -193,7 +193,7 @@ Archivierte Apps (memoro, storyteller) wurden bereits entfernt.
|
|||
- [x] Presigned URL Endpoint: `POST /api/v1/storage/avatar/upload-url`
|
||||
- [x] Direct Upload Endpoint: `POST /api/v1/storage/avatar`
|
||||
- [x] `manacore-storage` Bucket konfiguriert
|
||||
- [ ] Frontend-Integration (EditProfileModal) noch offen
|
||||
- [x] Frontend-Integration (EditProfileModal mit Avatar-Picker und Presigned-URL-Upload)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -291,27 +291,34 @@ GET /api/v1/subscriptions/invoices # Rechnungen
|
|||
|
||||
---
|
||||
|
||||
### 8. Onboarding-Flow
|
||||
### 8. ✅ Onboarding-Flow (ERLEDIGT)
|
||||
|
||||
**Status:** Implementiert am 2026-02-13
|
||||
|
||||
**Beschreibung:** Welcome-Wizard für neue Benutzer
|
||||
|
||||
**Schritte:**
|
||||
**Implementierte Schritte:**
|
||||
|
||||
1. Willkommen & Kurze Einführung
|
||||
2. Profil vervollständigen (Name, Avatar)
|
||||
3. Bevorzugte Apps auswählen
|
||||
4. Dashboard personalisieren
|
||||
5. Credits-System erklären
|
||||
6. Tour durch wichtigste Features
|
||||
1. Willkommen & Kurze Einführung (WelcomeStep)
|
||||
2. Profil vervollständigen - Name, Avatar (ProfileStep)
|
||||
3. Apps-Übersicht - alle Mana-Apps zeigen (AppsStep)
|
||||
4. Credits-System erklären (CreditsStep)
|
||||
5. Fertig - Quick Actions zum Start (CompleteStep)
|
||||
|
||||
**Aufgaben:**
|
||||
|
||||
- [ ] Onboarding-Wizard Komponente
|
||||
- [ ] Progress-Tracking (User hat Onboarding abgeschlossen)
|
||||
- [ ] Skip-Option
|
||||
- [ ] Feature-Tour (Tooltip-basiert)
|
||||
- [x] Onboarding-Store (`lib/stores/onboarding.svelte.ts`)
|
||||
- [x] OnboardingWizard Komponente mit Step-Navigation
|
||||
- [x] 5 Step-Komponenten (Welcome, Profile, Apps, Credits, Complete)
|
||||
- [x] Progress-Tracking (LocalStorage-basiert)
|
||||
- [x] Skip-Option
|
||||
- [x] Integration in App-Layout (zeigt Wizard für neue User)
|
||||
|
||||
**Geschätzter Aufwand:** 2-3 Tage
|
||||
**Dateien:**
|
||||
|
||||
- `apps/manacore/apps/web/src/lib/stores/onboarding.svelte.ts`
|
||||
- `apps/manacore/apps/web/src/lib/components/onboarding/OnboardingWizard.svelte`
|
||||
- `apps/manacore/apps/web/src/lib/components/onboarding/steps/*.svelte`
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -378,16 +385,16 @@ GET /api/v1/subscriptions/invoices # Rechnungen
|
|||
|
||||
## Empfohlene Reihenfolge
|
||||
|
||||
| # | Task | Aufwand | Impact | Abhängigkeiten |
|
||||
| --- | --------------------------- | -------- | -------- | -------------- |
|
||||
| 1 | App-Config aktualisieren | 2-4h | Hoch | Keine |
|
||||
| 2 | Stripe-Integration | 2-3 Tage | Kritisch | mana-core-auth |
|
||||
| 3 | Dashboard-Widgets erweitern | 1-2 Tage | Hoch | App-Config |
|
||||
| 4 | Profil-Features | 1-2 Tage | Mittel | Keine |
|
||||
| 5 | Notifications | 3-5 Tage | Hoch | Backend-Arbeit |
|
||||
| 6 | Onboarding | 2-3 Tage | Mittel | Keine |
|
||||
| 7 | Subscription-Management | 2-3 Tage | Mittel | Stripe |
|
||||
| 8 | API-Keys | 2-3 Tage | Niedrig | Keine |
|
||||
| # | Task | Aufwand | Impact | Status |
|
||||
| --- | --------------------------- | -------- | -------- | ----------- |
|
||||
| 1 | App-Config aktualisieren | 2-4h | Hoch | ✅ Erledigt |
|
||||
| 2 | Stripe-Integration | 2-3 Tage | Kritisch | ✅ Erledigt |
|
||||
| 3 | Dashboard-Widgets erweitern | 1-2 Tage | Hoch | ✅ Erledigt |
|
||||
| 4 | Profil-Features | 1-2 Tage | Mittel | ✅ Erledigt |
|
||||
| 5 | Notifications | 3-5 Tage | Hoch | ⏳ Offen |
|
||||
| 6 | Onboarding | 2-3 Tage | Mittel | ✅ Erledigt |
|
||||
| 7 | Subscription-Management | 2-3 Tage | Mittel | ✅ Erledigt |
|
||||
| 8 | API-Keys | 2-3 Tage | Niedrig | ⏳ Offen |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -414,4 +421,4 @@ Diese Tasks können schnell erledigt werden:
|
|||
|
||||
---
|
||||
|
||||
_Zuletzt aktualisiert: 2026-02-13 (Avatar Storage Backend + Subscription Plans Seed)_
|
||||
_Zuletzt aktualisiert: 2026-02-13 (Avatar-Upload Frontend + Onboarding-Flow implementiert)_
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Profile Service for ManaCore Web App
|
||||
* Handles profile updates, password changes, and account deletion
|
||||
* Handles profile updates, password changes, account deletion, and avatar uploads
|
||||
*/
|
||||
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
|
@ -33,6 +33,13 @@ export interface DeleteAccountRequest {
|
|||
reason?: string;
|
||||
}
|
||||
|
||||
export interface AvatarUploadUrlResponse {
|
||||
uploadUrl: string;
|
||||
fileUrl: string;
|
||||
key: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
// Helper function for authenticated requests
|
||||
async function fetchWithAuth<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = await authStore.getAccessToken();
|
||||
|
|
@ -96,4 +103,38 @@ export const profileService = {
|
|||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get presigned URL for avatar upload
|
||||
*/
|
||||
async getAvatarUploadUrl(filename: string): Promise<AvatarUploadUrlResponse> {
|
||||
return fetchWithAuth('/api/v1/storage/avatar/upload-url', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ filename }),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload avatar file using presigned URL, then update profile
|
||||
*/
|
||||
async uploadAvatar(file: File): Promise<{ success: boolean; user: UserProfile }> {
|
||||
// 1. Get presigned upload URL
|
||||
const { uploadUrl, fileUrl } = await this.getAvatarUploadUrl(file.name);
|
||||
|
||||
// 2. Upload file directly to S3/MinIO
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error('Avatar-Upload fehlgeschlagen');
|
||||
}
|
||||
|
||||
// 3. Update profile with new image URL
|
||||
return this.updateProfile({ image: fileUrl });
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
<script lang="ts">
|
||||
import { onboardingStore } from '$lib/stores/onboarding.svelte';
|
||||
import WelcomeStep from './steps/WelcomeStep.svelte';
|
||||
import ProfileStep from './steps/ProfileStep.svelte';
|
||||
import AppsStep from './steps/AppsStep.svelte';
|
||||
import CreditsStep from './steps/CreditsStep.svelte';
|
||||
import CompleteStep from './steps/CompleteStep.svelte';
|
||||
|
||||
interface Props {
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
let { onComplete }: Props = $props();
|
||||
|
||||
const STEPS = [
|
||||
{ id: 'welcome', label: 'Willkommen', component: WelcomeStep },
|
||||
{ id: 'profile', label: 'Profil', component: ProfileStep },
|
||||
{ id: 'apps', label: 'Apps', component: AppsStep },
|
||||
{ id: 'credits', label: 'Credits', component: CreditsStep },
|
||||
{ id: 'complete', label: 'Fertig', component: CompleteStep },
|
||||
];
|
||||
|
||||
let currentStep = $derived(onboardingStore.currentStep);
|
||||
let currentStepData = $derived(STEPS[currentStep] || STEPS[0]);
|
||||
let isFirstStep = $derived(currentStep === 0);
|
||||
let isLastStep = $derived(currentStep === STEPS.length - 1);
|
||||
let progress = $derived(((currentStep + 1) / STEPS.length) * 100);
|
||||
|
||||
function handleNext() {
|
||||
if (isLastStep) {
|
||||
onboardingStore.complete();
|
||||
onComplete();
|
||||
} else {
|
||||
onboardingStore.completeStep(currentStepData.id);
|
||||
onboardingStore.nextStep();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrev() {
|
||||
onboardingStore.prevStep();
|
||||
}
|
||||
|
||||
function handleSkip() {
|
||||
onboardingStore.skip();
|
||||
onComplete();
|
||||
}
|
||||
|
||||
function handleStepClick(index: number) {
|
||||
// Only allow going to completed steps or the next step
|
||||
if (index <= currentStep) {
|
||||
onboardingStore.goToStep(index);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-50 bg-background flex flex-col">
|
||||
<!-- Header with progress -->
|
||||
<header class="border-b px-6 py-4">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="h-10 w-10 rounded-xl bg-gradient-to-br from-primary to-primary/70 flex items-center justify-center"
|
||||
>
|
||||
<span class="text-xl">M</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="font-semibold text-lg">Willkommen bei ManaCore</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Schritt {currentStep + 1} von {STEPS.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={handleSkip}
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Überspringen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-primary transition-all duration-300 ease-out rounded-full"
|
||||
style="width: {progress}%"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Step indicators -->
|
||||
<div class="flex justify-between mt-3">
|
||||
{#each STEPS as step, index}
|
||||
<button
|
||||
onclick={() => handleStepClick(index)}
|
||||
disabled={index > currentStep}
|
||||
class="flex flex-col items-center gap-1 group"
|
||||
>
|
||||
<div
|
||||
class="h-8 w-8 rounded-full flex items-center justify-center text-sm font-medium transition-all
|
||||
{index < currentStep
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: index === currentStep
|
||||
? 'bg-primary/20 text-primary border-2 border-primary'
|
||||
: 'bg-muted text-muted-foreground'}"
|
||||
>
|
||||
{#if index < currentStep}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
{index + 1}
|
||||
{/if}
|
||||
</div>
|
||||
<span
|
||||
class="text-xs {index === currentStep
|
||||
? 'text-foreground font-medium'
|
||||
: 'text-muted-foreground'} hidden sm:block"
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Step content -->
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<div class="max-w-3xl mx-auto px-6 py-8">
|
||||
{#if currentStepData.id === 'welcome'}
|
||||
<WelcomeStep />
|
||||
{:else if currentStepData.id === 'profile'}
|
||||
<ProfileStep />
|
||||
{:else if currentStepData.id === 'apps'}
|
||||
<AppsStep />
|
||||
{:else if currentStepData.id === 'credits'}
|
||||
<CreditsStep />
|
||||
{:else if currentStepData.id === 'complete'}
|
||||
<CompleteStep />
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer with navigation -->
|
||||
<footer class="border-t px-6 py-4">
|
||||
<div class="max-w-3xl mx-auto flex justify-between">
|
||||
<button
|
||||
onclick={handlePrev}
|
||||
disabled={isFirstStep}
|
||||
class="px-6 py-2.5 border rounded-lg hover:bg-muted transition-colors disabled:opacity-0 disabled:pointer-events-none"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<button
|
||||
onclick={handleNext}
|
||||
class="px-6 py-2.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium"
|
||||
>
|
||||
{isLastStep ? "Los geht's!" : 'Weiter'}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export { default as OnboardingWizard } from './OnboardingWizard.svelte';
|
||||
export { default as WelcomeStep } from './steps/WelcomeStep.svelte';
|
||||
export { default as ProfileStep } from './steps/ProfileStep.svelte';
|
||||
export { default as AppsStep } from './steps/AppsStep.svelte';
|
||||
export { default as CreditsStep } from './steps/CreditsStep.svelte';
|
||||
export { default as CompleteStep } from './steps/CompleteStep.svelte';
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
<script lang="ts">
|
||||
// Apps overview step - shows available Mana apps
|
||||
|
||||
const APPS = [
|
||||
{
|
||||
id: 'todo',
|
||||
name: 'Todo',
|
||||
description: 'Aufgaben verwalten mit Projekten und Prioritäten',
|
||||
icon: '✓',
|
||||
color: 'bg-blue-500',
|
||||
category: 'Produktivität',
|
||||
},
|
||||
{
|
||||
id: 'calendar',
|
||||
name: 'Calendar',
|
||||
description: 'Termine und Ereignisse planen',
|
||||
icon: '📅',
|
||||
color: 'bg-green-500',
|
||||
category: 'Produktivität',
|
||||
},
|
||||
{
|
||||
id: 'contacts',
|
||||
name: 'Contacts',
|
||||
description: 'Kontakte organisieren und verwalten',
|
||||
icon: '👤',
|
||||
color: 'bg-purple-500',
|
||||
category: 'Produktivität',
|
||||
},
|
||||
{
|
||||
id: 'chat',
|
||||
name: 'Chat',
|
||||
description: 'KI-gestützte Konversationen',
|
||||
icon: '💬',
|
||||
color: 'bg-pink-500',
|
||||
category: 'AI',
|
||||
},
|
||||
{
|
||||
id: 'picture',
|
||||
name: 'Picture',
|
||||
description: 'Bilder mit KI generieren',
|
||||
icon: '🎨',
|
||||
color: 'bg-orange-500',
|
||||
category: 'AI',
|
||||
},
|
||||
{
|
||||
id: 'zitare',
|
||||
name: 'Zitare',
|
||||
description: 'Tägliche Inspiration mit Zitaten',
|
||||
icon: '💡',
|
||||
color: 'bg-yellow-500',
|
||||
category: 'Lifestyle',
|
||||
},
|
||||
{
|
||||
id: 'clock',
|
||||
name: 'Clock',
|
||||
description: 'Timer, Stoppuhr und Weltuhr',
|
||||
icon: '⏰',
|
||||
color: 'bg-red-500',
|
||||
category: 'Utility',
|
||||
},
|
||||
{
|
||||
id: 'manadeck',
|
||||
name: 'ManaDeck',
|
||||
description: 'Karteikarten für effektives Lernen',
|
||||
icon: '📚',
|
||||
color: 'bg-indigo-500',
|
||||
category: 'Bildung',
|
||||
},
|
||||
];
|
||||
|
||||
const categories = [...new Set(APPS.map((app) => app.category))];
|
||||
</script>
|
||||
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="text-2xl font-bold mb-2">Entdecke die Mana-Apps</h2>
|
||||
<p class="text-muted-foreground">Alle Apps sind mit deinem ManaCore-Konto verbunden.</p>
|
||||
</div>
|
||||
|
||||
{#each categories as category}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-medium text-muted-foreground mb-3">{category}</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{#each APPS.filter((a) => a.category === category) as app}
|
||||
<div
|
||||
class="p-4 rounded-xl bg-card border hover:border-primary/50 hover:shadow-sm transition-all cursor-pointer group"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="{app.color} h-10 w-10 rounded-lg flex items-center justify-center text-white text-lg"
|
||||
>
|
||||
{app.icon}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-semibold group-hover:text-primary transition-colors">{app.name}</h4>
|
||||
<p class="text-sm text-muted-foreground line-clamp-2">{app.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="mt-8 p-4 rounded-xl bg-primary/5 border border-primary/20">
|
||||
<div class="flex gap-3">
|
||||
<div class="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<svg class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium">Ein Konto, alle Apps</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Dein ManaCore-Login funktioniert in allen Apps. Deine Credits werden geteilt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
<script lang="ts">
|
||||
// Completion step with celebration
|
||||
</script>
|
||||
|
||||
<div class="text-center max-w-md mx-auto">
|
||||
<!-- Celebration animation -->
|
||||
<div class="mb-8 relative">
|
||||
<div
|
||||
class="inline-flex h-24 w-24 rounded-full bg-gradient-to-br from-green-400 to-emerald-500 items-center justify-center shadow-lg shadow-green-500/25 animate-bounce"
|
||||
>
|
||||
<svg class="h-12 w-12 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Confetti-like decorations -->
|
||||
<div class="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-2">
|
||||
<span class="text-2xl animate-pulse">🎉</span>
|
||||
</div>
|
||||
<div class="absolute top-4 left-8">
|
||||
<span class="text-xl animate-bounce" style="animation-delay: 0.1s;">✨</span>
|
||||
</div>
|
||||
<div class="absolute top-4 right-8">
|
||||
<span class="text-xl animate-bounce" style="animation-delay: 0.2s;">🌟</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-3xl font-bold mb-3">Alles bereit!</h2>
|
||||
<p class="text-lg text-muted-foreground mb-8">
|
||||
Du kannst jetzt loslegen und alle Mana-Apps nutzen.
|
||||
</p>
|
||||
|
||||
<!-- Quick actions -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-left mb-8">
|
||||
<a
|
||||
href="/dashboard"
|
||||
class="p-4 rounded-xl bg-card border hover:border-primary/50 hover:shadow-md transition-all group"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<svg class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold group-hover:text-primary transition-colors">Dashboard</div>
|
||||
<div class="text-xs text-muted-foreground">Dein Überblick</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/apps"
|
||||
class="p-4 rounded-xl bg-card border hover:border-primary/50 hover:shadow-md transition-all group"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="h-10 w-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center group-hover:bg-blue-200 dark:group-hover:bg-blue-900/50 transition-colors"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold group-hover:text-blue-600 transition-colors">Alle Apps</div>
|
||||
<div class="text-xs text-muted-foreground">Entdecke mehr</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/credits"
|
||||
class="p-4 rounded-xl bg-card border hover:border-primary/50 hover:shadow-md transition-all group"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="h-10 w-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/30 flex items-center justify-center group-hover:bg-yellow-200 dark:group-hover:bg-yellow-900/50 transition-colors"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-yellow-600 dark:text-yellow-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold group-hover:text-yellow-600 transition-colors">Credits</div>
|
||||
<div class="text-xs text-muted-foreground">150 Gratis-Credits</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/settings"
|
||||
class="p-4 rounded-xl bg-card border hover:border-primary/50 hover:shadow-md transition-all group"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="h-10 w-10 rounded-lg bg-gray-100 dark:bg-gray-800 flex items-center justify-center group-hover:bg-gray-200 dark:group-hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-600 dark:text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="font-semibold group-hover:text-gray-600 dark:group-hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Einstellungen
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">Personalisieren</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Klicke auf "Los geht's!" um zum Dashboard zu gelangen.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
<script lang="ts">
|
||||
// Credits explanation step
|
||||
</script>
|
||||
|
||||
<div class="max-w-xl mx-auto">
|
||||
<div class="text-center mb-8">
|
||||
<div
|
||||
class="inline-flex h-16 w-16 rounded-2xl bg-gradient-to-br from-yellow-400 to-orange-500 items-center justify-center mb-4 shadow-lg shadow-orange-500/25"
|
||||
>
|
||||
<svg class="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold mb-2">Das Credits-System</h2>
|
||||
<p class="text-muted-foreground">Einfach und transparent für alle AI-Features.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- How it works -->
|
||||
<div class="p-5 rounded-xl bg-card border">
|
||||
<h3 class="font-semibold mb-3 flex items-center gap-2">
|
||||
<span
|
||||
class="h-6 w-6 rounded-full bg-primary/10 flex items-center justify-center text-sm text-primary"
|
||||
>1</span
|
||||
>
|
||||
So funktioniert's
|
||||
</h3>
|
||||
<ul class="space-y-2 text-sm text-muted-foreground">
|
||||
<li class="flex items-start gap-2">
|
||||
<svg
|
||||
class="h-4 w-4 text-green-500 mt-0.5 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>Du hast ein Credit-Guthaben für alle Mana-Apps</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<svg
|
||||
class="h-4 w-4 text-green-500 mt-0.5 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>AI-Features wie Chat, Bildgenerierung etc. kosten Credits</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<svg
|
||||
class="h-4 w-4 text-green-500 mt-0.5 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>Einfache Preisregel: <strong class="text-foreground">1 Mana = 1 Cent</strong></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Free credits -->
|
||||
<div
|
||||
class="p-5 rounded-xl bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 border border-green-200 dark:border-green-800"
|
||||
>
|
||||
<h3 class="font-semibold mb-3 flex items-center gap-2">
|
||||
<span
|
||||
class="h-6 w-6 rounded-full bg-green-500 flex items-center justify-center text-sm text-white"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
Gratis-Credits
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="bg-white dark:bg-background rounded-lg p-3 text-center">
|
||||
<div class="text-2xl font-bold text-green-600">150</div>
|
||||
<div class="text-xs text-muted-foreground">Willkommensbonus</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-background rounded-lg p-3 text-center">
|
||||
<div class="text-2xl font-bold text-green-600">+5</div>
|
||||
<div class="text-xs text-muted-foreground">Täglich gratis</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit packages -->
|
||||
<div class="p-5 rounded-xl bg-card border">
|
||||
<h3 class="font-semibold mb-3 flex items-center gap-2">
|
||||
<span
|
||||
class="h-6 w-6 rounded-full bg-primary/10 flex items-center justify-center text-sm text-primary"
|
||||
>2</span
|
||||
>
|
||||
Credit-Pakete
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<div class="p-3 rounded-lg bg-muted text-center">
|
||||
<div class="font-bold">100</div>
|
||||
<div class="text-xs text-muted-foreground">€1</div>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg bg-muted text-center">
|
||||
<div class="font-bold">500</div>
|
||||
<div class="text-xs text-muted-foreground">€5</div>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg bg-muted text-center">
|
||||
<div class="font-bold">1.500</div>
|
||||
<div class="text-xs text-muted-foreground">€15</div>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg bg-muted text-center">
|
||||
<div class="font-bold">5.000</div>
|
||||
<div class="text-xs text-muted-foreground">€50</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pro tip -->
|
||||
<div
|
||||
class="p-4 rounded-xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<svg
|
||||
class="h-5 w-5 text-blue-500 shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-sm text-blue-800 dark:text-blue-200">
|
||||
<strong>Tipp:</strong> Mit einem Pro-Abo erhältst du monatlich 2.000 Credits und weitere Vorteile.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { profileService } from '$lib/api/profile';
|
||||
|
||||
let name = $state(authStore.user?.name || '');
|
||||
let avatarPreview = $state<string | null>(authStore.user?.image || null);
|
||||
let selectedFile = $state<File | null>(null);
|
||||
let saving = $state(false);
|
||||
let saved = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
error = 'Bitte wähle ein Bild aus';
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
error = 'Max. 5MB erlaubt';
|
||||
return;
|
||||
}
|
||||
|
||||
selectedFile = file;
|
||||
error = null;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
avatarPreview = e.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput?.click();
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
if (!name.trim()) {
|
||||
error = 'Bitte gib deinen Namen ein';
|
||||
return;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
// Upload avatar if selected
|
||||
if (selectedFile) {
|
||||
await profileService.uploadAvatar(selectedFile);
|
||||
}
|
||||
|
||||
// Update name
|
||||
await profileService.updateProfile({ name: name.trim() });
|
||||
saved = true;
|
||||
|
||||
// Refresh auth store
|
||||
// The profile update should update the user in authStore
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Speichern';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return (
|
||||
name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2) || 'U'
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="text-2xl font-bold mb-2">Vervollständige dein Profil</h2>
|
||||
<p class="text-muted-foreground">Füge ein Profilbild und deinen Namen hinzu.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Avatar Upload -->
|
||||
<div class="flex flex-col items-center">
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
onchange={handleFileSelect}
|
||||
/>
|
||||
|
||||
<button onclick={triggerFileInput} class="relative group">
|
||||
{#if avatarPreview}
|
||||
<img
|
||||
src={avatarPreview}
|
||||
alt="Avatar"
|
||||
class="h-28 w-28 rounded-full object-cover border-4 border-border group-hover:border-primary/50 transition-colors"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="h-28 w-28 rounded-full bg-primary/10 flex items-center justify-center text-primary text-3xl font-semibold border-4 border-border group-hover:border-primary/50 transition-colors"
|
||||
>
|
||||
{getInitials(name || 'U')}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Camera overlay -->
|
||||
<div
|
||||
class="absolute inset-0 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||
>
|
||||
<svg class="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<p class="mt-2 text-sm text-muted-foreground">Klicke zum Ändern</p>
|
||||
</div>
|
||||
|
||||
<!-- Name Input -->
|
||||
<div>
|
||||
<label for="onboarding-name" class="block text-sm font-medium mb-2">Dein Name</label>
|
||||
<input
|
||||
id="onboarding-name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder="Max Mustermann"
|
||||
class="w-full px-4 py-3 border rounded-xl bg-background focus:outline-none focus:ring-2 focus:ring-primary/50 text-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Email (readonly) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2 text-muted-foreground">E-Mail</label>
|
||||
<div class="px-4 py-3 border rounded-xl bg-muted text-muted-foreground">
|
||||
{authStore.user?.email || 'Nicht verfügbar'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"
|
||||
>
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if saved}
|
||||
<div
|
||||
class="p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg"
|
||||
>
|
||||
<p class="text-sm text-green-600 dark:text-green-400 flex items-center gap-2">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Profil gespeichert!
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={saveProfile}
|
||||
disabled={saving || !name.trim()}
|
||||
class="w-full px-4 py-3 bg-card border rounded-xl hover:bg-muted transition-colors disabled:opacity-50 font-medium flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if saving}
|
||||
<svg class="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
Speichern...
|
||||
{:else}
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Jetzt speichern
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<p class="text-center text-sm text-muted-foreground">
|
||||
Du kannst dein Profil jederzeit in den Einstellungen ändern.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
<script lang="ts">
|
||||
// Welcome step - introduces ManaCore
|
||||
</script>
|
||||
|
||||
<div class="text-center max-w-xl mx-auto">
|
||||
<!-- Hero Icon -->
|
||||
<div class="mb-8">
|
||||
<div
|
||||
class="inline-flex h-24 w-24 rounded-2xl bg-gradient-to-br from-primary via-primary/80 to-primary/60 items-center justify-center shadow-lg shadow-primary/25"
|
||||
>
|
||||
<span class="text-5xl text-primary-foreground font-bold">M</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-3xl font-bold mb-4">Willkommen bei ManaCore!</h2>
|
||||
<p class="text-lg text-muted-foreground mb-8">
|
||||
Dein zentrales Dashboard für alle Mana-Apps. Verwalte deine Aufgaben, Termine, Kontakte und mehr
|
||||
- alles an einem Ort.
|
||||
</p>
|
||||
|
||||
<!-- Feature highlights -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 text-left">
|
||||
<div class="p-4 rounded-xl bg-card border">
|
||||
<div
|
||||
class="h-10 w-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center mb-3"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="font-semibold mb-1">Alle Apps</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Zugriff auf Todo, Kalender, Chat, Picture und mehr.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 rounded-xl bg-card border">
|
||||
<div
|
||||
class="h-10 w-10 rounded-lg bg-green-100 dark:bg-green-900/30 flex items-center justify-center mb-3"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-green-600 dark:text-green-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="font-semibold mb-1">Credits-System</h3>
|
||||
<p class="text-sm text-muted-foreground">Ein Guthaben für alle AI-Features in allen Apps.</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 rounded-xl bg-card border">
|
||||
<div
|
||||
class="h-10 w-10 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center mb-3"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-purple-600 dark:text-purple-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="font-semibold mb-1">Personalisierung</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Themes, Widgets und Einstellungen nach deinem Geschmack.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -12,22 +12,71 @@
|
|||
|
||||
let name = $state('');
|
||||
let saving = $state(false);
|
||||
let uploadingAvatar = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let avatarPreview = $state<string | null>(null);
|
||||
let selectedFile = $state<File | null>(null);
|
||||
|
||||
// File input ref
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
// Initialize form when modal opens
|
||||
$effect(() => {
|
||||
if (show && user) {
|
||||
name = user.name || '';
|
||||
avatarPreview = user.image || null;
|
||||
selectedFile = null;
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
function handleBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget && !saving) {
|
||||
if (event.target === event.currentTarget && !saving && !uploadingAvatar) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
error = 'Bitte wähle ein Bild aus (JPG, PNG, GIF, WebP)';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
error = 'Das Bild darf maximal 5MB groß sein';
|
||||
return;
|
||||
}
|
||||
|
||||
selectedFile = file;
|
||||
error = null;
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
avatarPreview = e.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput?.click();
|
||||
}
|
||||
|
||||
function removeAvatar() {
|
||||
selectedFile = null;
|
||||
avatarPreview = null;
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
|
|
@ -39,15 +88,52 @@
|
|||
error = null;
|
||||
|
||||
try {
|
||||
const result = await profileService.updateProfile({ name: name.trim() });
|
||||
onSuccess(result.user);
|
||||
let updatedUser: UserProfile;
|
||||
|
||||
// If new avatar selected, upload first
|
||||
if (selectedFile) {
|
||||
uploadingAvatar = true;
|
||||
const result = await profileService.uploadAvatar(selectedFile);
|
||||
updatedUser = result.user;
|
||||
uploadingAvatar = false;
|
||||
|
||||
// Now update name if changed
|
||||
if (name.trim() !== updatedUser.name) {
|
||||
const nameResult = await profileService.updateProfile({ name: name.trim() });
|
||||
updatedUser = nameResult.user;
|
||||
}
|
||||
} else if (avatarPreview === null && user?.image) {
|
||||
// Avatar was removed
|
||||
const result = await profileService.updateProfile({
|
||||
name: name.trim(),
|
||||
image: '',
|
||||
});
|
||||
updatedUser = result.user;
|
||||
} else {
|
||||
// Just update name
|
||||
const result = await profileService.updateProfile({ name: name.trim() });
|
||||
updatedUser = result.user;
|
||||
}
|
||||
|
||||
onSuccess(updatedUser);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Speichern';
|
||||
} finally {
|
||||
saving = false;
|
||||
uploadingAvatar = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get initials for avatar placeholder
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
|
|
@ -59,9 +145,7 @@
|
|||
<div class="bg-card rounded-xl shadow-xl max-w-md w-full" role="dialog" aria-modal="true">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div
|
||||
class="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center"
|
||||
>
|
||||
<div class="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<svg class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
|
|
@ -76,6 +160,82 @@
|
|||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="space-y-4">
|
||||
<!-- Avatar Upload -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Profilbild</label>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Avatar Preview -->
|
||||
<div class="relative">
|
||||
{#if avatarPreview}
|
||||
<img
|
||||
src={avatarPreview}
|
||||
alt="Avatar"
|
||||
class="h-20 w-20 rounded-full object-cover border-2 border-border"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="h-20 w-20 rounded-full bg-primary/10 flex items-center justify-center text-primary text-xl font-semibold"
|
||||
>
|
||||
{getInitials(name || user?.name || 'U')}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if uploadingAvatar}
|
||||
<div
|
||||
class="absolute inset-0 bg-black/50 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<svg class="animate-spin h-6 w-6 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Upload/Remove Buttons -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
onchange={handleFileSelect}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={triggerFileInput}
|
||||
disabled={saving || uploadingAvatar}
|
||||
class="px-3 py-1.5 text-sm border rounded-lg hover:bg-muted transition-colors disabled:opacity-50"
|
||||
>
|
||||
Bild auswählen
|
||||
</button>
|
||||
{#if avatarPreview}
|
||||
<button
|
||||
type="button"
|
||||
onclick={removeAvatar}
|
||||
disabled={saving || uploadingAvatar}
|
||||
class="px-3 py-1.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-muted-foreground">JPG, PNG, GIF oder WebP. Max. 5MB.</p>
|
||||
</div>
|
||||
|
||||
<!-- Email (readonly) -->
|
||||
<div>
|
||||
<label for="profile-email" class="block text-sm font-medium mb-2">E-Mail</label>
|
||||
<input
|
||||
|
|
@ -88,20 +248,23 @@
|
|||
<p class="mt-1 text-xs text-muted-foreground">E-Mail kann nicht geändert werden</p>
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="profile-name" class="block text-sm font-medium mb-2">Name</label>
|
||||
<input
|
||||
id="profile-name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
disabled={saving}
|
||||
disabled={saving || uploadingAvatar}
|
||||
placeholder="Dein Name"
|
||||
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div
|
||||
class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"
|
||||
>
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -111,17 +274,17 @@
|
|||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
disabled={saving}
|
||||
disabled={saving || uploadingAvatar}
|
||||
class="flex-1 px-4 py-2 border rounded-lg hover:bg-muted transition-colors disabled:opacity-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
disabled={saving || uploadingAvatar}
|
||||
class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if saving}
|
||||
{#if saving || uploadingAvatar}
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
|
|
@ -137,7 +300,7 @@
|
|||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Speichern...</span>
|
||||
<span>{uploadingAvatar ? 'Hochladen...' : 'Speichern...'}</span>
|
||||
{:else}
|
||||
Speichern
|
||||
{/if}
|
||||
|
|
|
|||
122
apps/manacore/apps/web/src/lib/stores/onboarding.svelte.ts
Normal file
122
apps/manacore/apps/web/src/lib/stores/onboarding.svelte.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* Onboarding Store
|
||||
* Tracks user onboarding progress and completion status
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'manacore-onboarding';
|
||||
|
||||
interface OnboardingState {
|
||||
completed: boolean;
|
||||
currentStep: number;
|
||||
completedSteps: string[];
|
||||
skipped: boolean;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
function createOnboardingStore() {
|
||||
let state = $state<OnboardingState>({
|
||||
completed: false,
|
||||
currentStep: 0,
|
||||
completedSteps: [],
|
||||
skipped: false,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
});
|
||||
|
||||
// Load from localStorage on init
|
||||
function load() {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
state = { ...state, ...parsed };
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
function save() {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
return {
|
||||
get completed() {
|
||||
return state.completed;
|
||||
},
|
||||
get currentStep() {
|
||||
return state.currentStep;
|
||||
},
|
||||
get completedSteps() {
|
||||
return state.completedSteps;
|
||||
},
|
||||
get skipped() {
|
||||
return state.skipped;
|
||||
},
|
||||
get shouldShow() {
|
||||
return !state.completed && !state.skipped;
|
||||
},
|
||||
|
||||
load,
|
||||
|
||||
start() {
|
||||
state.startedAt = new Date().toISOString();
|
||||
state.currentStep = 0;
|
||||
save();
|
||||
},
|
||||
|
||||
nextStep() {
|
||||
state.currentStep++;
|
||||
save();
|
||||
},
|
||||
|
||||
prevStep() {
|
||||
if (state.currentStep > 0) {
|
||||
state.currentStep--;
|
||||
save();
|
||||
}
|
||||
},
|
||||
|
||||
goToStep(step: number) {
|
||||
state.currentStep = step;
|
||||
save();
|
||||
},
|
||||
|
||||
completeStep(stepId: string) {
|
||||
if (!state.completedSteps.includes(stepId)) {
|
||||
state.completedSteps = [...state.completedSteps, stepId];
|
||||
save();
|
||||
}
|
||||
},
|
||||
|
||||
complete() {
|
||||
state.completed = true;
|
||||
state.completedAt = new Date().toISOString();
|
||||
save();
|
||||
},
|
||||
|
||||
skip() {
|
||||
state.skipped = true;
|
||||
save();
|
||||
},
|
||||
|
||||
reset() {
|
||||
state = {
|
||||
completed: false,
|
||||
currentStep: 0,
|
||||
completedSteps: [],
|
||||
skipped: false,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
};
|
||||
save();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const onboardingStore = createOnboardingStore();
|
||||
|
|
@ -22,6 +22,8 @@
|
|||
isNavCollapsed as collapsedStore,
|
||||
} from '$lib/stores/navigation';
|
||||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
import { onboardingStore } from '$lib/stores/onboarding.svelte';
|
||||
import { OnboardingWizard } from '$lib/components/onboarding';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
|
|
@ -148,6 +150,17 @@
|
|||
|
||||
// Track initialization state
|
||||
let isInitializing = $state(true);
|
||||
let showOnboarding = $state(false);
|
||||
|
||||
function handleOnboardingComplete() {
|
||||
onboardingStore.complete();
|
||||
showOnboarding = false;
|
||||
}
|
||||
|
||||
function handleOnboardingSkip() {
|
||||
onboardingStore.skip();
|
||||
showOnboarding = false;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Initialize auth store first
|
||||
|
|
@ -182,6 +195,13 @@
|
|||
// Settings API not available - use defaults
|
||||
});
|
||||
|
||||
// Load onboarding state and show wizard if needed
|
||||
onboardingStore.load();
|
||||
if (onboardingStore.shouldShow) {
|
||||
onboardingStore.start();
|
||||
showOnboarding = true;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
|
@ -198,6 +218,15 @@
|
|||
</div>
|
||||
</div>
|
||||
{:else if authStore.isAuthenticated}
|
||||
<!-- Onboarding Wizard Modal -->
|
||||
{#if showOnboarding}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-background/95 backdrop-blur-sm"
|
||||
>
|
||||
<OnboardingWizard onComplete={handleOnboardingComplete} onSkip={handleOnboardingSkip} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="min-h-screen bg-background">
|
||||
<!-- Pill Navigation -->
|
||||
<PillNavigation
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue