mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(credits): merge Credits + Mana subscription into one workbench app
The former /credits (balance, transactions, Stripe checkout, cost breakdown) and /mana (subscription plans placeholder) were separate pages covering the same billing domain. Merge into a single workbench app "Credits & Abo" with 5 tabs: 1. Übersicht — balance cards + recent transactions + quick-buy 2. Abonnements — SubscriptionPage from @mana/subscriptions 3. Transaktionen — full transaction history table 4. Kaufen — Stripe-integrated package cards 5. Kosten — per-operation cost breakdown with category filter Stripe redirect handling: Stripe returns to /?app=credits&success=true. The deep-link handler opens the app and strips ?app; the ListView reads ?success / ?canceled from window.location.search on mount, shows the appropriate toast, and cleans the URL via history.replaceState. - Delete /credits/+page.svelte and lib/modules/mana/ (placeholder) - Register workbench app id='credits' (Crown icon, amber) - Replace mana app registration (no longer needed) - Update all links: command menu, PillNavigation (manaHref + creditsHref both → /?app=credits), CreditsSection, CreditsWidget, TransactionsWidget, CompleteStep, sync-status, sync billing page, gift redeem page Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
23b8cc13fb
commit
27ac5fc23e
12 changed files with 1033 additions and 648 deletions
|
|
@ -1089,12 +1089,12 @@ registerApp({
|
|||
});
|
||||
|
||||
registerApp({
|
||||
id: 'mana',
|
||||
name: 'Mana',
|
||||
id: 'credits',
|
||||
name: 'Credits & Abo',
|
||||
color: '#F59E0B',
|
||||
icon: Crown,
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/mana/ListView.svelte') },
|
||||
list: { load: () => import('$lib/modules/credits/ListView.svelte') },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@
|
|||
<span class="text-2xl font-bold">{formatCredits(data.balance)}</span>
|
||||
</div>
|
||||
<a
|
||||
href="/credits"
|
||||
href="/?app=credits"
|
||||
class="mt-4 block w-full rounded-lg bg-primary/10 py-2 text-center text-sm font-medium text-primary hover:bg-primary/20"
|
||||
>
|
||||
{$_('dashboard.widgets.credits.manage')}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@
|
|||
<span>📊</span>
|
||||
{$_('dashboard.widgets.transactions.title')}
|
||||
</h3>
|
||||
<a href="/credits?tab=transactions" class="text-sm text-primary hover:underline">
|
||||
<a href="/?app=credits&tab=transactions" class="text-sm text-primary hover:underline">
|
||||
{$_('common.view_all')} →
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export function useSyncStatusItems() {
|
|||
id: 'sync-paused',
|
||||
label: 'Sync pausiert — Credits aufladen',
|
||||
icon: 'bell',
|
||||
onClick: () => goto('/credits?tab=packages'),
|
||||
onClick: () => goto('/?app=credits&tab=packages'),
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
</a>
|
||||
|
||||
<a
|
||||
href="/credits"
|
||||
href="/?app=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">
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
tone="yellow"
|
||||
>
|
||||
{#snippet action()}
|
||||
<a href="/credits" class="text-sm text-primary hover:underline">Alle Details</a>
|
||||
<a href="/?app=credits" class="text-sm text-primary hover:underline">Alle Details</a>
|
||||
{/snippet}
|
||||
</SettingsSectionHeader>
|
||||
|
||||
|
|
@ -60,13 +60,13 @@
|
|||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<a
|
||||
href="/credits?tab=packages"
|
||||
href="/?app=credits&tab=packages"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Credits kaufen
|
||||
</a>
|
||||
<a
|
||||
href="/credits?tab=transactions"
|
||||
href="/?app=credits&tab=transactions"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
Transaktionen
|
||||
|
|
|
|||
1007
apps/mana/apps/web/src/lib/modules/credits/ListView.svelte
Normal file
1007
apps/mana/apps/web/src/lib/modules/credits/ListView.svelte
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,50 +0,0 @@
|
|||
<!--
|
||||
Mana — Workbench-embedded subscription / pricing page.
|
||||
Shows available plans and one-time credit packages.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { SubscriptionPage } from '@mana/subscriptions';
|
||||
|
||||
let toastMessage = $state<string | null>(null);
|
||||
|
||||
function handleSubscribe(planId: string) {
|
||||
toastMessage = `Abo "${planId}" ausgewahlt. Bezahlsystem wird in Kurze integriert.`;
|
||||
setTimeout(() => (toastMessage = null), 4000);
|
||||
}
|
||||
|
||||
function handleBuyPackage(packageId: string) {
|
||||
toastMessage = `Paket "${packageId}" ausgewahlt. Bezahlsystem wird in Kurze integriert.`;
|
||||
setTimeout(() => (toastMessage = null), 4000);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if toastMessage}
|
||||
<div
|
||||
class="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-lg bg-amber-600 px-5 py-3 text-sm font-medium text-white shadow-lg"
|
||||
>
|
||||
{toastMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mana-page">
|
||||
<SubscriptionPage
|
||||
appName="Mana"
|
||||
onSubscribe={handleSubscribe}
|
||||
onBuyPackage={handleBuyPackage}
|
||||
currentPlanId="free"
|
||||
pageTitle="Wähle dein Abo"
|
||||
subscriptionsTitle="Abonnements"
|
||||
packagesTitle="Einmal-Pakete"
|
||||
yearlyDiscount="20% Rabatt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mana-page {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -740,7 +740,12 @@
|
|||
category: 'Navigation',
|
||||
onExecute: () => goto('/?app=spiral'),
|
||||
},
|
||||
{ id: 'credits', label: 'Credits', category: 'Navigation', onExecute: () => goto('/credits') },
|
||||
{
|
||||
id: 'credits',
|
||||
label: 'Credits',
|
||||
category: 'Navigation',
|
||||
onExecute: () => goto('/?app=credits'),
|
||||
},
|
||||
{ id: 'apps', label: 'Alle Apps', category: 'Navigation', onExecute: () => goto('/apps') },
|
||||
{
|
||||
id: 'settings',
|
||||
|
|
@ -805,7 +810,10 @@
|
|||
>
|
||||
<span>Cloud Sync pausiert — Credits reichen nicht aus.</span>
|
||||
<div class="flex gap-2">
|
||||
<a href="/credits?tab=packages" class="font-medium underline hover:no-underline">
|
||||
<a
|
||||
href="/?app=credits&tab=packages"
|
||||
class="font-medium underline hover:no-underline"
|
||||
>
|
||||
Credits aufladen
|
||||
</a>
|
||||
<a href="/settings/sync" class="font-medium underline hover:no-underline">
|
||||
|
|
@ -974,10 +982,10 @@
|
|||
currentSyncLabel={syncStatus.label}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
manaHref="/?app=mana"
|
||||
manaHref="/?app=credits"
|
||||
profileHref="/?app=profile"
|
||||
spiralHref="/?app=spiral"
|
||||
creditsHref="/credits"
|
||||
creditsHref="/?app=credits"
|
||||
themesHref="/?app=themes"
|
||||
helpHref="/?app=help"
|
||||
allAppsHref="/apps"
|
||||
|
|
|
|||
|
|
@ -1,582 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { Card, PageHeader } from '@mana/shared-ui';
|
||||
import {
|
||||
creditsService,
|
||||
type CreditBalance,
|
||||
type CreditTransaction,
|
||||
type CreditPackage,
|
||||
} from '$lib/api/credits';
|
||||
import {
|
||||
OPERATION_METADATA,
|
||||
CREDIT_COSTS,
|
||||
CreditCategory,
|
||||
formatCreditCost,
|
||||
type CreditOperationType,
|
||||
} from '@mana/credits';
|
||||
import { ManaEvents } from '@mana/shared-utils/analytics';
|
||||
|
||||
let balance = $state<CreditBalance | null>(null);
|
||||
let transactions = $state<CreditTransaction[]>([]);
|
||||
let packages = $state<CreditPackage[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let activeTab = $state<'overview' | 'transactions' | 'packages' | 'costs'>('overview');
|
||||
let costFilter = $state<'all' | 'ai' | 'premium'>('all');
|
||||
|
||||
// Build pricing data grouped by app
|
||||
const allOperations = $derived(
|
||||
Object.entries(OPERATION_METADATA).map(([op, meta]) => ({
|
||||
operation: op as CreditOperationType,
|
||||
name: meta.name,
|
||||
description: meta.description,
|
||||
category: meta.category,
|
||||
app: meta.app,
|
||||
cost: CREDIT_COSTS[op as CreditOperationType],
|
||||
formattedCost: formatCreditCost(CREDIT_COSTS[op as CreditOperationType]),
|
||||
}))
|
||||
);
|
||||
|
||||
const filteredOperations = $derived(
|
||||
costFilter === 'all'
|
||||
? allOperations
|
||||
: allOperations.filter((op) => {
|
||||
if (costFilter === 'ai') return op.category === CreditCategory.AI;
|
||||
if (costFilter === 'premium') return op.category === CreditCategory.PREMIUM;
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
const operationsByApp = $derived(() => {
|
||||
const groups: Record<string, typeof filteredOperations> = {};
|
||||
for (const op of filteredOperations) {
|
||||
if (!groups[op.app]) groups[op.app] = [];
|
||||
groups[op.app].push(op);
|
||||
}
|
||||
// Sort apps alphabetically
|
||||
return Object.fromEntries(Object.entries(groups).sort(([a], [b]) => a.localeCompare(b)));
|
||||
});
|
||||
|
||||
const APP_LABELS: Record<string, string> = {
|
||||
calendar: 'Kalender',
|
||||
chat: 'Chat',
|
||||
contacts: 'Kontakte',
|
||||
context: 'Context',
|
||||
general: 'Allgemein',
|
||||
cards: 'Cards',
|
||||
food: 'Food',
|
||||
picture: 'Picture',
|
||||
plants: 'Plants',
|
||||
presi: 'Presi',
|
||||
questions: 'Questions',
|
||||
skilltree: 'SkillTree',
|
||||
todo: 'Todo',
|
||||
traces: 'Traces',
|
||||
quotes: 'Quotes',
|
||||
};
|
||||
|
||||
function getAppLabel(app: string): string {
|
||||
return APP_LABELS[app] ?? app.charAt(0).toUpperCase() + app.slice(1);
|
||||
}
|
||||
|
||||
function getCategoryLabel(category: CreditCategory): string {
|
||||
switch (category) {
|
||||
case CreditCategory.AI:
|
||||
return 'KI-Features';
|
||||
case CreditCategory.PREMIUM:
|
||||
return 'Premium';
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
}
|
||||
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';
|
||||
else if (tab === 'costs') activeTab = 'costs';
|
||||
if (activeTab !== 'overview') ManaEvents.creditsTabViewed(activeTab);
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const [balanceData, transactionsData, packagesData] = await Promise.all([
|
||||
creditsService.getBalance(),
|
||||
creditsService.getTransactions(20),
|
||||
creditsService.getPackages(),
|
||||
]);
|
||||
balance = balanceData;
|
||||
transactions = transactionsData;
|
||||
packages = packagesData.filter((p) => p.active).sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $_('common.error_loading');
|
||||
console.error('Failed to load credits data:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCredits(amount: number): string {
|
||||
return amount.toLocaleString('de-DE');
|
||||
}
|
||||
|
||||
function formatPrice(cents: number): string {
|
||||
return (cents / 100).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',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function getTransactionIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'purchase':
|
||||
return '💳';
|
||||
case 'usage':
|
||||
return '⚡';
|
||||
case 'refund':
|
||||
return '↩️';
|
||||
case 'gift':
|
||||
return '🎁';
|
||||
default:
|
||||
return '📝';
|
||||
}
|
||||
}
|
||||
|
||||
function getTransactionColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'purchase':
|
||||
case 'gift':
|
||||
case 'refund':
|
||||
return 'text-green-600 dark:text-green-400';
|
||||
case 'usage':
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
default:
|
||||
return 'text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<div>
|
||||
<PageHeader title="Credits" backHref="/" sticky 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}
|
||||
<!-- Balance Overview Cards -->
|
||||
<div class="grid gap-4 sm:grid-cols-3 mb-8">
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-muted-foreground">Verfügbare Credits</p>
|
||||
<p class="text-3xl font-bold text-primary mt-1">
|
||||
{formatCredits(balance?.balance ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-muted-foreground">Gesamt erhalten</p>
|
||||
<p class="text-3xl font-bold mt-1">
|
||||
{formatCredits(balance?.totalEarned ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-muted-foreground">Gesamt verbraucht</p>
|
||||
<p class="text-3xl font-bold mt-1">
|
||||
{formatCredits(balance?.totalSpent ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-2 mb-6 border-b border-border">
|
||||
<button
|
||||
onclick={() => (activeTab = 'overview')}
|
||||
class="px-4 py-2 -mb-px transition-colors {activeTab === 'overview'
|
||||
? 'border-b-2 border-primary text-primary font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
Übersicht
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (activeTab = 'transactions')}
|
||||
class="px-4 py-2 -mb-px transition-colors {activeTab === 'transactions'
|
||||
? 'border-b-2 border-primary text-primary font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
Transaktionen
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (activeTab = 'packages')}
|
||||
class="px-4 py-2 -mb-px transition-colors {activeTab === 'packages'
|
||||
? 'border-b-2 border-primary text-primary font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
Credits kaufen
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (activeTab = 'costs')}
|
||||
class="px-4 py-2 -mb-px transition-colors {activeTab === 'costs'
|
||||
? 'border-b-2 border-primary text-primary font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
Kosten
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
{#if activeTab === 'overview'}
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<!-- Recent Transactions -->
|
||||
<Card>
|
||||
<h3 class="text-lg font-semibold mb-4">Letzte Transaktionen</h3>
|
||||
{#if transactions.length === 0}
|
||||
<p class="text-muted-foreground text-sm">Noch keine Transaktionen</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each transactions.slice(0, 5) as tx}
|
||||
<div
|
||||
class="flex items-center justify-between py-2 border-b border-border last:border-0"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl">{getTransactionIcon(tx.type)}</span>
|
||||
<div>
|
||||
<p class="font-medium text-sm">{tx.description || tx.type}</p>
|
||||
<p class="text-xs text-muted-foreground">{formatDate(tx.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="font-semibold {getTransactionColor(tx.type)}">
|
||||
{tx.amount > 0 ? '+' : ''}{formatCredits(tx.amount)}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
onclick={() => (activeTab = 'transactions')}
|
||||
class="mt-4 text-sm text-primary hover:underline"
|
||||
>
|
||||
Alle anzeigen →
|
||||
</button>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<!-- Quick Buy -->
|
||||
<Card>
|
||||
<h3 class="text-lg font-semibold mb-4">Credits kaufen</h3>
|
||||
{#if packages.length === 0}
|
||||
<p class="text-muted-foreground text-sm">Keine Pakete verfügbar</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each packages.slice(0, 3) as pkg}
|
||||
<button
|
||||
onclick={() => handleBuyPackage(pkg)}
|
||||
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>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{formatCredits(pkg.credits)} Credits
|
||||
</p>
|
||||
</div>
|
||||
{#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>
|
||||
<button
|
||||
onclick={() => (activeTab = 'packages')}
|
||||
class="mt-4 text-sm text-primary hover:underline"
|
||||
>
|
||||
Alle Pakete →
|
||||
</button>
|
||||
{/if}
|
||||
</Card>
|
||||
</div>
|
||||
{:else if activeTab === 'transactions'}
|
||||
<Card>
|
||||
<h3 class="text-lg font-semibold mb-4">Transaktionsverlauf</h3>
|
||||
{#if transactions.length === 0}
|
||||
<p class="text-muted-foreground">Noch keine Transaktionen 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">Typ</th>
|
||||
<th class="pb-3 pr-4">Beschreibung</th>
|
||||
<th class="pb-3 pr-4">App</th>
|
||||
<th class="pb-3 pr-4 text-right">Betrag</th>
|
||||
<th class="pb-3 pr-4 text-right">Kontostand</th>
|
||||
<th class="pb-3">Datum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each transactions as tx}
|
||||
<tr class="border-b border-border last:border-0">
|
||||
<td class="py-3 pr-4">
|
||||
<span class="text-lg">{getTransactionIcon(tx.type)}</span>
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-sm">{tx.description || '-'}</td>
|
||||
<td class="py-3 pr-4 text-sm text-muted-foreground">{tx.appId || '-'}</td>
|
||||
<td class="py-3 pr-4 text-right font-medium {getTransactionColor(tx.type)}">
|
||||
{tx.amount > 0 ? '+' : ''}{formatCredits(tx.amount)}
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-right text-sm text-muted-foreground">
|
||||
{formatCredits(tx.balanceAfter)}
|
||||
</td>
|
||||
<td class="py-3 text-sm text-muted-foreground">{formatDate(tx.createdAt)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
{:else if activeTab === 'packages'}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each packages as pkg}
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<h3 class="text-xl font-bold">{pkg.name}</h3>
|
||||
{#if pkg.description}
|
||||
<p class="text-sm text-muted-foreground mt-1">{pkg.description}</p>
|
||||
{/if}
|
||||
<p class="text-4xl font-bold text-primary mt-4">
|
||||
{formatCredits(pkg.credits)}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">Credits</p>
|
||||
<p class="text-2xl font-semibold mt-4">
|
||||
{formatPrice(pkg.priceEuroCents)}
|
||||
</p>
|
||||
<button
|
||||
onclick={() => handleBuyPackage(pkg)}
|
||||
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"
|
||||
>
|
||||
{#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>
|
||||
{/each}
|
||||
</div>
|
||||
{#if packages.length === 0}
|
||||
<Card>
|
||||
<p class="text-center text-muted-foreground py-8">
|
||||
Aktuell sind keine Credit-Pakete verfügbar.
|
||||
</p>
|
||||
</Card>
|
||||
{/if}
|
||||
{:else if activeTab === 'costs'}
|
||||
<!-- Category Filter -->
|
||||
<div class="flex flex-wrap gap-2 mb-6">
|
||||
{#each [{ key: 'all', label: 'Alle' }, { key: 'ai', label: 'KI-Features' }, { key: 'premium', label: 'Premium' }] as filter}
|
||||
<button
|
||||
onclick={() => (costFilter = filter.key as typeof costFilter)}
|
||||
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors {costFilter ===
|
||||
filter.key
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-surface-hover text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div
|
||||
class="mb-6 p-4 rounded-lg bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800"
|
||||
>
|
||||
<p class="text-sm text-blue-800 dark:text-blue-200">
|
||||
Lesen, Bearbeiten, Löschen und Organisieren von Einträgen ist immer <strong
|
||||
>kostenlos</strong
|
||||
>. Credits werden nur für die unten aufgeführten Aktionen verbraucht.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Operations grouped by app -->
|
||||
{@const groups = operationsByApp()}
|
||||
<div class="space-y-4">
|
||||
{#each Object.entries(groups) as [app, operations]}
|
||||
<Card>
|
||||
<h3 class="text-lg font-semibold mb-4">{getAppLabel(app)}</h3>
|
||||
<div class="divide-y divide-border">
|
||||
{#each operations as op}
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<div class="flex-1 min-w-0 pr-4">
|
||||
<p class="font-medium text-sm">{op.name}</p>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">{op.description}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-shrink-0">
|
||||
<span class="text-xs text-muted-foreground"
|
||||
>{getCategoryLabel(op.category)}</span
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold tabular-nums {op.cost ===
|
||||
0
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: op.cost < 1
|
||||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'}"
|
||||
>
|
||||
{op.cost === 0 ? 'Kostenlos' : op.formattedCost}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
|
||||
{#if Object.keys(groups).length === 0}
|
||||
<Card>
|
||||
<p class="text-center text-muted-foreground py-8">
|
||||
Keine Operationen in dieser Kategorie.
|
||||
</p>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
{/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>
|
||||
|
|
@ -134,7 +134,7 @@
|
|||
</p>
|
||||
<div class="mt-8 flex justify-center gap-4">
|
||||
<a
|
||||
href="/credits"
|
||||
href="/?app=credits"
|
||||
class="rounded-lg bg-primary px-6 py-2 font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Zu meinen Credits
|
||||
|
|
|
|||
|
|
@ -198,7 +198,9 @@
|
|||
{#if balance !== null && balance.balance < SYNC_PRICES[selectedInterval]}
|
||||
<p class="mt-2 text-center text-sm text-amber-600 dark:text-amber-400">
|
||||
Nicht genügend Credits.
|
||||
<a href="/credits?tab=packages" class="underline hover:no-underline">Aufladen</a>
|
||||
<a href="/?app=credits&tab=packages" class="underline hover:no-underline"
|
||||
>Aufladen</a
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue