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

@@ -6,7 +6,6 @@ import { useState } from "react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useMutation, useQuery } from "convex/react"; import { useMutation, useQuery } from "convex/react";
import { import {
Activity,
ArrowUpRight, ArrowUpRight,
ChevronDown, ChevronDown,
Coins, Coins,
@@ -14,12 +13,10 @@ import {
Monitor, Monitor,
Moon, Moon,
Search, Search,
Sparkles,
Sun, Sun,
} from "lucide-react"; } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -32,87 +29,12 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CreditOverview } from "@/components/dashboard/credit-overview";
import { RecentTransactions } from "@/components/dashboard/recent-transactions";
const formatEurFromCents = (cents: number) =>
new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(cents / 100);
const mockRuns = [
{
id: "run-8841",
workspace: "Sommer-Kampagne",
node: "KI-Bild",
model: "flux-pro",
status: "done" as const,
credits: 42,
updated: "vor 12 Min.",
},
{
id: "run-8839",
workspace: "Produktfotos",
node: "KI-Bild",
model: "flux-schnell",
status: "executing" as const,
credits: 18,
updated: "gerade eben",
},
{
id: "run-8832",
workspace: "Social Variants",
node: "Compare",
model: "—",
status: "idle" as const,
credits: 0,
updated: "vor 1 Std.",
},
{
id: "run-8828",
workspace: "Sommer-Kampagne",
node: "KI-Bild",
model: "flux-pro",
status: "error" as const,
credits: 0,
updated: "vor 2 Std.",
},
];
function StatusDot({ status }: { status: (typeof mockRuns)[0]["status"] }) {
const base = "inline-block size-2 rounded-full";
switch (status) {
case "done":
return <span className={cn(base, "bg-primary")} />;
case "executing":
return (
<span className="relative inline-flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary/60" />
<span className={cn(base, "relative bg-primary")} />
</span>
);
case "idle":
return <span className={cn(base, "bg-border")} />;
case "error":
return <span className={cn(base, "bg-destructive")} />;
}
}
function statusLabel(status: (typeof mockRuns)[0]["status"]) {
switch (status) {
case "done":
return "Fertig";
case "executing":
return "Läuft";
case "idle":
return "Bereit";
case "error":
return "Fehler";
}
}
function getInitials(nameOrEmail: string) { function getInitials(nameOrEmail: string) {
const normalized = nameOrEmail.trim(); const normalized = nameOrEmail.trim();
@@ -162,13 +84,6 @@ export default function DashboardPage() {
} }
}; };
const balanceCents = 4320;
const reservedCents = 180;
const monthlyPoolCents = 5000;
const usagePercent = Math.round(
((monthlyPoolCents - balanceCents) / monthlyPoolCents) * 100,
);
return ( return (
<div className="min-h-full bg-background"> <div className="min-h-full bg-background">
{/* Header */} {/* Header */}
@@ -257,85 +172,21 @@ export default function DashboardPage() {
</p> </p>
</div> </div>
{/* Credits & Active Generation — asymmetric two-column */} {/* Credits Overview */}
<div className="mb-12 grid gap-6 lg:grid-cols-[1fr_1.2fr]"> <section className="mb-12">
{/* Credits Section */} <div className="mb-4 flex items-center gap-2 text-sm font-medium">
<div className="space-y-5"> <Coins className="size-3.5 text-muted-foreground" />
<div className="flex items-center gap-2 text-sm text-muted-foreground"> Credit-Übersicht
<Coins className="size-3.5" />
<span>Credit-Guthaben</span>
</div>
<div className="text-4xl font-semibold tabular-nums tracking-tight">
{formatEurFromCents(balanceCents)}
</div>
<div className="space-y-3 pt-1">
<div className="flex items-baseline justify-between text-sm">
<span className="text-muted-foreground">Reserviert</span>
<span className="tabular-nums font-medium">
{formatEurFromCents(reservedCents)}
</span>
</div>
<div>
<div className="mb-2 flex items-baseline justify-between text-sm">
<span className="text-muted-foreground">
Monatskontingent
</span>
<span className="tabular-nums text-muted-foreground">
{usagePercent}%
</span>
</div>
<Progress value={usagePercent} className="h-1.5" />
</div>
</div>
<p className="text-xs leading-relaxed text-muted-foreground/80">
Bei fehlgeschlagenen Jobs werden reservierte Credits automatisch
freigegeben.
</p>
</div> </div>
<CreditOverview />
{/* Active Generation */} </section>
<div className="rounded-2xl border bg-card p-6 shadow-sm shadow-foreground/3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="size-3.5" />
<span>Aktive Generierung</span>
</div>
<Badge className="gap-1.5 font-normal">
<span className="relative inline-flex size-1.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary-foreground/60 opacity-75" />
<span className="relative inline-flex size-1.5 rounded-full bg-primary-foreground" />
</span>
Läuft
</Badge>
</div>
<h2 className="mt-4 text-lg font-medium">
Produktfotos Variante 3/4
</h2>
<div className="mt-5">
<div className="mb-2 flex items-baseline justify-between text-sm">
<span className="text-muted-foreground">Fortschritt</span>
<span className="font-medium tabular-nums">62%</span>
</div>
<Progress value={62} className="h-1.5" />
</div>
<p className="mt-4 text-xs text-muted-foreground leading-relaxed">
Step 2 von 4 {" "}
<span className="font-mono text-[0.7rem]">flux-schnell</span>
</p>
</div>
</div>
{/* Workspaces */} {/* Workspaces */}
<section className="mb-12"> <section className="mb-12">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium"> <div className="flex items-center gap-2 text-sm font-medium">
<LayoutTemplate className="size-3.5 text-muted-foreground" /> <LayoutTemplate className="size-3.5 text-muted-foreground" />
Workspaces Arbeitsbereiche
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
@@ -345,17 +196,17 @@ export default function DashboardPage() {
onClick={handleCreateWorkspace} onClick={handleCreateWorkspace}
disabled={isCreatingWorkspace || isSessionPending || !session?.user} disabled={isCreatingWorkspace || isSessionPending || !session?.user}
> >
{isCreatingWorkspace ? "Erstelle..." : "Neuer Workspace"} {isCreatingWorkspace ? "Erstelle..." : "Neuen Arbeitsbereich"}
</Button> </Button>
</div> </div>
{isSessionPending || canvases === undefined ? ( {isSessionPending || canvases === undefined ? (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground shadow-sm shadow-foreground/3"> <div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground shadow-sm shadow-foreground/3">
Workspaces werden geladen... Arbeitsbereiche werden geladen...
</div> </div>
) : canvases.length === 0 ? ( ) : canvases.length === 0 ? (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground shadow-sm shadow-foreground/3"> <div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground shadow-sm shadow-foreground/3">
Noch kein Workspace vorhanden. Mit &quot;Neuer Workspace&quot; legst du den Noch kein Arbeitsbereich vorhanden. Mit Neuer Arbeitsbereich legst du den
ersten an. ersten an.
</div> </div>
) : ( ) : (
@@ -384,58 +235,9 @@ export default function DashboardPage() {
)} )}
</section> </section>
{/* Recent Activity */} {/* Recent Transactions */}
<section> <section className="mb-12">
<div className="mb-4 flex items-center gap-2 text-sm font-medium"> <RecentTransactions />
<Activity className="size-3.5 text-muted-foreground" />
Letzte Aktivität
</div>
<div className="rounded-xl border bg-card shadow-sm shadow-foreground/3">
<div className="divide-y">
{mockRuns.map((run) => (
<div
key={run.id}
className="flex items-center gap-4 px-5 py-3.5"
>
<StatusDot status={run.status} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="truncate text-sm font-medium">
{run.workspace}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{run.node}
</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
{run.model !== "—" && (
<span className="font-mono text-[0.7rem]">
{run.model}
</span>
)}
{run.credits > 0 && (
<>
<span aria-hidden>·</span>
<span className="tabular-nums">{run.credits} ct</span>
</>
)}
</div>
</div>
<div className="shrink-0 text-right">
<span className="text-xs text-muted-foreground">
{statusLabel(run.status)}
</span>
<p className="mt-0.5 text-[0.7rem] text-muted-foreground/70">
{run.updated}
</p>
</div>
</div>
))}
</div>
</div>
</section> </section>
</main> </main>
</div> </div>

View File

@@ -2,6 +2,7 @@
import { useRef } from "react"; import { useRef } from "react";
import { CreditDisplay } from "@/components/canvas/credit-display";
import { ExportButton } from "@/components/canvas/export-button"; import { ExportButton } from "@/components/canvas/export-button";
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
@@ -98,6 +99,7 @@ export default function CanvasToolbar({
</button> </button>
))} ))}
<div className="ml-1 h-6 w-px bg-border" /> <div className="ml-1 h-6 w-px bg-border" />
<CreditDisplay />
<ExportButton canvasName={canvasName ?? "canvas"} /> <ExportButton canvasName={canvasName ?? "canvas"} />
</div> </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 BaseNodeWrapper from "./base-node-wrapper";
import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models"; import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models";
import { DEFAULT_ASPECT_RATIO } from "@/lib/image-formats"; import { DEFAULT_ASPECT_RATIO } from "@/lib/image-formats";
import { cn, formatEurFromCents } from "@/lib/utils";
import { import {
Loader2, Loader2,
AlertCircle, AlertCircle,
RefreshCw, RefreshCw,
ImageIcon, ImageIcon,
Coins,
} from "lucide-react"; } from "lucide-react";
type AiImageNodeData = { type AiImageNodeData = {
@@ -21,6 +21,7 @@ type AiImageNodeData = {
url?: string; url?: string;
prompt?: string; prompt?: string;
model?: string; model?: string;
modelLabel?: string;
modelTier?: string; modelTier?: string;
generatedAt?: number; generatedAt?: number;
/** Gebuchte Credits in Euro-Cent (PRD: nach Commit) */ /** 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="shrink-0 border-b border-border px-3 py-2">
<div className="text-xs font-medium text-emerald-600 dark:text-emerald-400"> <div className="flex items-center gap-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
🖼 AI Image <ImageIcon className="h-3.5 w-3.5" />
AI Image
</div> </div>
</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 && ( {status === "done" && nodeData.url && !isLoading && (
<div <div
className={cn( className="absolute right-2 bottom-2 z-20 opacity-0 transition-opacity group-hover:opacity-100"
"absolute right-2 z-20 opacity-0 transition-opacity group-hover:opacity-100",
nodeData.creditCost != null ? "bottom-12" : "bottom-2",
)}
> >
<button <button
type="button" type="button"
@@ -222,9 +209,25 @@ export default function AiImageNode({
<p className="line-clamp-2 text-[10px] text-muted-foreground"> <p className="line-clamp-2 text-[10px] text-muted-foreground">
{nodeData.prompt} {nodeData.prompt}
</p> </p>
<p className="mt-0.5 text-[10px] text-muted-foreground/60"> {status === "done" && nodeData.creditCost != null ? (
{modelName} · {nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO} <div className="mt-0.5 flex items-center justify-between gap-2 text-[10px] text-muted-foreground">
</p> <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> </div>
)} )}

View File

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

View File

@@ -17,6 +17,7 @@ export const generateImage = action({
aspectRatio: v.optional(v.string()), aspectRatio: v.optional(v.string()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
// Auth: über requireAuth in runMutation — kein verschachteltes getCurrentUser (ConvexError → generische Client-Fehler).
const internalCreditsEnabled = const internalCreditsEnabled =
process.env.INTERNAL_CREDITS_ENABLED === "true"; process.env.INTERNAL_CREDITS_ENABLED === "true";
@@ -31,13 +32,9 @@ export const generateImage = action({
throw new Error(`Unknown model: ${modelId}`); throw new Error(`Unknown model: ${modelId}`);
} }
if (!(await ctx.runQuery(api.auth.getCurrentUser, {}))) {
throw new Error("User not found");
}
const reservationId = internalCreditsEnabled const reservationId = internalCreditsEnabled
? await ctx.runMutation(api.credits.reserve, { ? await ctx.runMutation(api.credits.reserve, {
estimatedCost: modelConfig.estimatedCostPerImage, estimatedCost: modelConfig.creditCost,
description: `Bildgenerierung — ${modelConfig.name}`, description: `Bildgenerierung — ${modelConfig.name}`,
model: modelId, model: modelId,
nodeId: args.nodeId, nodeId: args.nodeId,
@@ -76,7 +73,7 @@ export const generateImage = action({
const existing = await ctx.runQuery(api.nodes.get, { nodeId: args.nodeId }); const existing = await ctx.runQuery(api.nodes.get, { nodeId: args.nodeId });
if (!existing) throw new Error("Node not found"); if (!existing) throw new Error("Node not found");
const prev = (existing.data ?? {}) as Record<string, unknown>; const prev = (existing.data ?? {}) as Record<string, unknown>;
const creditCost = modelConfig.estimatedCostPerImage; const creditCost = modelConfig.creditCost;
const aspectRatio = const aspectRatio =
args.aspectRatio?.trim() || args.aspectRatio?.trim() ||
@@ -89,6 +86,7 @@ export const generateImage = action({
storageId, storageId,
prompt: args.prompt, prompt: args.prompt,
model: modelId, model: modelId,
modelLabel: modelConfig.name,
modelTier: modelConfig.tier, modelTier: modelConfig.tier,
generatedAt: Date.now(), generatedAt: Date.now(),
creditCost, creditCost,

View File

@@ -85,17 +85,30 @@ export const listTransactions = query({
}); });
/** /**
* Aktuelle Subscription des Users abrufen. * Aktuelle Subscription des Users abrufen (kompakt, immer definiert für die UI).
*/ */
export const getSubscription = query({ export const getSubscription = query({
args: {}, args: {},
handler: async (ctx) => { handler: async (ctx) => {
const user = await requireAuth(ctx); const user = await requireAuth(ctx);
return await ctx.db const row = await ctx.db
.query("subscriptions") .query("subscriptions")
.withIndex("by_user", (q) => q.eq("userId", user.userId)) .withIndex("by_user", (q) => q.eq("userId", user.userId))
.order("desc") .order("desc")
.first(); .first();
if (!row) {
return {
tier: "free" as const,
status: "active" as const,
};
}
return {
tier: row.tier,
status: row.status,
currentPeriodEnd: row.currentPeriodEnd,
};
}, },
}); });
@@ -119,6 +132,61 @@ export const getDailyUsage = query({
}, },
}); });
/**
* Neueste Transaktionen des Users abrufen (für Dashboard "Recent Activity").
* Ähnlich wie listTransactions, aber als dedizierter Query mit explizitem Limit.
*/
export const getRecentTransactions = query({
args: {
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const user = await requireAuth(ctx);
const limit = args.limit ?? 10;
return await ctx.db
.query("creditTransactions")
.withIndex("by_user", (q) => q.eq("userId", user.userId))
.order("desc")
.take(limit);
},
});
/**
* Monatliche Credit-Statistiken des Users abrufen (für Dashboard Verbrauchsbalken).
* Berechnet: monatlicher Verbrauch (nur committed usage-Transaktionen) + Anzahl Generierungen.
*/
export const getUsageStats = query({
args: {},
handler: async (ctx) => {
const user = await requireAuth(ctx);
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
const transactions = await ctx.db
.query("creditTransactions")
.withIndex("by_user", (q) => q.eq("userId", user.userId))
.order("desc")
.collect();
const monthlyTransactions = transactions.filter(
(t) =>
t._creationTime >= monthStart &&
t.status === "committed" &&
t.type === "usage"
);
return {
monthlyUsage: monthlyTransactions.reduce(
(sum, t) => sum + Math.abs(t.amount),
0
),
totalGenerations: monthlyTransactions.length,
};
},
});
// ============================================================================ // ============================================================================
// Mutations — Credit Balance Management // Mutations — Credit Balance Management
// ============================================================================ // ============================================================================
@@ -171,6 +239,49 @@ export const initBalance = mutation({
}, },
}); });
/**
* Nur Testphase: schreibt dem eingeloggten User Gutschrift gut.
* In Produktion deaktiviert, außer ALLOW_TEST_CREDIT_GRANT ist in Convex auf "true" gesetzt.
*/
export const grantTestCredits = mutation({
args: {
amount: v.optional(v.number()),
},
handler: async (ctx, { amount = 2000 }) => {
if (process.env.ALLOW_TEST_CREDIT_GRANT !== "true") {
throw new Error("Test-Gutschriften sind deaktiviert (ALLOW_TEST_CREDIT_GRANT).");
}
if (amount <= 0 || amount > 1_000_000) {
throw new Error("Ungültiger Betrag.");
}
const user = await requireAuth(ctx);
const balance = await ctx.db
.query("creditBalances")
.withIndex("by_user", (q) => q.eq("userId", user.userId))
.unique();
if (!balance) {
throw new Error("Keine Credit-Balance — zuerst einloggen / initBalance.");
}
const next = balance.balance + amount;
await ctx.db.patch(balance._id, {
balance: next,
updatedAt: Date.now(),
});
await ctx.db.insert("creditTransactions", {
userId: user.userId,
amount,
type: "subscription",
status: "committed",
description: `Testphase — Gutschrift (${amount} Cr)`,
});
return { newBalance: next };
},
});
// ============================================================================ // ============================================================================
// Mutations — Reservation + Commit (Kern des Credit-Systems) // Mutations — Reservation + Commit (Kern des Credit-Systems)
// ============================================================================ // ============================================================================

View File

@@ -5,6 +5,8 @@ export interface OpenRouterModel {
name: string; name: string;
tier: "budget" | "standard" | "premium"; tier: "budget" | "standard" | "premium";
estimatedCostPerImage: number; // in Euro-Cent (for credit reservation) estimatedCostPerImage: number; // in Euro-Cent (for credit reservation)
/** Gleiche Einheit wie UI „Cr“ / lib/ai-models creditCost */
creditCost: number;
} }
// Phase 1: Gemini 2.5 Flash Image only. // Phase 1: Gemini 2.5 Flash Image only.
@@ -15,6 +17,7 @@ export const IMAGE_MODELS: Record<string, OpenRouterModel> = {
name: "Gemini 2.5 Flash", name: "Gemini 2.5 Flash",
tier: "standard", tier: "standard",
estimatedCostPerImage: 4, // ~€0.04 in Euro-Cent estimatedCostPerImage: 4, // ~€0.04 in Euro-Cent
creditCost: 4,
}, },
}; };
@@ -33,6 +36,48 @@ export interface OpenRouterImageResponse {
mimeType: string; mimeType: string;
} }
const DATA_IMAGE_URI =
/data:image\/[\w+.+-]+;base64,[A-Za-z0-9+/=\s]+/;
function firstDataImageUriInString(s: string): string | undefined {
const m = s.match(DATA_IMAGE_URI);
if (!m) return undefined;
return m[0]!.replace(/\s+/g, "");
}
function dataUriFromContentPart(p: Record<string, unknown>): string | undefined {
const block = (p.image_url ?? p.imageUrl) as
| Record<string, unknown>
| undefined;
const url = block?.url;
if (typeof url === "string" && url.startsWith("data:")) {
return url;
}
if (typeof url === "string" && (url.startsWith("http://") || url.startsWith("https://"))) {
return url;
}
const inline =
(p.inline_data ?? p.inlineData) as
| Record<string, unknown>
| undefined;
if (inline && typeof inline.data === "string") {
const mime =
typeof inline.mime_type === "string"
? inline.mime_type
: typeof inline.mimeType === "string"
? inline.mimeType
: "image/png";
return `data:${mime};base64,${inline.data}`;
}
if (p.type === "text" && typeof p.text === "string") {
return firstDataImageUriInString(p.text);
}
return undefined;
}
/** /**
* Calls the OpenRouter API to generate an image. * Calls the OpenRouter API to generate an image.
* Uses the chat/completions endpoint with a vision-capable model that returns * Uses the chat/completions endpoint with a vision-capable model that returns
@@ -46,30 +91,31 @@ export async function generateImageViaOpenRouter(
): Promise<OpenRouterImageResponse> { ): Promise<OpenRouterImageResponse> {
const modelId = params.model ?? DEFAULT_IMAGE_MODEL; const modelId = params.model ?? DEFAULT_IMAGE_MODEL;
// Build message content — text prompt, optionally with a reference image // Ohne Referenzbild: einfacher String als content — bei Gemini/OpenRouter sonst oft nur Text (refusal/reasoning) statt Bild.
const userContent: object[] = []; const userMessage =
params.referenceImageUrl != null && params.referenceImageUrl !== ""
if (params.referenceImageUrl) { ? {
userContent.push({ role: "user" as const,
type: "image_url", content: [
image_url: { url: params.referenceImageUrl }, {
}); type: "image_url" as const,
} image_url: { url: params.referenceImageUrl },
},
userContent.push({ {
type: "text", type: "text" as const,
text: params.prompt, text: params.prompt,
}); },
],
}
: {
role: "user" as const,
content: params.prompt,
};
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
model: modelId, model: modelId,
modalities: ["image", "text"], modalities: ["image", "text"],
messages: [ messages: [userMessage],
{
role: "user",
content: userContent,
},
],
}; };
if (params.aspectRatio?.trim()) { if (params.aspectRatio?.trim()) {
@@ -96,21 +142,110 @@ export async function generateImageViaOpenRouter(
const data = await response.json(); const data = await response.json();
// OpenRouter returns generated images in message.images (separate from content) const message = data?.choices?.[0]?.message as Record<string, unknown> | undefined;
const images = data?.choices?.[0]?.message?.images; if (!message) {
throw new Error("OpenRouter: choices[0].message fehlt");
if (!images || images.length === 0) {
throw new Error("No image found in OpenRouter response");
} }
const imageUrl = images[0]?.image_url?.url; let rawImage: string | undefined;
if (!imageUrl) {
throw new Error("Image block missing image_url.url"); const images = message.images;
if (Array.isArray(images) && images.length > 0) {
const first = images[0] as Record<string, unknown>;
const block = (first.image_url ?? first.imageUrl) as
| Record<string, unknown>
| undefined;
const url = block?.url;
if (typeof url === "string") {
rawImage = url;
}
} }
// The URL is a data URI: "data:image/png;base64,<data>" const content = message.content;
const dataUri: string = imageUrl; if (!rawImage && Array.isArray(content)) {
const [meta, base64Data] = dataUri.split(","); for (const part of content) {
if (!part || typeof part !== "object") continue;
const p = part as Record<string, unknown>;
const uri = dataUriFromContentPart(p);
if (uri) {
rawImage = uri;
break;
}
}
}
if (!rawImage && typeof content === "string") {
rawImage = firstDataImageUriInString(content);
}
const refusal = message.refusal;
if (
(!rawImage || (!rawImage.startsWith("data:") && !rawImage.startsWith("http"))) &&
refusal != null &&
String(refusal).length > 0
) {
const r =
typeof refusal === "string" ? refusal : JSON.stringify(refusal);
throw new Error(`OpenRouter: Modell lehnt ab — ${r.slice(0, 500)}`);
}
if (
!rawImage ||
(!rawImage.startsWith("data:") &&
!rawImage.startsWith("http://") &&
!rawImage.startsWith("https://"))
) {
const reasoning =
typeof message.reasoning === "string"
? message.reasoning.slice(0, 400)
: "";
const contentPreview =
typeof content === "string"
? content.slice(0, 400)
: Array.isArray(content)
? JSON.stringify(content).slice(0, 400)
: "";
throw new Error(
`OpenRouter: kein Bild in der Antwort. Keys: ${Object.keys(message).join(", ")}. ` +
(reasoning ? `reasoning: ${reasoning}` : `content: ${contentPreview}`),
);
}
let dataUri = rawImage;
if (rawImage.startsWith("http://") || rawImage.startsWith("https://")) {
const imgRes = await fetch(rawImage);
if (!imgRes.ok) {
throw new Error(
`OpenRouter: Bild-URL konnte nicht geladen werden (${imgRes.status})`,
);
}
const mimeTypeFromRes =
imgRes.headers.get("content-type") ?? "image/png";
const buf = await imgRes.arrayBuffer();
let b64: string;
if (typeof Buffer !== "undefined") {
b64 = Buffer.from(buf).toString("base64");
} else {
const bytes = new Uint8Array(buf);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]!);
}
b64 = btoa(binary);
}
dataUri = `data:${mimeTypeFromRes};base64,${b64}`;
}
if (!dataUri.startsWith("data:")) {
throw new Error("OpenRouter: Bild konnte nicht als data-URI erstellt werden");
}
const comma = dataUri.indexOf(",");
if (comma === -1) {
throw new Error("OpenRouter: data-URI ohne Base64-Teil");
}
const meta = dataUri.slice(0, comma);
const base64Data = dataUri.slice(comma + 1);
const mimeType = meta.replace("data:", "").replace(";base64", ""); const mimeType = meta.replace("data:", "").replace(";base64", "");
return { return {

View File

@@ -7,6 +7,8 @@ export interface AiModel {
tier: "budget" | "standard" | "premium"; tier: "budget" | "standard" | "premium";
description: string; description: string;
estimatedCost: string; // human-readable, e.g. "~€0.04" estimatedCost: string; // human-readable, e.g. "~€0.04"
/** Credits pro Generierung — gleiche Einheit wie Convex reserve/commit (Euro-Cent). */
creditCost: number;
minTier: "free" | "starter" | "pro" | "business"; // minimum subscription tier minTier: "free" | "starter" | "pro" | "business"; // minimum subscription tier
} }
@@ -17,6 +19,7 @@ export const IMAGE_MODELS: AiModel[] = [
tier: "standard", tier: "standard",
description: "Fast, high-quality generation", description: "Fast, high-quality generation",
estimatedCost: "~€0.04", estimatedCost: "~€0.04",
creditCost: 4,
minTier: "free", minTier: "free",
}, },
// Phase 2 — uncomment when model selector UI is ready: // Phase 2 — uncomment when model selector UI is ready:

20
lib/format-time.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Formatiert einen Timestamp als relative Zeitangabe.
* Beispiele: "Just now", "5m ago", "3h ago", "2d ago", "12. Mär"
*/
export function formatRelativeTime(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return new Date(timestamp).toLocaleDateString("de-DE", {
day: "numeric",
month: "short",
});
}