feat: enhance dashboard and canvas components with credit management features

- Integrated CreditOverview and RecentTransactions components into the dashboard for better credit visibility.
- Updated canvas toolbar to display current credit balance using CreditDisplay.
- Improved AI image and prompt nodes to show credit costs and handle credit availability checks during image generation.
- Added new queries for fetching recent transactions and monthly usage statistics to support dashboard features.
- Refactored existing code to streamline credit-related functionalities across components.
This commit is contained in:
2026-03-26 22:15:03 +01:00
parent 886a530f26
commit 8d62ee27a2
12 changed files with 796 additions and 297 deletions

View File

@@ -0,0 +1,140 @@
"use client";
import { useQuery } from "convex/react";
import { CreditCard } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { api } from "@/convex/_generated/api";
import { formatEurFromCents } from "@/lib/utils";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Tier-Config — monatliches Credit-Kontingent pro Tier (in Cent)
// ---------------------------------------------------------------------------
const TIER_MONTHLY_CREDITS: Record<string, number> = {
free: 50,
starter: 630,
pro: 3602,
business: 7623,
};
const TIER_BADGE_STYLES: Record<string, string> = {
free: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400",
starter: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400",
pro: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400",
business: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400",
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function CreditOverview() {
const balance = useQuery(api.credits.getBalance);
const subscription = useQuery(api.credits.getSubscription);
const usageStats = useQuery(api.credits.getUsageStats);
// ── Loading State ──────────────────────────────────────────────────────
if (
balance === undefined ||
subscription === undefined ||
usageStats === undefined
) {
return (
<div className="rounded-xl border bg-card p-6 shadow-sm shadow-foreground/3">
<div className="grid gap-6 sm:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-3">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="h-8 w-32 animate-pulse rounded bg-muted" />
<div className="h-3 w-20 animate-pulse rounded bg-muted" />
</div>
))}
</div>
</div>
);
}
// ── Computed Values ────────────────────────────────────────────────────
const tier = subscription.tier;
const monthlyCredits = TIER_MONTHLY_CREDITS[tier] ?? 0;
const usagePercent = monthlyCredits > 0
? Math.min(100, Math.round((usageStats.monthlyUsage / monthlyCredits) * 100))
: 0;
const progressColorClass =
usagePercent > 95
? "[&>[data-slot=progress-indicator]]:bg-destructive"
: usagePercent >= 80
? "[&>[data-slot=progress-indicator]]:bg-amber-500"
: "";
return (
<div className="rounded-xl border bg-card p-6 shadow-sm shadow-foreground/3">
<div className="grid gap-6 sm:grid-cols-3">
{/* ── Block A: Verfügbare Credits ──────────────────────────────── */}
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Verfügbare Credits</p>
<div className="flex items-baseline gap-3">
<span className="text-3xl font-semibold tabular-nums tracking-tight">
{formatEurFromCents(balance.available)}
</span>
<Badge
variant="secondary"
className={cn(
"text-xs font-medium",
TIER_BADGE_STYLES[tier],
)}
>
{tier.charAt(0).toUpperCase() + tier.slice(1)}
</Badge>
</div>
{balance.reserved > 0 && (
<p className="text-xs text-muted-foreground">
({formatEurFromCents(balance.reserved)} reserviert)
</p>
)}
</div>
{/* ── Block B: Monatlicher Verbrauch ───────────────────────────── */}
<div className="space-y-3">
<div className="flex items-baseline justify-between">
<p className="text-sm text-muted-foreground">Monatlicher Verbrauch</p>
<span className="text-xs tabular-nums text-muted-foreground">
{usagePercent}%
</span>
</div>
<Progress
value={usagePercent}
className={cn("h-2", progressColorClass)}
/>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{formatEurFromCents(usageStats.monthlyUsage)} von{" "}
{formatEurFromCents(monthlyCredits)} verwendet
</span>
<span className="tabular-nums">
{usageStats.totalGenerations} Generierungen
</span>
</div>
</div>
{/* ── Block C: Aufladen ───────────────────────────────────────── */}
<div className="flex items-end">
<Button
variant="outline"
className="w-full gap-2"
disabled
title="Demnächst verfügbar Top-Up via Polar.sh"
>
<CreditCard className="size-4" />
Credits aufladen
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,150 @@
"use client";
import { useQuery } from "convex/react";
import { Activity, Coins } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { api } from "@/convex/_generated/api";
import { formatEurFromCents, cn } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/format-time";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function statusBadge(status: string) {
switch (status) {
case "committed":
return <Badge variant="secondary" className="text-xs font-normal">Abgeschlossen</Badge>;
case "reserved":
return (
<Badge variant="outline" className="border-amber-300 text-xs font-normal text-amber-700 dark:border-amber-700 dark:text-amber-400">
Reserviert
</Badge>
);
case "released":
return (
<Badge variant="secondary" className="text-xs font-normal text-emerald-600 dark:text-emerald-400">
Rückerstattet
</Badge>
);
case "failed":
return <Badge variant="destructive" className="text-xs font-normal">Fehlgeschlagen</Badge>;
default:
return <Badge variant="secondary" className="text-xs font-normal">Unbekannt</Badge>;
}
}
function truncatedDescription(text: string, maxLen = 40) {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen) + "…";
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function RecentTransactions() {
const transactions = useQuery(api.credits.getRecentTransactions, {
limit: 10,
});
// ── Loading State ──────────────────────────────────────────────────────
if (transactions === undefined) {
return (
<div className="rounded-xl border bg-card p-6 shadow-sm shadow-foreground/3">
<div className="mb-4 flex items-center gap-2 text-sm font-medium">
<Activity className="size-3.5 text-muted-foreground" />
Letzte Aktivität
</div>
<div className="divide-y">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-6 px-1 py-3.5">
<div className="h-2.5 w-2.5 animate-pulse rounded-full bg-muted" />
<div className="flex-1 space-y-1.5">
<div className="h-3.5 w-48 animate-pulse rounded bg-muted" />
<div className="h-3 w-32 animate-pulse rounded bg-muted" />
</div>
<div className="h-3.5 w-16 animate-pulse rounded bg-muted" />
</div>
))}
</div>
</div>
);
}
// ── Empty State ────────────────────────────────────────────────────────
if (transactions.length === 0) {
return (
<div className="rounded-xl border bg-card p-6 shadow-sm shadow-foreground/3">
<div className="mb-4 flex items-center gap-2 text-sm font-medium">
<Activity className="size-3.5 text-muted-foreground" />
Letzte Aktivität
</div>
<div className="flex flex-col items-center justify-center py-10 text-center">
<Coins className="mb-3 size-10 text-muted-foreground/40" />
<p className="text-sm font-medium text-muted-foreground">
Noch keine Aktivität
</p>
<p className="mt-1 text-xs text-muted-foreground/70">
Erstelle dein erstes KI-Bild im Canvas
</p>
</div>
</div>
);
}
// ── Transaction List ───────────────────────────────────────────────────
return (
<div className="rounded-xl border bg-card shadow-sm shadow-foreground/3">
<div className="flex items-center gap-2 px-5 pt-5 pb-3 text-sm font-medium">
<Activity className="size-3.5 text-muted-foreground" />
Letzte Aktivität
</div>
<div className="divide-y">
{transactions.map((t) => {
const isCredit = t.amount > 0;
return (
<div
key={t._id}
className="flex items-center gap-6 px-5 py-3.5"
>
{/* Status Indicator */}
<div className="shrink-0">
{statusBadge(t.status)}
</div>
{/* Description */}
<div className="min-w-0 flex-1">
<p
className="truncate text-sm font-medium"
title={t.description}
>
{truncatedDescription(t.description)}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{formatRelativeTime(t._creationTime)}
</p>
</div>
{/* Credits */}
<div className="shrink-0 text-right">
<span
className={cn(
"text-sm tabular-nums font-medium",
isCredit
? "text-emerald-600 dark:text-emerald-400"
: "text-foreground",
)}
>
{isCredit ? "+" : ""}
{formatEurFromCents(Math.abs(t.amount))}
</span>
</div>
</div>
);
})}
</div>
</div>
);
}