feat(manacore): add profile, subscription, and credits frontend

Profile Features:
- EditProfileModal for name changes
- ChangePasswordModal with validation
- DeleteAccountModal with email confirmation

Subscription Features:
- Full subscription page at /subscription
- Plan comparison with monthly/yearly toggle
- Stripe Checkout integration
- Billing portal access
- Invoice history display

Credits Features:
- Stripe Checkout integration for credit purchases
- Loading states and toast notifications
- Success/error handling with URL params

API Services:
- profile.ts: getProfile, updateProfile, changePassword, deleteAccount
- subscriptions.ts: getPlans, getCurrentSubscription, createCheckout, etc.
- credits.ts: added initiatePurchase method

Also includes German/English i18n translations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-13 22:46:25 +01:00
parent affcfe4614
commit d3b0f9f38d
13 changed files with 1732 additions and 41 deletions

View file

@ -86,10 +86,14 @@
- `services/mana-core-auth/src/stripe/` - Stripe-Module
- `services/mana-core-auth/src/credits/credits.service.ts` - Purchase-Methoden
**Frontend (Implementiert 2026-02-13):**
- [x] Credits-Seite: Stripe Checkout Integration
- [x] Loading-States und Toast-Benachrichtigungen
**Noch offen:**
- [ ] Rechnungs-PDF generieren
- [ ] Frontend: Stripe Elements einbinden
---
@ -177,12 +181,12 @@ Archivierte Apps (memoro, storyteller) wurden bereits entfernt.
- `services/mana-core-auth/src/auth/dto/change-password.dto.ts` - Passwort-Ändern DTO
- `services/mana-core-auth/src/auth/dto/delete-account.dto.ts` - Konto-Löschen DTO
**Noch offen (Frontend):**
**Frontend (Implementiert 2026-02-13):**
- [ ] Profil-Edit Modal/Seite erstellen
- [ ] Passwort-Ändern Dialog
- [ ] Konto-Löschung mit Bestätigung
- [ ] Avatar-Upload mit S3/MinIO Integration
- [x] Profil-Edit Modal erstellt (`EditProfileModal.svelte`)
- [x] Passwort-Ändern Dialog erstellt (`ChangePasswordModal.svelte`)
- [x] Konto-Löschung mit Bestätigung (`DeleteAccountModal.svelte`)
- [ ] Avatar-Upload mit S3/MinIO Integration (noch offen)
---
@ -246,10 +250,13 @@ POST /api/v1/subscriptions/reactivate # Reaktivieren
GET /api/v1/subscriptions/invoices # Rechnungen
```
**Noch offen (Frontend):**
**Frontend (Implementiert 2026-02-13):**
- [ ] Plan-Übersicht Seite im Frontend
- [ ] Plan-Vergleichs-UI
- [x] Plan-Übersicht Seite im Frontend (`/subscription`)
- [x] Plan-Vergleichs-UI mit monatlich/jährlich Toggle
- [x] Stripe Checkout Integration für Subscriptions
- [x] Billing Portal Integration
- [x] Rechnungsübersicht
- [ ] Stripe Price IDs in DB eintragen (nach Stripe-Setup)
---
@ -399,4 +406,4 @@ Diese Tasks können schnell erledigt werden:
---
_Zuletzt aktualisiert: 2026-02-13 (Profile-Features Backend)_
_Zuletzt aktualisiert: 2026-02-13 (Profile + Subscription + Credits Frontend)_

View file

@ -119,4 +119,22 @@ export const creditsService = {
body: JSON.stringify({ amount, appId, description }),
});
},
/**
* Initiate a credit purchase via Stripe Checkout
*/
async initiatePurchase(packageId: string): Promise<{
purchaseId: string;
checkoutUrl: string;
amount: number;
credits: number;
}> {
const successUrl = `${window.location.origin}/credits?success=true`;
const cancelUrl = `${window.location.origin}/credits?canceled=true`;
return fetchWithAuth('/api/v1/credits/purchase', {
method: 'POST',
body: JSON.stringify({ packageId, successUrl, cancelUrl }),
});
},
};

View file

@ -0,0 +1,99 @@
/**
* Profile Service for ManaCore Web App
* Handles profile updates, password changes, and account deletion
*/
import { authStore } from '$lib/stores/auth.svelte';
const MANA_AUTH_URL = 'http://localhost:3001';
// Types
export interface UserProfile {
id: string;
name: string;
email: string;
emailVerified: boolean;
image?: string;
role: string;
createdAt: string;
}
export interface UpdateProfileRequest {
name?: string;
image?: string;
}
export interface ChangePasswordRequest {
currentPassword: string;
newPassword: string;
}
export interface DeleteAccountRequest {
password: string;
reason?: string;
}
// Helper function for authenticated requests
async function fetchWithAuth<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = await authStore.getAccessToken();
const response = await fetch(`${MANA_AUTH_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
// Profile Service
export const profileService = {
/**
* Get current user profile
*/
async getProfile(): Promise<UserProfile> {
return fetchWithAuth<UserProfile>('/api/v1/auth/profile');
},
/**
* Update user profile
*/
async updateProfile(
data: UpdateProfileRequest
): Promise<{ success: boolean; user: UserProfile }> {
return fetchWithAuth('/api/v1/auth/profile', {
method: 'POST',
body: JSON.stringify(data),
});
},
/**
* Change password
*/
async changePassword(
data: ChangePasswordRequest
): Promise<{ success: boolean; message: string }> {
return fetchWithAuth('/api/v1/auth/change-password', {
method: 'POST',
body: JSON.stringify(data),
});
},
/**
* Delete account (soft-delete)
*/
async deleteAccount(data: DeleteAccountRequest): Promise<{ success: boolean; message: string }> {
return fetchWithAuth('/api/v1/auth/account', {
method: 'DELETE',
body: JSON.stringify(data),
});
},
};

View file

@ -0,0 +1,157 @@
/**
* Subscriptions Service for ManaCore Web App
* Handles subscription plans, checkout, and billing portal
*/
import { authStore } from '$lib/stores/auth.svelte';
const MANA_AUTH_URL = 'http://localhost:3001';
// Types
export interface SubscriptionPlan {
id: string;
name: string;
description?: string;
monthlyCredits: number;
priceMonthlyEuroCents: number;
priceYearlyEuroCents: number;
features: string[];
isDefault: boolean;
active: boolean;
sortOrder: number;
stripePriceIdMonthly?: string;
stripePriceIdYearly?: string;
}
export interface Subscription {
id: string;
userId: string;
planId: string;
status: 'active' | 'canceled' | 'past_due' | 'trialing' | 'incomplete';
billingInterval: 'month' | 'year';
currentPeriodStart: string;
currentPeriodEnd: string;
cancelAtPeriodEnd: boolean;
stripeSubscriptionId?: string;
}
export interface Invoice {
id: string;
number?: string;
status: string;
amountDueEuroCents: number;
amountPaidEuroCents: number;
currency: string;
hostedInvoiceUrl?: string;
invoicePdfUrl?: string;
periodStart?: string;
periodEnd?: string;
paidAt?: string;
createdAt: string;
}
export interface CurrentSubscription {
plan: SubscriptionPlan | null;
subscription: Subscription | null;
isFreePlan: boolean;
}
// Helper function for authenticated requests
async function fetchWithAuth<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = await authStore.getAccessToken();
const response = await fetch(`${MANA_AUTH_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
// Subscriptions Service
export const subscriptionsService = {
/**
* Get all available plans (public)
*/
async getPlans(): Promise<SubscriptionPlan[]> {
const response = await fetch(`${MANA_AUTH_URL}/api/v1/subscriptions/plans`);
if (!response.ok) {
throw new Error('Failed to fetch plans');
}
return response.json();
},
/**
* Get current subscription
*/
async getCurrentSubscription(): Promise<CurrentSubscription> {
return fetchWithAuth<CurrentSubscription>('/api/v1/subscriptions/current');
},
/**
* Create checkout session for subscription
*/
async createCheckout(
planId: string,
billingInterval: 'month' | 'year'
): Promise<{ sessionId: string; url: string }> {
const successUrl = `${window.location.origin}/subscription?success=true`;
const cancelUrl = `${window.location.origin}/subscription?canceled=true`;
return fetchWithAuth('/api/v1/subscriptions/checkout', {
method: 'POST',
body: JSON.stringify({
planId,
billingInterval,
successUrl,
cancelUrl,
}),
});
},
/**
* Open billing portal for self-service
*/
async openPortal(): Promise<{ url: string }> {
const returnUrl = `${window.location.origin}/subscription`;
return fetchWithAuth('/api/v1/subscriptions/portal', {
method: 'POST',
body: JSON.stringify({ returnUrl }),
});
},
/**
* Cancel subscription (at period end)
*/
async cancelSubscription(): Promise<{ success: boolean; cancelAtPeriodEnd: boolean }> {
return fetchWithAuth('/api/v1/subscriptions/cancel', {
method: 'POST',
});
},
/**
* Reactivate canceled subscription
*/
async reactivateSubscription(): Promise<{ success: boolean }> {
return fetchWithAuth('/api/v1/subscriptions/reactivate', {
method: 'POST',
});
},
/**
* Get invoice history
*/
async getInvoices(limit = 20): Promise<Invoice[]> {
return fetchWithAuth<Invoice[]>(`/api/v1/subscriptions/invoices?limit=${limit}`);
},
};

View file

@ -0,0 +1,227 @@
<script lang="ts">
import { profileService } from '$lib/api/profile';
interface Props {
show: boolean;
onClose: () => void;
onSuccess: () => void;
}
let { show, onClose, onSuccess }: Props = $props();
let currentPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
let saving = $state(false);
let error = $state<string | null>(null);
let showCurrentPassword = $state(false);
let showNewPassword = $state(false);
// Reset form when modal opens
$effect(() => {
if (show) {
currentPassword = '';
newPassword = '';
confirmPassword = '';
error = null;
showCurrentPassword = false;
showNewPassword = false;
}
});
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget && !saving) {
onClose();
}
}
async function handleSubmit(e: Event) {
e.preventDefault();
// Validation
if (!currentPassword) {
error = 'Bitte gib dein aktuelles Passwort ein';
return;
}
if (!newPassword) {
error = 'Bitte gib ein neues Passwort ein';
return;
}
if (newPassword.length < 8) {
error = 'Das neue Passwort muss mindestens 8 Zeichen lang sein';
return;
}
if (newPassword !== confirmPassword) {
error = 'Die Passwörter stimmen nicht überein';
return;
}
saving = true;
error = null;
try {
await profileService.changePassword({
currentPassword,
newPassword,
});
onSuccess();
onClose();
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Ändern des Passworts';
} finally {
saving = false;
}
}
</script>
{#if show}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onclick={handleBackdropClick}
>
<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-blue-100 dark:bg-blue-900/30 flex items-center justify-center"
>
<svg class="h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
</div>
<h3 class="text-lg font-semibold">Passwort ändern</h3>
</div>
<form onsubmit={handleSubmit}>
<div class="space-y-4">
<div>
<label for="current-password" class="block text-sm font-medium mb-2">
Aktuelles Passwort
</label>
<div class="relative">
<input
id="current-password"
type={showCurrentPassword ? 'text' : 'password'}
bind:value={currentPassword}
disabled={saving}
class="w-full px-3 py-2 pr-10 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50"
/>
<button
type="button"
onclick={() => (showCurrentPassword = !showCurrentPassword)}
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
>
{#if showCurrentPassword}
<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
{: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="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>
{/if}
</button>
</div>
</div>
<div>
<label for="new-password" class="block text-sm font-medium mb-2">
Neues Passwort
</label>
<div class="relative">
<input
id="new-password"
type={showNewPassword ? 'text' : 'password'}
bind:value={newPassword}
disabled={saving}
placeholder="Mindestens 8 Zeichen"
class="w-full px-3 py-2 pr-10 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50"
/>
<button
type="button"
onclick={() => (showNewPassword = !showNewPassword)}
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
>
{#if showNewPassword}
<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
{: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="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>
{/if}
</button>
</div>
</div>
<div>
<label for="confirm-password" class="block text-sm font-medium mb-2">
Passwort bestätigen
</label>
<input
id="confirm-password"
type="password"
bind:value={confirmPassword}
disabled={saving}
placeholder="Neues Passwort wiederholen"
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">
<p class="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
{/if}
</div>
<div class="flex gap-3 mt-6">
<button
type="button"
onclick={onClose}
disabled={saving}
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}
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}
<svg class="animate-spin h-4 w-4" 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>
<span>Ändern...</span>
{:else}
Passwort ändern
{/if}
</button>
</div>
</form>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,260 @@
<script lang="ts">
import { profileService } from '$lib/api/profile';
interface Props {
show: boolean;
userEmail: string;
onClose: () => void;
onSuccess: () => void;
}
let { show, userEmail, onClose, onSuccess }: Props = $props();
let password = $state('');
let reason = $state('');
let confirmEmail = $state('');
let deleting = $state(false);
let error = $state<string | null>(null);
let step = $state<'confirm' | 'password'>('confirm');
// Reset form when modal opens
$effect(() => {
if (show) {
password = '';
reason = '';
confirmEmail = '';
error = null;
step = 'confirm';
}
});
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget && !deleting) {
onClose();
}
}
function handleContinue() {
if (confirmEmail !== userEmail) {
error = 'Die E-Mail-Adresse stimmt nicht überein';
return;
}
error = null;
step = 'password';
}
async function handleDelete() {
if (!password) {
error = 'Bitte gib dein Passwort ein';
return;
}
deleting = true;
error = null;
try {
await profileService.deleteAccount({
password,
reason: reason || undefined,
});
onSuccess();
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Löschen des Kontos';
} finally {
deleting = false;
}
}
</script>
{#if show}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onclick={handleBackdropClick}
>
<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-4">
<div
class="h-10 w-10 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"
>
<svg class="h-5 w-5 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<h3 class="text-lg font-semibold text-red-600">Konto löschen</h3>
</div>
{#if step === 'confirm'}
<div
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4"
>
<p class="text-sm text-red-700 dark:text-red-300 font-medium mb-2">
Diese Aktion ist unwiderruflich!
</p>
<p class="text-sm text-red-600 dark:text-red-400">
Dein Konto und alle deine Daten werden dauerhaft gelöscht. Dies umfasst:
</p>
</div>
<ul class="text-sm text-muted-foreground mb-6 space-y-2">
<li class="flex items-center gap-2">
<svg class="h-4 w-4 text-red-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
<span>Alle Projektdaten (Chats, Todos, Termine, etc.)</span>
</li>
<li class="flex items-center gap-2">
<svg class="h-4 w-4 text-red-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
<span>Alle verbleibenden Credits</span>
</li>
<li class="flex items-center gap-2">
<svg class="h-4 w-4 text-red-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
<span>Dein aktives Abonnement (wird gekündigt)</span>
</li>
<li class="flex items-center gap-2">
<svg class="h-4 w-4 text-red-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
<span>Dein Nutzerkonto</span>
</li>
</ul>
<div class="mb-4">
<label for="delete-confirm-email" class="block text-sm font-medium mb-2">
Gib zur Bestätigung deine E-Mail-Adresse ein:
</label>
<input
id="delete-confirm-email"
type="email"
placeholder={userEmail}
bind:value={confirmEmail}
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-red-500/50"
/>
</div>
{#if error}
<div class="mb-4 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}
<div class="flex gap-3">
<button
onclick={onClose}
class="flex-1 px-4 py-2 border rounded-lg hover:bg-muted transition-colors"
>
Abbrechen
</button>
<button
onclick={handleContinue}
disabled={confirmEmail !== userEmail}
class="flex-1 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Weiter
</button>
</div>
{:else}
<p class="text-sm text-muted-foreground mb-4">
Bitte gib dein Passwort ein, um die Löschung zu bestätigen.
</p>
<div class="space-y-4">
<div>
<label for="delete-password" class="block text-sm font-medium mb-2">Passwort</label>
<input
id="delete-password"
type="password"
bind:value={password}
disabled={deleting}
placeholder="Dein aktuelles Passwort"
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-red-500/50 disabled:opacity-50"
/>
</div>
<div>
<label for="delete-reason" class="block text-sm font-medium mb-2">
Grund für die Löschung (optional)
</label>
<textarea
id="delete-reason"
bind:value={reason}
disabled={deleting}
placeholder="Hilf uns, besser zu werden..."
rows="2"
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 resize-none"
></textarea>
</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}
</div>
<div class="flex gap-3 mt-6">
<button
onclick={() => (step = 'confirm')}
disabled={deleting}
class="flex-1 px-4 py-2 border rounded-lg hover:bg-muted transition-colors disabled:opacity-50"
>
Zurück
</button>
<button
onclick={handleDelete}
disabled={deleting || !password}
class="flex-1 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
>
{#if deleting}
<svg class="animate-spin h-4 w-4" 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>
<span>Wird gelöscht...</span>
{:else}
Konto endgültig löschen
{/if}
</button>
</div>
{/if}
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,150 @@
<script lang="ts">
import { profileService, type UserProfile } from '$lib/api/profile';
interface Props {
show: boolean;
user: UserProfile | null;
onClose: () => void;
onSuccess: (user: UserProfile) => void;
}
let { show, user, onClose, onSuccess }: Props = $props();
let name = $state('');
let saving = $state(false);
let error = $state<string | null>(null);
// Initialize form when modal opens
$effect(() => {
if (show && user) {
name = user.name || '';
error = null;
}
});
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget && !saving) {
onClose();
}
}
async function handleSubmit(e: Event) {
e.preventDefault();
if (!name.trim()) {
error = 'Name darf nicht leer sein';
return;
}
saving = true;
error = null;
try {
const result = await profileService.updateProfile({ name: name.trim() });
onSuccess(result.user);
onClose();
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Speichern';
} finally {
saving = false;
}
}
</script>
{#if show}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onclick={handleBackdropClick}
>
<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"
>
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</div>
<h3 class="text-lg font-semibold">Profil bearbeiten</h3>
</div>
<form onsubmit={handleSubmit}>
<div class="space-y-4">
<div>
<label for="profile-email" class="block text-sm font-medium mb-2">E-Mail</label>
<input
id="profile-email"
type="email"
value={user?.email || ''}
disabled
class="w-full px-3 py-2 border rounded-lg bg-muted text-muted-foreground cursor-not-allowed"
/>
<p class="mt-1 text-xs text-muted-foreground">E-Mail kann nicht geändert werden</p>
</div>
<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}
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">
<p class="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
{/if}
</div>
<div class="flex gap-3 mt-6">
<button
type="button"
onclick={onClose}
disabled={saving}
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}
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}
<svg class="animate-spin h-4 w-4" 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>
<span>Speichern...</span>
{:else}
Speichern
{/if}
</button>
</div>
</form>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,3 @@
export { default as EditProfileModal } from './EditProfileModal.svelte';
export { default as ChangePasswordModal } from './ChangePasswordModal.svelte';
export { default as DeleteAccountModal } from './DeleteAccountModal.svelte';

View file

@ -114,6 +114,42 @@
"total_spent": "Verbraucht",
"manage": "Credits verwalten"
},
"profile": {
"title": "Profil",
"edit": "Profil bearbeiten",
"change_password": "Passwort ändern",
"delete_account": "Konto löschen",
"logout": "Abmelden",
"name": "Name",
"email": "E-Mail",
"save": "Speichern",
"cancel": "Abbrechen",
"current_password": "Aktuelles Passwort",
"new_password": "Neues Passwort",
"confirm_password": "Passwort bestätigen",
"password_changed": "Passwort erfolgreich geändert",
"profile_updated": "Profil erfolgreich aktualisiert"
},
"subscription": {
"title": "Abonnement",
"current_plan": "Aktueller Plan",
"free_plan": "Free Plan",
"upgrade": "Upgrade",
"cancel": "Kündigen",
"reactivate": "Reaktivieren",
"billing_portal": "Zahlungsmethode verwalten",
"monthly": "Monatlich",
"yearly": "Jährlich",
"save_percent": "Spare {percent}%",
"per_month": "/ Monat",
"per_year": "/ Jahr",
"mana_per_month": "Mana / Monat",
"features": "Features",
"select_plan": "Auswählen",
"current": "Aktuell",
"invoices": "Rechnungen",
"no_invoices": "Noch keine Rechnungen vorhanden"
},
"app_slider": {
"title": "Teil des Mana Ökosystems",
"memoro_desc": "KI-gestützte Sprachnotizen",

View file

@ -114,6 +114,42 @@
"total_spent": "Spent",
"manage": "Manage credits"
},
"profile": {
"title": "Profile",
"edit": "Edit profile",
"change_password": "Change password",
"delete_account": "Delete account",
"logout": "Log out",
"name": "Name",
"email": "Email",
"save": "Save",
"cancel": "Cancel",
"current_password": "Current password",
"new_password": "New password",
"confirm_password": "Confirm password",
"password_changed": "Password changed successfully",
"profile_updated": "Profile updated successfully"
},
"subscription": {
"title": "Subscription",
"current_plan": "Current plan",
"free_plan": "Free Plan",
"upgrade": "Upgrade",
"cancel": "Cancel",
"reactivate": "Reactivate",
"billing_portal": "Manage payment method",
"monthly": "Monthly",
"yearly": "Yearly",
"save_percent": "Save {percent}%",
"per_month": "/ month",
"per_year": "/ year",
"mana_per_month": "Mana / month",
"features": "Features",
"select_plan": "Select",
"current": "Current",
"invoices": "Invoices",
"no_invoices": "No invoices yet"
},
"app_slider": {
"title": "Part of the Mana Ecosystem",
"memoro_desc": "AI-powered voice notes",

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { Card, PageHeader } from '@manacore/shared-ui';
import {
creditsService,
@ -14,6 +15,31 @@
let loading = $state(true);
let error = $state<string | null>(null);
let activeTab = $state<'overview' | 'transactions' | 'packages'>('overview');
let processingPackageId = $state<string | null>(null);
// Toast notification
let toastMessage = $state<string | null>(null);
let toastType = $state<'success' | 'error'>('success');
// Handle tab from URL params
$effect(() => {
const tab = $page.url.searchParams.get('tab');
if (tab === 'packages') activeTab = 'packages';
else if (tab === 'transactions') activeTab = 'transactions';
// Handle success/canceled from Stripe redirect
const success = $page.url.searchParams.get('success');
const canceled = $page.url.searchParams.get('canceled');
if (success === 'true') {
showToast('Credits wurden erfolgreich gekauft!', 'success');
loadData();
window.history.replaceState({}, '', '/credits');
} else if (canceled === 'true') {
showToast('Kauf wurde abgebrochen', 'error');
window.history.replaceState({}, '', '/credits');
}
});
onMount(async () => {
await loadData();
@ -90,11 +116,25 @@
}
}
function handleBuyPackage(pkg: CreditPackage) {
// TODO: Integrate with Stripe
alert(
`Paket "${pkg.name}" kaufen\n\n${formatCredits(pkg.credits)} Credits für ${formatPrice(pkg.priceEuroCents)}\n\nStripe-Integration kommt bald!`
);
async function handleBuyPackage(pkg: CreditPackage) {
processingPackageId = pkg.id;
try {
const result = await creditsService.initiatePurchase(pkg.id);
// Redirect to Stripe Checkout
window.location.href = result.checkoutUrl;
} catch (e) {
showToast(e instanceof Error ? e.message : 'Fehler beim Erstellen der Checkout-Session', 'error');
} finally {
processingPackageId = null;
}
}
function showToast(message: string, type: 'success' | 'error') {
toastMessage = message;
toastType = type;
setTimeout(() => {
toastMessage = null;
}, 4000);
}
</script>
@ -230,7 +270,8 @@
{#each packages.slice(0, 3) as pkg}
<button
onclick={() => handleBuyPackage(pkg)}
class="w-full flex items-center justify-between p-3 rounded-lg border border-border hover:bg-surface-hover transition-colors"
disabled={processingPackageId === pkg.id}
class="w-full flex items-center justify-between p-3 rounded-lg border border-border hover:bg-surface-hover transition-colors disabled:opacity-50"
>
<div class="text-left">
<p class="font-medium">{pkg.name}</p>
@ -238,7 +279,14 @@
{formatCredits(pkg.credits)} Credits
</p>
</div>
<span class="font-semibold text-primary">{formatPrice(pkg.priceEuroCents)}</span>
{#if processingPackageId === pkg.id}
<svg class="animate-spin h-5 w-5 text-primary" 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>
{:else}
<span class="font-semibold text-primary">{formatPrice(pkg.priceEuroCents)}</span>
{/if}
</button>
{/each}
</div>
@ -309,9 +357,18 @@
</p>
<button
onclick={() => handleBuyPackage(pkg)}
class="mt-4 w-full py-2 px-4 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium"
disabled={processingPackageId === pkg.id}
class="mt-4 w-full py-2 px-4 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium disabled:opacity-50 flex items-center justify-center gap-2"
>
Kaufen
{#if processingPackageId === pkg.id}
<svg class="animate-spin h-4 w-4" 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>
Wird geladen...
{:else}
Kaufen
{/if}
</button>
</div>
</Card>
@ -327,3 +384,30 @@
{/if}
{/if}
</div>
<!-- Toast Notification -->
{#if toastMessage}
<div
class="fixed bottom-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg animate-fade-in {toastType === 'success'
? 'bg-green-600 text-white'
: 'bg-red-600 text-white'}"
>
{toastMessage}
</div>
{/if}
<style>
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
</style>

View file

@ -1,43 +1,152 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ProfilePage } from '@manacore/shared-profile-ui';
import type { UserProfile, ProfileActions } from '@manacore/shared-profile-ui';
import { authStore } from '$lib/stores/auth.svelte';
import { goto } from '$app/navigation';
import { profileService, type UserProfile as ApiUserProfile } from '$lib/api/profile';
import { EditProfileModal, ChangePasswordModal, DeleteAccountModal } from '$lib/components/profile';
// Map auth store user to UserProfile
// Profile data from API
let apiProfile = $state<ApiUserProfile | null>(null);
let loading = $state(true);
// Modal states
let showEditModal = $state(false);
let showPasswordModal = $state(false);
let showDeleteModal = $state(false);
// Toast notification
let toastMessage = $state<string | null>(null);
onMount(async () => {
await loadProfile();
});
async function loadProfile() {
try {
apiProfile = await profileService.getProfile();
} catch (e) {
console.error('Failed to load profile:', e);
} finally {
loading = false;
}
}
// Map auth store user to UserProfile (use API profile when available)
let userProfile = $derived<UserProfile>({
id: authStore.user?.id || '',
email: authStore.user?.email || '',
role: authStore.user?.role,
id: apiProfile?.id || authStore.user?.id || '',
email: apiProfile?.email || authStore.user?.email || '',
displayName: apiProfile?.name || undefined,
role: apiProfile?.role || authStore.user?.role,
createdAt: apiProfile?.createdAt,
});
// Profile actions
const actions: ProfileActions = {
onEditProfile: () => {
showEditModal = true;
},
onChangePassword: () => {
showPasswordModal = true;
},
onLogout: async () => {
await authStore.signOut();
goto('/login');
},
onDeleteAccount: () => {
alert('Konto löschen ist noch nicht implementiert.');
showDeleteModal = true;
},
};
function handleProfileUpdate(user: ApiUserProfile) {
apiProfile = user;
showToast('Profil erfolgreich aktualisiert');
}
function handlePasswordChange() {
showToast('Passwort erfolgreich geändert');
}
async function handleAccountDeleted() {
showToast('Konto wird gelöscht...');
await authStore.signOut();
goto('/login');
}
function showToast(message: string) {
toastMessage = message;
setTimeout(() => {
toastMessage = null;
}, 3000);
}
</script>
<ProfilePage
user={userProfile}
appName="ManaCore"
{actions}
pageTitle="Profil"
accountInfoTitle="Konto-Informationen"
actionsTitle="Aktionen"
emailLabel="E-Mail"
nameLabel="Name"
memberSinceLabel="Mitglied seit"
lastLoginLabel="Letzter Login"
roleLabel="Rolle"
editProfileLabel="Profil bearbeiten"
changePasswordLabel="Passwort ändern"
logoutLabel="Abmelden"
deleteAccountLabel="Konto löschen"
deleteAccountWarning="Diese Aktion kann nicht rückgängig gemacht werden."
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
</div>
{:else}
<ProfilePage
user={userProfile}
appName="ManaCore"
{actions}
pageTitle="Profil"
accountInfoTitle="Konto-Informationen"
actionsTitle="Aktionen"
emailLabel="E-Mail"
nameLabel="Name"
memberSinceLabel="Mitglied seit"
lastLoginLabel="Letzter Login"
roleLabel="Rolle"
editProfileLabel="Profil bearbeiten"
changePasswordLabel="Passwort ändern"
logoutLabel="Abmelden"
deleteAccountLabel="Konto löschen"
deleteAccountWarning="Diese Aktion kann nicht rückgängig gemacht werden."
/>
{/if}
<!-- Modals -->
<EditProfileModal
show={showEditModal}
user={apiProfile}
onClose={() => (showEditModal = false)}
onSuccess={handleProfileUpdate}
/>
<ChangePasswordModal
show={showPasswordModal}
onClose={() => (showPasswordModal = false)}
onSuccess={handlePasswordChange}
/>
<DeleteAccountModal
show={showDeleteModal}
userEmail={apiProfile?.email || authStore.user?.email || ''}
onClose={() => (showDeleteModal = false)}
onSuccess={handleAccountDeleted}
/>
<!-- Toast Notification -->
{#if toastMessage}
<div class="fixed bottom-4 right-4 z-50 px-4 py-3 bg-green-600 text-white rounded-lg shadow-lg animate-fade-in">
{toastMessage}
</div>
{/if}
<style>
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
</style>

View file

@ -0,0 +1,505 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { Card, PageHeader } from '@manacore/shared-ui';
import {
subscriptionsService,
type SubscriptionPlan,
type CurrentSubscription,
type Invoice,
} from '$lib/api/subscriptions';
let plans = $state<SubscriptionPlan[]>([]);
let currentSubscription = $state<CurrentSubscription | null>(null);
let invoices = $state<Invoice[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let activeTab = $state<'plans' | 'billing' | 'invoices'>('plans');
let billingInterval = $state<'month' | 'year'>('month');
let processingPlanId = $state<string | null>(null);
let cancelingSubscription = $state(false);
let reactivatingSubscription = $state(false);
let openingPortal = $state(false);
// Toast notification
let toastMessage = $state<string | null>(null);
let toastType = $state<'success' | 'error'>('success');
// Handle success/canceled from Stripe redirect
$effect(() => {
const success = $page.url.searchParams.get('success');
const canceled = $page.url.searchParams.get('canceled');
if (success === 'true') {
showToast('Abonnement erfolgreich abgeschlossen!', 'success');
// Reload data
loadData();
// Clean URL
window.history.replaceState({}, '', '/subscription');
} else if (canceled === 'true') {
showToast('Checkout wurde abgebrochen', 'error');
window.history.replaceState({}, '', '/subscription');
}
});
onMount(async () => {
await loadData();
});
async function loadData() {
loading = true;
error = null;
try {
const [plansData, subscriptionData, invoicesData] = await Promise.all([
subscriptionsService.getPlans(),
subscriptionsService.getCurrentSubscription(),
subscriptionsService.getInvoices(10),
]);
plans = plansData.filter((p) => p.active).sort((a, b) => a.sortOrder - b.sortOrder);
currentSubscription = subscriptionData;
invoices = invoicesData;
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Laden der Daten';
console.error('Failed to load subscription data:', e);
} finally {
loading = false;
}
}
function formatPrice(cents: number, interval: 'month' | 'year'): string {
const amount = cents / 100;
return amount.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
}
function formatMonthlyEquivalent(yearlyCents: number): string {
const monthlyAmount = yearlyCents / 12 / 100;
return monthlyAmount.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
function getStatusBadge(status: string): { text: string; class: string } {
switch (status) {
case 'active':
return { text: 'Aktiv', class: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400' };
case 'canceled':
return { text: 'Gekündigt', class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400' };
case 'past_due':
return { text: 'Überfällig', class: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400' };
case 'trialing':
return { text: 'Testphase', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400' };
default:
return { text: status, class: 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400' };
}
}
function getSavingsPercent(monthly: number, yearly: number): number {
const yearlyFromMonthly = monthly * 12;
if (yearlyFromMonthly === 0) return 0;
return Math.round(((yearlyFromMonthly - yearly) / yearlyFromMonthly) * 100);
}
async function handleSelectPlan(plan: SubscriptionPlan) {
if (plan.isDefault) return; // Free plan, no checkout needed
processingPlanId = plan.id;
try {
const { url } = await subscriptionsService.createCheckout(plan.id, billingInterval);
window.location.href = url;
} catch (e) {
showToast(e instanceof Error ? e.message : 'Fehler beim Erstellen der Checkout-Session', 'error');
} finally {
processingPlanId = null;
}
}
async function handleOpenPortal() {
openingPortal = true;
try {
const { url } = await subscriptionsService.openPortal();
window.location.href = url;
} catch (e) {
showToast(e instanceof Error ? e.message : 'Fehler beim Öffnen des Billing-Portals', 'error');
} finally {
openingPortal = false;
}
}
async function handleCancelSubscription() {
if (!confirm('Möchtest du dein Abonnement wirklich kündigen? Du kannst es bis zum Ende der Laufzeit weiter nutzen.')) {
return;
}
cancelingSubscription = true;
try {
await subscriptionsService.cancelSubscription();
showToast('Abonnement wird zum Ende der Laufzeit gekündigt', 'success');
await loadData();
} catch (e) {
showToast(e instanceof Error ? e.message : 'Fehler beim Kündigen', 'error');
} finally {
cancelingSubscription = false;
}
}
async function handleReactivateSubscription() {
reactivatingSubscription = true;
try {
await subscriptionsService.reactivateSubscription();
showToast('Abonnement wurde reaktiviert', 'success');
await loadData();
} catch (e) {
showToast(e instanceof Error ? e.message : 'Fehler beim Reaktivieren', 'error');
} finally {
reactivatingSubscription = false;
}
}
function showToast(message: string, type: 'success' | 'error') {
toastMessage = message;
toastType = type;
setTimeout(() => {
toastMessage = null;
}, 4000);
}
</script>
<div>
<PageHeader
title="Abonnement"
description="Verwalte dein Abonnement und sieh dir deine Rechnungen an"
size="lg"
/>
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
</div>
{:else if error}
<Card>
<div class="text-center py-8">
<p class="text-red-500 mb-4">{error}</p>
<button
onclick={loadData}
class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90"
>
Erneut versuchen
</button>
</div>
</Card>
{:else}
<!-- Current Subscription Status -->
{#if currentSubscription?.subscription}
{@const sub = currentSubscription.subscription}
{@const plan = currentSubscription.plan}
{@const status = getStatusBadge(sub.status)}
<Card>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<div class="flex items-center gap-3">
<h2 class="text-xl font-bold">{plan?.name || 'Aktueller Plan'}</h2>
<span class="px-2 py-0.5 text-xs font-medium rounded-full {status.class}">
{status.text}
</span>
</div>
<p class="text-sm text-muted-foreground mt-1">
{plan?.monthlyCredits.toLocaleString('de-DE')} Mana / Monat
</p>
</div>
<div class="flex gap-2">
<button
onclick={handleOpenPortal}
disabled={openingPortal}
class="px-4 py-2 border rounded-lg hover:bg-muted transition-colors disabled:opacity-50 flex items-center gap-2"
>
{#if openingPortal}
<svg class="animate-spin h-4 w-4" 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>
{/if}
Zahlungsmethode verwalten
</button>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-3 pt-4 border-t border-border">
<div>
<p class="text-sm text-muted-foreground">Abrechnungszeitraum</p>
<p class="font-medium">{sub.billingInterval === 'year' ? 'Jährlich' : 'Monatlich'}</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Aktuelle Periode</p>
<p class="font-medium">
{formatDate(sub.currentPeriodStart)} - {formatDate(sub.currentPeriodEnd)}
</p>
</div>
<div>
{#if sub.cancelAtPeriodEnd}
<p class="text-sm text-muted-foreground">Endet am</p>
<p class="font-medium text-yellow-600">{formatDate(sub.currentPeriodEnd)}</p>
<button
onclick={handleReactivateSubscription}
disabled={reactivatingSubscription}
class="mt-2 text-sm text-primary hover:underline disabled:opacity-50"
>
{reactivatingSubscription ? 'Wird reaktiviert...' : 'Reaktivieren'}
</button>
{:else}
<p class="text-sm text-muted-foreground">Verlängert am</p>
<p class="font-medium">{formatDate(sub.currentPeriodEnd)}</p>
<button
onclick={handleCancelSubscription}
disabled={cancelingSubscription}
class="mt-2 text-sm text-red-500 hover:underline disabled:opacity-50"
>
{cancelingSubscription ? 'Wird gekündigt...' : 'Kündigen'}
</button>
{/if}
</div>
</div>
</Card>
{:else}
<!-- Free Plan Info -->
<Card>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<div class="flex items-center gap-3">
<h2 class="text-xl font-bold">Free Plan</h2>
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400">
Aktuell
</span>
</div>
<p class="text-sm text-muted-foreground mt-1">
150 Mana / Monat
</p>
</div>
<p class="text-sm text-muted-foreground">
Upgrade auf einen bezahlten Plan für mehr Mana und Features.
</p>
</div>
</Card>
{/if}
<!-- Tabs -->
<div class="flex gap-2 mt-8 mb-6 border-b border-border">
<button
onclick={() => (activeTab = 'plans')}
class="px-4 py-2 -mb-px transition-colors {activeTab === 'plans'
? 'border-b-2 border-primary text-primary font-medium'
: 'text-muted-foreground hover:text-foreground'}"
>
Pläne
</button>
<button
onclick={() => (activeTab = 'invoices')}
class="px-4 py-2 -mb-px transition-colors {activeTab === 'invoices'
? 'border-b-2 border-primary text-primary font-medium'
: 'text-muted-foreground hover:text-foreground'}"
>
Rechnungen
</button>
</div>
<!-- Tab Content -->
{#if activeTab === 'plans'}
<!-- Billing Interval Toggle -->
<div class="flex justify-center mb-8">
<div class="inline-flex items-center gap-2 p-1 bg-muted rounded-lg">
<button
onclick={() => (billingInterval = 'month')}
class="px-4 py-2 rounded-md text-sm font-medium transition-colors {billingInterval === 'month'
? 'bg-background shadow-sm'
: 'hover:text-foreground'}"
>
Monatlich
</button>
<button
onclick={() => (billingInterval = 'year')}
class="px-4 py-2 rounded-md text-sm font-medium transition-colors {billingInterval === 'year'
? 'bg-background shadow-sm'
: 'hover:text-foreground'}"
>
Jährlich
<span class="ml-1 text-xs text-green-600">Spare 17%</span>
</button>
</div>
</div>
<!-- Plans Grid -->
<div class="grid gap-6 md:grid-cols-3">
{#each plans as plan}
{@const isCurrentPlan = currentSubscription?.plan?.id === plan.id}
{@const price = billingInterval === 'year' ? plan.priceYearlyEuroCents : plan.priceMonthlyEuroCents}
{@const savings = getSavingsPercent(plan.priceMonthlyEuroCents, plan.priceYearlyEuroCents)}
<Card>
<div class="text-center p-2">
{#if isCurrentPlan}
<span class="inline-block px-3 py-1 text-xs font-medium rounded-full bg-primary/10 text-primary mb-4">
Dein Plan
</span>
{/if}
<h3 class="text-xl font-bold">{plan.name}</h3>
{#if plan.description}
<p class="text-sm text-muted-foreground mt-1">{plan.description}</p>
{/if}
<div class="mt-6">
<span class="text-4xl font-bold">
{plan.isDefault ? 'Kostenlos' : formatPrice(price, billingInterval)}
</span>
{#if !plan.isDefault}
<span class="text-muted-foreground">
/ {billingInterval === 'year' ? 'Jahr' : 'Monat'}
</span>
{#if billingInterval === 'year' && savings > 0}
<p class="text-sm text-green-600 mt-1">
{formatMonthlyEquivalent(plan.priceYearlyEuroCents)} / Monat
</p>
{/if}
{/if}
</div>
<p class="text-lg font-semibold text-primary mt-4">
{plan.monthlyCredits.toLocaleString('de-DE')} Mana / Monat
</p>
{#if plan.features && plan.features.length > 0}
<ul class="mt-6 space-y-3 text-left">
{#each plan.features as feature}
<li class="flex items-start gap-2 text-sm">
<svg class="h-5 w-5 text-green-500 flex-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>{feature}</span>
</li>
{/each}
</ul>
{/if}
<button
onclick={() => handleSelectPlan(plan)}
disabled={isCurrentPlan || processingPlanId === plan.id || plan.isDefault}
class="mt-6 w-full py-2 px-4 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2
{isCurrentPlan || plan.isDefault
? 'bg-muted text-muted-foreground'
: 'bg-primary text-primary-foreground hover:bg-primary/90'}"
>
{#if processingPlanId === plan.id}
<svg class="animate-spin h-4 w-4" 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>
Wird verarbeitet...
{:else if isCurrentPlan}
Aktueller Plan
{:else if plan.isDefault}
Kostenlos
{:else}
Auswählen
{/if}
</button>
</div>
</Card>
{/each}
</div>
{:else if activeTab === 'invoices'}
<Card>
<h3 class="text-lg font-semibold mb-4">Rechnungsverlauf</h3>
{#if invoices.length === 0}
<p class="text-muted-foreground text-center py-8">Noch keine Rechnungen vorhanden.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-border text-left text-sm text-muted-foreground">
<th class="pb-3 pr-4">Nummer</th>
<th class="pb-3 pr-4">Datum</th>
<th class="pb-3 pr-4">Zeitraum</th>
<th class="pb-3 pr-4 text-right">Betrag</th>
<th class="pb-3 pr-4">Status</th>
<th class="pb-3"></th>
</tr>
</thead>
<tbody>
{#each invoices as invoice}
<tr class="border-b border-border last:border-0">
<td class="py-3 pr-4 font-mono text-sm">{invoice.number || '-'}</td>
<td class="py-3 pr-4 text-sm">{formatDate(invoice.createdAt)}</td>
<td class="py-3 pr-4 text-sm text-muted-foreground">
{#if invoice.periodStart && invoice.periodEnd}
{formatDate(invoice.periodStart)} - {formatDate(invoice.periodEnd)}
{:else}
-
{/if}
</td>
<td class="py-3 pr-4 text-right font-medium">
{formatPrice(invoice.amountPaidEuroCents, 'month')}
</td>
<td class="py-3 pr-4">
{#if invoice.status === 'paid'}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400">
Bezahlt
</span>
{:else}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400">
{invoice.status}
</span>
{/if}
</td>
<td class="py-3">
{#if invoice.invoicePdfUrl}
<a
href={invoice.invoicePdfUrl}
target="_blank"
rel="noopener noreferrer"
class="text-sm text-primary hover:underline"
>
PDF
</a>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</Card>
{/if}
{/if}
</div>
<!-- Toast Notification -->
{#if toastMessage}
<div
class="fixed bottom-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg animate-fade-in {toastType === 'success'
? 'bg-green-600 text-white'
: 'bg-red-600 text-white'}"
>
{toastMessage}
</div>
{/if}
<style>
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
</style>