diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
index daa49a4..d71d891 100644
--- a/app/dashboard/page.tsx
+++ b/app/dashboard/page.tsx
@@ -6,7 +6,6 @@ import { useState } from "react";
import { useTheme } from "next-themes";
import { useMutation, useQuery } from "convex/react";
import {
- Activity,
ArrowUpRight,
ChevronDown,
Coins,
@@ -14,12 +13,10 @@ import {
Monitor,
Moon,
Search,
- Sparkles,
Sun,
} from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
-import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -32,87 +29,12 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
-import { Progress } from "@/components/ui/progress";
import { api } from "@/convex/_generated/api";
import { authClient } from "@/lib/auth-client";
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 ;
- case "executing":
- return (
-
-
-
-
- );
- case "idle":
- return ;
- case "error":
- return ;
- }
-}
-
-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) {
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 (
{/* Header */}
@@ -257,85 +172,21 @@ export default function DashboardPage() {
- {/* Credits & Active Generation — asymmetric two-column */}
-
- {/* Credits Section */}
-
-
-
- Credit-Guthaben
-
-
- {formatEurFromCents(balanceCents)}
-
-
-
-
- Reserviert
-
- {formatEurFromCents(reservedCents)}
-
-
-
-
-
- Monatskontingent
-
-
- {usagePercent}%
-
-
-
-
-
-
-
- Bei fehlgeschlagenen Jobs werden reservierte Credits automatisch
- freigegeben.
-
+ {/* Credits Overview */}
+
+
+
+ Credit-Übersicht
-
- {/* Active Generation */}
-
-
-
-
- Aktive Generierung
-
-
-
-
-
-
- Läuft
-
-
-
-
- Produktfotos — Variante 3/4
-
-
-
-
- Fortschritt
- 62%
-
-
-
-
-
- Step 2 von 4 —{" "}
- flux-schnell
-
-
-
+
+
{/* Workspaces */}
- Workspaces
+ Arbeitsbereiche
- {isCreatingWorkspace ? "Erstelle..." : "Neuer Workspace"}
+ {isCreatingWorkspace ? "Erstelle..." : "Neuen Arbeitsbereich"}
{isSessionPending || canvases === undefined ? (
- Workspaces werden geladen...
+ Arbeitsbereiche werden geladen...
) : canvases.length === 0 ? (
- Noch kein Workspace vorhanden. Mit "Neuer Workspace" legst du den
+ Noch kein Arbeitsbereich vorhanden. Mit „Neuer Arbeitsbereich“ legst du den
ersten an.
) : (
@@ -384,58 +235,9 @@ export default function DashboardPage() {
)}
- {/* Recent Activity */}
-
-
-
-
-
- {mockRuns.map((run) => (
-
-
-
-
-
-
- {run.workspace}
-
-
- {run.node}
-
-
-
- {run.model !== "—" && (
-
- {run.model}
-
- )}
- {run.credits > 0 && (
- <>
- ·
- {run.credits} ct
- >
- )}
-
-
-
-
-
- {statusLabel(run.status)}
-
-
- {run.updated}
-
-
-
- ))}
-
-
+ {/* Recent Transactions */}
+
diff --git a/components/canvas/canvas-toolbar.tsx b/components/canvas/canvas-toolbar.tsx
index 5b48a7f..f1c7788 100644
--- a/components/canvas/canvas-toolbar.tsx
+++ b/components/canvas/canvas-toolbar.tsx
@@ -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({
))}
+
);
diff --git a/components/canvas/credit-display.tsx b/components/canvas/credit-display.tsx
new file mode 100644
index 0000000..8b8eb04
--- /dev/null
+++ b/components/canvas/credit-display.tsx
@@ -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 = {
+ free: "Free",
+ starter: "Starter",
+ pro: "Pro",
+ business: "Business",
+};
+
+const TIER_COLORS: Record = {
+ 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 (
+
+ );
+ }
+
+ 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 (
+
+
+
+
+ {available.toLocaleString("de-DE")} Cr
+
+ {balance.reserved > 0 && (
+
+ ({balance.reserved} reserved)
+
+ )}
+ ·
+ {tierLabel}
+
+ {showTestCreditGrant && (
+
{
+ 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
+
+ )}
+
+ );
+}
diff --git a/components/canvas/nodes/ai-image-node.tsx b/components/canvas/nodes/ai-image-node.tsx
index e8ab04a..a263cac 100644
--- a/components/canvas/nodes/ai-image-node.tsx
+++ b/components/canvas/nodes/ai-image-node.tsx
@@ -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({
/>
-
- 🖼️ AI Image
+
+
+ AI Image
@@ -186,24 +188,9 @@ export default function AiImageNode({
/>
)}
- {nodeData.creditCost != null &&
- nodeData.url &&
- !isLoading &&
- status !== "error" && (
-
- {formatEurFromCents(nodeData.creditCost)}
-
- )}
-
{status === "done" && nodeData.url && !isLoading && (
{nodeData.prompt}
-
- {modelName} · {nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO}
-
+ {status === "done" && nodeData.creditCost != null ? (
+
+
+ {nodeData.modelLabel ?? modelName} ·{" "}
+ {nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO}
+
+
+
+ {nodeData.creditCost} Cr
+
+
+ ) : (
+
+ {modelName} · {nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO}
+
+ )}
)}
diff --git a/components/canvas/nodes/prompt-node.tsx b/components/canvas/nodes/prompt-node.tsx
index 4f39dae..8e97711 100644
--- a/components/canvas/nodes/prompt-node.tsx
+++ b/components/canvas/nodes/prompt-node.tsx
@@ -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({
/>
-
- ✨ Eingabe
+
+
+ Eingabe
{inputMeta.hasTextInput ? (
@@ -313,24 +322,45 @@ export default function PromptNode({
{error}
)}
-
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 ? (
- <>
-
- Generiere…
- >
- ) : (
- <>
-
- Bild generieren
- >
+
+
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 ? (
+ <>
+
+ Generiere…
+ >
+ ) : (
+ <>
+
+ Bild generieren
+
+
+ {creditCost} Cr
+
+ >
+ )}
+
+ {availableCredits !== null && !hasEnoughCredits && (
+
+ Not enough credits ({availableCredits} available, {creditCost}{" "}
+ needed)
+
)}
-
+
= {
+ free: 50,
+ starter: 630,
+ pro: 3602,
+ business: 7623,
+};
+
+const TIER_BADGE_STYLES: Record = {
+ 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 (
+
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+
+ );
+ }
+
+ // ── 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 (
+
+
+ {/* ── Block A: Verfügbare Credits ──────────────────────────────── */}
+
+
Verfügbare Credits
+
+
+ {formatEurFromCents(balance.available)}
+
+
+ {tier.charAt(0).toUpperCase() + tier.slice(1)}
+
+
+ {balance.reserved > 0 && (
+
+ ({formatEurFromCents(balance.reserved)} reserviert)
+
+ )}
+
+
+ {/* ── Block B: Monatlicher Verbrauch ───────────────────────────── */}
+
+
+
Monatlicher Verbrauch
+
+ {usagePercent}%
+
+
+
+
+
+ {formatEurFromCents(usageStats.monthlyUsage)} von{" "}
+ {formatEurFromCents(monthlyCredits)} verwendet
+
+
+ {usageStats.totalGenerations} Generierungen
+
+
+
+
+ {/* ── Block C: Aufladen ───────────────────────────────────────── */}
+
+
+
+ Credits aufladen
+
+
+
+
+ );
+}
diff --git a/components/dashboard/recent-transactions.tsx b/components/dashboard/recent-transactions.tsx
new file mode 100644
index 0000000..42613b5
--- /dev/null
+++ b/components/dashboard/recent-transactions.tsx
@@ -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 Abgeschlossen ;
+ case "reserved":
+ return (
+
+ Reserviert
+
+ );
+ case "released":
+ return (
+
+ Rückerstattet
+
+ );
+ case "failed":
+ return Fehlgeschlagen ;
+ default:
+ return Unbekannt ;
+ }
+}
+
+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 (
+
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+
+ );
+ }
+
+ // ── Empty State ────────────────────────────────────────────────────────
+ if (transactions.length === 0) {
+ return (
+
+
+
+
+
+ Noch keine Aktivität
+
+
+ Erstelle dein erstes KI-Bild im Canvas
+
+
+
+ );
+ }
+
+ // ── Transaction List ───────────────────────────────────────────────────
+ return (
+
+
+
+ {transactions.map((t) => {
+ const isCredit = t.amount > 0;
+ return (
+
+ {/* Status Indicator */}
+
+ {statusBadge(t.status)}
+
+
+ {/* Description */}
+
+
+ {truncatedDescription(t.description)}
+
+
+ {formatRelativeTime(t._creationTime)}
+
+
+
+ {/* Credits */}
+
+
+ {isCredit ? "+" : "−"}
+ {formatEurFromCents(Math.abs(t.amount))}
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/convex/ai.ts b/convex/ai.ts
index 0b69c15..585696e 100644
--- a/convex/ai.ts
+++ b/convex/ai.ts
@@ -17,6 +17,7 @@ export const generateImage = action({
aspectRatio: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ // Auth: über requireAuth in runMutation — kein verschachteltes getCurrentUser (ConvexError → generische Client-Fehler).
const internalCreditsEnabled =
process.env.INTERNAL_CREDITS_ENABLED === "true";
@@ -31,13 +32,9 @@ export const generateImage = action({
throw new Error(`Unknown model: ${modelId}`);
}
- if (!(await ctx.runQuery(api.auth.getCurrentUser, {}))) {
- throw new Error("User not found");
- }
-
const reservationId = internalCreditsEnabled
? await ctx.runMutation(api.credits.reserve, {
- estimatedCost: modelConfig.estimatedCostPerImage,
+ estimatedCost: modelConfig.creditCost,
description: `Bildgenerierung — ${modelConfig.name}`,
model: modelId,
nodeId: args.nodeId,
@@ -76,7 +73,7 @@ export const generateImage = action({
const existing = await ctx.runQuery(api.nodes.get, { nodeId: args.nodeId });
if (!existing) throw new Error("Node not found");
const prev = (existing.data ?? {}) as Record;
- const creditCost = modelConfig.estimatedCostPerImage;
+ const creditCost = modelConfig.creditCost;
const aspectRatio =
args.aspectRatio?.trim() ||
@@ -89,6 +86,7 @@ export const generateImage = action({
storageId,
prompt: args.prompt,
model: modelId,
+ modelLabel: modelConfig.name,
modelTier: modelConfig.tier,
generatedAt: Date.now(),
creditCost,
diff --git a/convex/credits.ts b/convex/credits.ts
index dc7921d..8482b07 100644
--- a/convex/credits.ts
+++ b/convex/credits.ts
@@ -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({
args: {},
handler: async (ctx) => {
const user = await requireAuth(ctx);
- return await ctx.db
+ const row = await ctx.db
.query("subscriptions")
.withIndex("by_user", (q) => q.eq("userId", user.userId))
.order("desc")
.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
// ============================================================================
@@ -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)
// ============================================================================
diff --git a/convex/openrouter.ts b/convex/openrouter.ts
index 5d8c48b..889381d 100644
--- a/convex/openrouter.ts
+++ b/convex/openrouter.ts
@@ -5,6 +5,8 @@ export interface OpenRouterModel {
name: string;
tier: "budget" | "standard" | "premium";
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.
@@ -15,6 +17,7 @@ export const IMAGE_MODELS: Record = {
name: "Gemini 2.5 Flash",
tier: "standard",
estimatedCostPerImage: 4, // ~€0.04 in Euro-Cent
+ creditCost: 4,
},
};
@@ -33,6 +36,48 @@ export interface OpenRouterImageResponse {
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 | undefined {
+ const block = (p.image_url ?? p.imageUrl) as
+ | Record
+ | 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
+ | 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.
* Uses the chat/completions endpoint with a vision-capable model that returns
@@ -46,30 +91,31 @@ export async function generateImageViaOpenRouter(
): Promise {
const modelId = params.model ?? DEFAULT_IMAGE_MODEL;
- // Build message content — text prompt, optionally with a reference image
- const userContent: object[] = [];
-
- if (params.referenceImageUrl) {
- userContent.push({
- type: "image_url",
- image_url: { url: params.referenceImageUrl },
- });
- }
-
- userContent.push({
- type: "text",
- text: params.prompt,
- });
+ // Ohne Referenzbild: einfacher String als content — bei Gemini/OpenRouter sonst oft nur Text (refusal/reasoning) statt Bild.
+ const userMessage =
+ params.referenceImageUrl != null && params.referenceImageUrl !== ""
+ ? {
+ role: "user" as const,
+ content: [
+ {
+ type: "image_url" as const,
+ image_url: { url: params.referenceImageUrl },
+ },
+ {
+ type: "text" as const,
+ text: params.prompt,
+ },
+ ],
+ }
+ : {
+ role: "user" as const,
+ content: params.prompt,
+ };
const body: Record = {
model: modelId,
modalities: ["image", "text"],
- messages: [
- {
- role: "user",
- content: userContent,
- },
- ],
+ messages: [userMessage],
};
if (params.aspectRatio?.trim()) {
@@ -96,21 +142,110 @@ export async function generateImageViaOpenRouter(
const data = await response.json();
- // OpenRouter returns generated images in message.images (separate from content)
- const images = data?.choices?.[0]?.message?.images;
-
- if (!images || images.length === 0) {
- throw new Error("No image found in OpenRouter response");
+ const message = data?.choices?.[0]?.message as Record | undefined;
+ if (!message) {
+ throw new Error("OpenRouter: choices[0].message fehlt");
}
- const imageUrl = images[0]?.image_url?.url;
- if (!imageUrl) {
- throw new Error("Image block missing image_url.url");
+ let rawImage: string | undefined;
+
+ const images = message.images;
+ if (Array.isArray(images) && images.length > 0) {
+ const first = images[0] as Record;
+ const block = (first.image_url ?? first.imageUrl) as
+ | Record
+ | undefined;
+ const url = block?.url;
+ if (typeof url === "string") {
+ rawImage = url;
+ }
}
- // The URL is a data URI: "data:image/png;base64,"
- const dataUri: string = imageUrl;
- const [meta, base64Data] = dataUri.split(",");
+ const content = message.content;
+ if (!rawImage && Array.isArray(content)) {
+ for (const part of content) {
+ if (!part || typeof part !== "object") continue;
+ const p = part as Record;
+ 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", "");
return {
diff --git a/lib/ai-models.ts b/lib/ai-models.ts
index 5ab5033..1161151 100644
--- a/lib/ai-models.ts
+++ b/lib/ai-models.ts
@@ -7,6 +7,8 @@ export interface AiModel {
tier: "budget" | "standard" | "premium";
description: string;
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
}
@@ -17,6 +19,7 @@ export const IMAGE_MODELS: AiModel[] = [
tier: "standard",
description: "Fast, high-quality generation",
estimatedCost: "~€0.04",
+ creditCost: 4,
minTier: "free",
},
// Phase 2 — uncomment when model selector UI is ready:
diff --git a/lib/format-time.ts b/lib/format-time.ts
new file mode 100644
index 0000000..6a4997f
--- /dev/null
+++ b/lib/format-time.ts
@@ -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",
+ });
+}