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

@@ -2,6 +2,7 @@
import { useRef } from "react";
import { CreditDisplay } from "@/components/canvas/credit-display";
import { ExportButton } from "@/components/canvas/export-button";
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
@@ -98,6 +99,7 @@ export default function CanvasToolbar({
</button>
))}
<div className="ml-1 h-6 w-px bg-border" />
<CreditDisplay />
<ExportButton canvasName={canvasName ?? "canvas"} />
</div>
);

View File

@@ -0,0 +1,105 @@
"use client";
import { useMutation, useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
import { Coins } from "lucide-react";
import { toast } from "sonner";
const TIER_LABELS: Record<string, string> = {
free: "Free",
starter: "Starter",
pro: "Pro",
business: "Business",
};
const TIER_COLORS: Record<string, string> = {
free: "text-muted-foreground",
starter: "text-blue-500",
pro: "text-purple-500",
business: "text-amber-500",
};
const showTestCreditGrant =
typeof process.env.NEXT_PUBLIC_ALLOW_TEST_CREDIT_GRANT === "string" &&
process.env.NEXT_PUBLIC_ALLOW_TEST_CREDIT_GRANT === "true";
export function CreditDisplay() {
const balance = useQuery(api.credits.getBalance);
const subscription = useQuery(api.credits.getSubscription);
const grantTestCredits = useMutation(api.credits.grantTestCredits);
if (balance === undefined || subscription === undefined) {
return (
<div className="flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-1.5 animate-pulse">
<Coins className="h-4 w-4 text-muted-foreground" />
<div className="h-4 w-16 rounded bg-muted" />
</div>
);
}
const available = balance.balance - balance.reserved;
const tier = subscription.tier;
const tierLabel = TIER_LABELS[tier] ?? tier;
const tierColor = TIER_COLORS[tier] ?? "text-muted-foreground";
const isLow = available < 10;
const isEmpty = available <= 0;
return (
<div className="flex items-center gap-2">
<div
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 transition-colors ${
isEmpty
? "bg-destructive/10"
: isLow
? "bg-amber-500/10"
: "bg-muted/50"
}`}
>
<Coins
className={`h-4 w-4 ${
isEmpty
? "text-destructive"
: isLow
? "text-amber-500"
: "text-muted-foreground"
}`}
/>
<span
className={`text-sm font-medium tabular-nums ${
isEmpty ? "text-destructive" : isLow ? "text-amber-500" : "text-foreground"
}`}
>
{available.toLocaleString("de-DE")} Cr
</span>
{balance.reserved > 0 && (
<span className="text-xs text-muted-foreground/70">
({balance.reserved} reserved)
</span>
)}
<span className="text-xs text-muted-foreground/70">·</span>
<span className={`text-xs font-medium ${tierColor}`}>{tierLabel}</span>
</div>
{showTestCreditGrant && (
<button
type="button"
title="Testphase: +2000 Cr"
className="rounded-md border border-dashed border-border px-2 py-1 text-[10px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => {
void grantTestCredits({ amount: 2000 })
.then((r) => {
toast.success(`+2000 Cr — Stand: ${r.newBalance.toLocaleString("de-DE")}`);
})
.catch((e: unknown) => {
toast.error(
e instanceof Error ? e.message : "Gutschrift fehlgeschlagen",
);
});
}}
>
Test +2000
</button>
)}
</div>
);
}

View File

@@ -8,12 +8,12 @@ import type { Id } from "@/convex/_generated/dataModel";
import BaseNodeWrapper from "./base-node-wrapper";
import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models";
import { DEFAULT_ASPECT_RATIO } from "@/lib/image-formats";
import { cn, formatEurFromCents } from "@/lib/utils";
import {
Loader2,
AlertCircle,
RefreshCw,
ImageIcon,
Coins,
} from "lucide-react";
type AiImageNodeData = {
@@ -21,6 +21,7 @@ type AiImageNodeData = {
url?: string;
prompt?: string;
model?: string;
modelLabel?: string;
modelTier?: string;
generatedAt?: number;
/** Gebuchte Credits in Euro-Cent (PRD: nach Commit) */
@@ -123,8 +124,9 @@ export default function AiImageNode({
/>
<div className="shrink-0 border-b border-border px-3 py-2">
<div className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
🖼 AI Image
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<ImageIcon className="h-3.5 w-3.5" />
AI Image
</div>
</div>
@@ -186,24 +188,9 @@ export default function AiImageNode({
/>
)}
{nodeData.creditCost != null &&
nodeData.url &&
!isLoading &&
status !== "error" && (
<div
className="pointer-events-none absolute bottom-2 right-2 z-[15] rounded-md border border-border/80 bg-background/85 px-1.5 py-0.5 text-[10px] tabular-nums text-muted-foreground shadow-sm backdrop-blur-sm"
title="Gebuchte Credits (Cent) für diese Generierung"
>
{formatEurFromCents(nodeData.creditCost)}
</div>
)}
{status === "done" && nodeData.url && !isLoading && (
<div
className={cn(
"absolute right-2 z-20 opacity-0 transition-opacity group-hover:opacity-100",
nodeData.creditCost != null ? "bottom-12" : "bottom-2",
)}
className="absolute right-2 bottom-2 z-20 opacity-0 transition-opacity group-hover:opacity-100"
>
<button
type="button"
@@ -222,9 +209,25 @@ export default function AiImageNode({
<p className="line-clamp-2 text-[10px] text-muted-foreground">
{nodeData.prompt}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground/60">
{modelName} · {nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO}
</p>
{status === "done" && nodeData.creditCost != null ? (
<div className="mt-0.5 flex items-center justify-between gap-2 text-[10px] text-muted-foreground">
<span
className="min-w-0 truncate"
title={nodeData.model ?? DEFAULT_MODEL_ID}
>
{nodeData.modelLabel ?? modelName} ·{" "}
{nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO}
</span>
<span className="inline-flex shrink-0 items-center gap-1 tabular-nums">
<Coins className="h-3 w-3" />
{nodeData.creditCost} Cr
</span>
</div>
) : (
<p className="mt-0.5 text-[10px] text-muted-foreground/60">
{modelName} · {nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO}
</p>
)}
</div>
)}

View File

@@ -9,13 +9,13 @@ import {
type NodeProps,
type Node,
} from "@xyflow/react";
import { useMutation, useAction } from "convex/react";
import { useMutation, useAction, useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import BaseNodeWrapper from "./base-node-wrapper";
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { DEFAULT_MODEL_ID } from "@/lib/ai-models";
import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models";
import {
DEFAULT_ASPECT_RATIO,
getAiImageNodeOuterSize,
@@ -33,7 +33,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Sparkles, Loader2 } from "lucide-react";
import { Sparkles, Loader2, Coins } from "lucide-react";
type PromptNodeData = {
prompt?: string;
@@ -104,6 +104,14 @@ export default function PromptNode({
const dataRef = useRef(data);
dataRef.current = data;
const balance = useQuery(api.credits.getBalance);
const creditCost = getModel(DEFAULT_MODEL_ID)?.creditCost ?? 4;
const availableCredits =
balance !== undefined ? balance.balance - balance.reserved : null;
const hasEnoughCredits =
availableCredits !== null && availableCredits >= creditCost;
const updateData = useMutation(api.nodes.updateData);
const createEdge = useMutation(api.edges.create);
const generateImage = useAction(api.ai.generateImage);
@@ -248,8 +256,9 @@ export default function PromptNode({
/>
<div className="flex flex-col gap-2 p-3">
<div className="text-xs font-medium text-violet-600 dark:text-violet-400">
Eingabe
<div className="flex items-center gap-1.5 text-xs font-medium text-violet-600 dark:text-violet-400">
<Sparkles className="h-3.5 w-3.5" />
Eingabe
</div>
{inputMeta.hasTextInput ? (
<div className="rounded-md border border-violet-500/30 bg-violet-500/5 px-3 py-2">
@@ -313,24 +322,45 @@ export default function PromptNode({
<p className="text-xs text-destructive">{error}</p>
)}
<button
type="button"
onClick={() => void handleGenerate()}
disabled={!effectivePrompt.trim() || isGenerating}
className="nodrag flex items-center justify-center gap-2 rounded-md bg-violet-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Generiere
</>
) : (
<>
<Sparkles className="h-4 w-4" />
Bild generieren
</>
<div className="flex flex-col gap-1">
<button
type="button"
onClick={() => void handleGenerate()}
disabled={
!effectivePrompt.trim() ||
isGenerating ||
balance === undefined ||
(availableCredits !== null && !hasEnoughCredits)
}
className={`nodrag flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed ${
availableCredits !== null && !hasEnoughCredits
? "bg-muted text-muted-foreground"
: "bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-50"
}`}
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Generiere
</>
) : (
<>
<Sparkles className="h-4 w-4" />
Bild generieren
<span className="inline-flex items-center gap-1 text-xs opacity-90">
<Coins className="h-3 w-3" />
{creditCost} Cr
</span>
</>
)}
</button>
{availableCredits !== null && !hasEnoughCredits && (
<p className="text-center text-xs text-destructive">
Not enough credits ({availableCredits} available, {creditCost}{" "}
needed)
</p>
)}
</button>
</div>
</div>
<Handle

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>
);
}