Implement internationalization support across components

- Integrated `next-intl` for toast messages and locale handling in various components, including `Providers`, `CanvasUserMenu`, and `CreditOverview`.
- Replaced hardcoded strings with translation keys to enhance localization capabilities.
- Updated `RootLayout` to dynamically set the language attribute based on the user's locale.
- Ensured consistent user feedback through localized toast messages in actions such as sign-out, canvas operations, and billing notifications.
This commit is contained in:
2026-04-01 18:16:52 +02:00
parent 6ce1d4a82e
commit 79d9092d43
44 changed files with 1385 additions and 507 deletions

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation } from "convex/react";
import { useTheme } from "next-themes";
import { useTranslations } from "next-intl";
import {
Monitor,
Moon,
@@ -36,7 +37,6 @@ import { Input } from "@/components/ui/input";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import { useAuthQuery } from "@/hooks/use-auth-query";
type CanvasAppMenuProps = {
@@ -44,6 +44,7 @@ type CanvasAppMenuProps = {
};
export function CanvasAppMenu({ canvasId }: CanvasAppMenuProps) {
const t = useTranslations('toasts');
const router = useRouter();
const canvas = useAuthQuery(api.canvases.get, { canvasId });
const removeCanvas = useMutation(api.canvases.remove);
@@ -65,8 +66,7 @@ export function CanvasAppMenu({ canvasId }: CanvasAppMenuProps) {
const handleRename = async () => {
const trimmed = renameValue.trim();
if (!trimmed) {
const { title, desc } = msg.dashboard.renameEmpty;
toast.error(title, desc);
toast.error(t('dashboard.renameEmptyTitle'), t('dashboard.renameEmptyDesc'));
return;
}
if (trimmed === canvas?.name) {
@@ -76,10 +76,10 @@ export function CanvasAppMenu({ canvasId }: CanvasAppMenuProps) {
setRenameSaving(true);
try {
await renameCanvas({ canvasId, name: trimmed });
toast.success(msg.dashboard.renameSuccess.title);
toast.success(t('dashboard.renameSuccess'));
setRenameOpen(false);
} catch {
toast.error(msg.dashboard.renameFailed.title);
toast.error(t('dashboard.renameFailed'));
} finally {
setRenameSaving(false);
}

View File

@@ -6,15 +6,19 @@ import {
type Node as RFNode,
type OnBeforeDelete,
} from "@xyflow/react";
import { useTranslations } from "next-intl";
import type { Id } from "@/convex/_generated/dataModel";
import { computeBridgeCreatesForDeletedNodes } from "@/lib/canvas-utils";
import { toast } from "@/lib/toast";
import { msg, type CanvasNodeDeleteBlockReason } from "@/lib/toast-messages";
import { type CanvasNodeDeleteBlockReason } from "@/lib/toast";
import { getNodeDeleteBlockReason } from "./canvas-helpers";
type ToastTranslations = ReturnType<typeof useTranslations<'toasts'>>;
type UseCanvasDeleteHandlersParams = {
t: ToastTranslations;
canvasId: Id<"canvases">;
nodes: RFNode[];
edges: RFEdge[];
@@ -32,6 +36,7 @@ type UseCanvasDeleteHandlersParams = {
};
export function useCanvasDeleteHandlers({
t,
canvasId,
nodes,
edges,
@@ -71,16 +76,20 @@ export function useCanvasDeleteHandlers({
}
if (allowed.length === 0) {
const { title, desc } = msg.canvas.nodeDeleteBlockedExplain(blockedReasons);
const title = t('canvas.nodeDeleteBlockedTitle');
const desc = t('canvas.nodeDeleteBlockedDesc');
toast.warning(title, desc);
return false;
}
if (blocked.length > 0) {
const { title, desc } = msg.canvas.nodeDeleteBlockedPartial(
blocked.length,
blockedReasons,
);
const title = t('canvas.nodeDeleteBlockedPartialTitle');
const whyDesc = t('canvas.nodeDeleteBlockedDesc');
const suffix =
blocked.length === 1
? t('canvas.nodeDeleteBlockedPartialSuffixOne')
: t('canvas.nodeDeleteBlockedPartialSuffixOther', { count: blocked.length });
const desc = `${whyDesc} ${suffix}`;
toast.warning(title, desc);
return {
nodes: allowed,
@@ -140,10 +149,11 @@ export function useCanvasDeleteHandlers({
}
});
const { title } = msg.canvas.nodesRemoved(count);
const title = t('canvas.nodesRemoved', { count });
toast.info(title);
},
[
t,
canvasId,
deletingNodeIds,
edges,

View File

@@ -1,8 +1,8 @@
import { useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import type { Doc } from "@/convex/_generated/dataModel";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import {
GENERATION_FAILURE_THRESHOLD,
@@ -10,6 +10,7 @@ import {
} from "./canvas-helpers";
export function useGenerationFailureWarnings(
t: ReturnType<typeof useTranslations<'toasts'>>,
convexNodes: Doc<"nodes">[] | undefined,
): void {
const recentGenerationFailureTimestampsRef = useRef<number[]>([]);
@@ -60,11 +61,11 @@ export function useGenerationFailureWarnings(
}
if (recentFailures.length >= GENERATION_FAILURE_THRESHOLD) {
toast.warning(msg.ai.openrouterIssues.title, msg.ai.openrouterIssues.desc);
toast.warning(t('ai.openrouterIssuesTitle'), t('ai.openrouterIssuesDesc'));
recentGenerationFailureTimestampsRef.current = [];
return;
}
recentGenerationFailureTimestampsRef.current = recentFailures;
}, [convexNodes]);
}, [t, convexNodes]);
}

View File

@@ -2,7 +2,7 @@ import type { DefaultEdgeOptions, Edge as RFEdge, Node as RFNode } from "@xyflow
import { readCanvasOps } from "@/lib/canvas-local-persistence";
import type { Doc, Id } from "@/convex/_generated/dataModel";
import type { CanvasNodeDeleteBlockReason } from "@/lib/toast-messages";
import type { CanvasNodeDeleteBlockReason } from "@/lib/toast";
export const OPTIMISTIC_NODE_PREFIX = "optimistic_";
export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";

View File

@@ -38,12 +38,11 @@ export function CanvasShell({ canvasId }: CanvasShellProps) {
return (
<div className="h-screen w-screen overflow-hidden overscroll-none">
<ResizablePanelGroup
direction="horizontal"
orientation="horizontal"
className="h-full w-full min-h-0 min-w-0 overflow-hidden"
>
<ResizablePanel
id="canvas-sidebar-panel"
order={1}
defaultSize={SIDEBAR_DEFAULT_SIZE}
minSize={SIDEBAR_COLLAPSE_THRESHOLD}
maxSize={SIDEBAR_MAX_SIZE}
@@ -62,7 +61,6 @@ export function CanvasShell({ canvasId }: CanvasShellProps) {
<ResizablePanel
id="canvas-main-panel"
order={2}
minSize={MAIN_PANEL_MIN_SIZE}
className="min-h-0 min-w-0"
>

View File

@@ -2,13 +2,13 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { LayoutDashboard, LogOut } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
function getInitials(nameOrEmail: string) {
const normalized = nameOrEmail.trim();
@@ -25,6 +25,7 @@ type CanvasUserMenuProps = {
};
export function CanvasUserMenu({ compact = false }: CanvasUserMenuProps) {
const t = useTranslations('toasts');
const router = useRouter();
const { data: session, isPending } = authClient.useSession();
@@ -32,7 +33,7 @@ export function CanvasUserMenu({ compact = false }: CanvasUserMenuProps) {
const initials = getInitials(displayName);
const handleSignOut = async () => {
toast.info(msg.auth.signedOut.title);
toast.info(t('auth.signedOut'));
await authClient.signOut();
router.replace("/auth/sign-in");
router.refresh();

View File

@@ -10,6 +10,7 @@ import {
type MouseEvent as ReactMouseEvent,
} from "react";
import { useTheme } from "next-themes";
import { useTranslations } from "next-intl";
import {
ReactFlow,
ReactFlowProvider,
@@ -30,7 +31,6 @@ import {
import { cn } from "@/lib/utils";
import "@xyflow/react/dist/style.css";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import {
dropCanvasOpsByClientRequestIds,
dropCanvasOpsByEdgeIds,
@@ -153,6 +153,7 @@ function isLikelyTransientSyncError(error: unknown): boolean {
}
function CanvasInner({ canvasId }: CanvasInnerProps) {
const t = useTranslations('toasts');
const { screenToFlowPosition } = useReactFlow();
const { resolvedTheme } = useTheme();
const { data: session, isPending: isSessionPending } = authClient.useSession();
@@ -1583,7 +1584,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const highlightedEdgeOriginalStyleRef = useRef<RFEdge["style"] | undefined>(
undefined,
);
useGenerationFailureWarnings(convexNodes);
useGenerationFailureWarnings(t, convexNodes);
const { onEdgeClickScissors, onScissorsFlowPointerDownCapture } = useCanvasScissors({
scissorsMode,
@@ -1596,6 +1597,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
});
const { onBeforeDelete, onNodesDelete, onEdgesDelete } = useCanvasDeleteHandlers({
t,
canvasId,
nodes,
edges,
@@ -2456,7 +2458,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
});
} catch (err) {
console.error("Failed to upload dropped file:", err);
toast.error(msg.canvas.uploadFailed.title, err instanceof Error ? err.message : undefined);
toast.error(t('canvas.uploadFailed'), err instanceof Error ? err.message : undefined);
}
return;
}

View File

@@ -2,16 +2,17 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useConvexConnectionState } from "convex/react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import { toast, toastDuration } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
type BannerState = "hidden" | "reconnecting" | "disconnected" | "reconnected";
const RECONNECTED_HIDE_DELAY_MS = 1800;
export default function ConnectionBanner() {
const t = useTranslations('toasts');
const connectionState = useConvexConnectionState();
const previousConnectedRef = useRef(connectionState.isWebSocketConnected);
const disconnectToastIdRef = useRef<string | number | undefined>(undefined);
@@ -77,8 +78,8 @@ export default function ConnectionBanner() {
if (shouldAlertDisconnect) {
if (disconnectToastIdRef.current === undefined) {
disconnectToastIdRef.current = toast.error(
msg.system.connectionLost.title,
msg.system.connectionLost.desc,
t('system.connectionLostTitle'),
t('system.connectionLostDesc'),
{ duration: Number.POSITIVE_INFINITY },
);
}
@@ -88,11 +89,12 @@ export default function ConnectionBanner() {
if (connected && disconnectToastIdRef.current !== undefined) {
toast.dismiss(disconnectToastIdRef.current);
disconnectToastIdRef.current = undefined;
toast.success(msg.system.reconnected.title, undefined, {
toast.success(t('system.reconnected'), undefined, {
duration: toastDuration.successShort,
});
}
}, [
t,
connectionState.connectionRetries,
connectionState.hasEverConnected,
connectionState.isWebSocketConnected,

View File

@@ -2,10 +2,10 @@
import { useMutation } from "convex/react";
import { useAuthQuery } from "@/hooks/use-auth-query";
import { useTranslations } from "next-intl";
import { api } from "@/convex/_generated/api";
import { Coins } from "lucide-react";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
const TIER_LABELS: Record<string, string> = {
free: "Free",
@@ -28,6 +28,7 @@ const showTestCreditGrant =
process.env.NEXT_PUBLIC_ALLOW_TEST_CREDIT_GRANT === "true";
export function CreditDisplay() {
const t = useTranslations('toasts');
const balance = useAuthQuery(api.credits.getBalance);
const subscription = useAuthQuery(api.credits.getSubscription);
const grantTestCredits = useMutation(api.credits.grantTestCredits);
@@ -92,15 +93,14 @@ export function CreditDisplay() {
onClick={() => {
void grantTestCredits({ amount: 2000 })
.then((r) => {
const { title, desc } = msg.billing.creditsAdded(2000);
toast.success(
title,
`${desc} — Stand: ${r.newBalance.toLocaleString("de-DE")}`,
t('billing.creditsAddedTitle'),
`${t('billing.creditsAddedDesc', { credits: 2000 })} — Stand: ${r.newBalance.toLocaleString("de-DE")}`,
);
})
.catch((e: unknown) => {
toast.error(
msg.billing.testGrantFailed.title,
t('billing.testGrantFailedTitle'),
e instanceof Error ? e.message : undefined,
);
});

View File

@@ -3,18 +3,19 @@
import { useCallback, useState } from "react";
import { useReactFlow } from "@xyflow/react";
import { useAction } from "convex/react";
import { useTranslations } from "next-intl";
import JSZip from "jszip";
import { Archive, Loader2 } from "lucide-react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
interface ExportButtonProps {
canvasName?: string;
}
export function ExportButton({ canvasName = "canvas" }: ExportButtonProps) {
const t = useTranslations('toasts');
const { getNodes } = useReactFlow();
const exportFrame = useAction(api.export.exportFrame);
const [isExporting, setIsExporting] = useState(false);
@@ -72,19 +73,19 @@ export function ExportButton({ canvasName = "canvas" }: ExportButtonProps) {
try {
await toast.promise(runExport(), {
loading: msg.export.exportingFrames.title,
success: msg.export.zipReady.title,
loading: t('export.exportingFrames'),
success: t('export.zipReady'),
error: (err) => {
const m = err instanceof Error ? err.message : "";
if (m === NO_FRAMES) return msg.export.noFramesOnCanvas.title;
if (m.includes("No images found")) return msg.export.frameEmpty.title;
return msg.export.exportFailed.title;
if (m === NO_FRAMES) return t('export.noFramesOnCanvasTitle');
if (m.includes("No images found")) return t('export.frameEmptyTitle');
return t('export.exportFailed');
},
description: {
error: (err) => {
const m = err instanceof Error ? err.message : "";
if (m === NO_FRAMES) return msg.export.noFramesOnCanvas.desc;
if (m.includes("No images found")) return msg.export.frameEmpty.desc;
if (m === NO_FRAMES) return t('export.noFramesOnCanvasDesc');
if (m.includes("No images found")) return t('export.frameEmptyDesc');
return m || undefined;
},
},
@@ -92,17 +93,17 @@ export function ExportButton({ canvasName = "canvas" }: ExportButtonProps) {
} catch (err) {
const m = err instanceof Error ? err.message : "";
if (m === NO_FRAMES) {
setError(msg.export.noFramesOnCanvas.desc);
setError(t('export.noFramesOnCanvasDesc'));
} else if (m.includes("No images found")) {
setError(msg.export.frameEmpty.desc);
setError(t('export.frameEmptyDesc'));
} else {
setError(m || msg.export.exportFailed.title);
setError(m || t('export.exportFailed'));
}
} finally {
setIsExporting(false);
setProgress(null);
}
}, [canvasName, exportFrame, getNodes, isExporting]);
}, [t, canvasName, exportFrame, getNodes, isExporting]);
return (
<div className="relative">

View File

@@ -2,16 +2,16 @@
import { useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Handle, Position, useReactFlow, type NodeProps, type Node } from "@xyflow/react";
import { useAction } from "convex/react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import BaseNodeWrapper from "./base-node-wrapper";
import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models";
import { classifyError, type AiErrorCategory } from "@/lib/ai-errors";
import { classifyError, type ErrorType } from "@/lib/ai-errors";
import { DEFAULT_ASPECT_RATIO } from "@/lib/image-formats";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import {
Loader2,
@@ -59,6 +59,7 @@ export default function AiImageNode({
data,
selected,
}: NodeProps<AiImageNode>) {
const t = useTranslations('toasts');
const nodeData = data as AiImageNodeData;
const { getEdges, getNode } = useReactFlow();
const { status: syncStatus } = useCanvasSync();
@@ -135,17 +136,17 @@ export default function AiImageNode({
aspectRatio: nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO,
}),
{
loading: msg.ai.generating.title,
success: msg.ai.generationQueued.title,
error: msg.ai.generationFailed.title,
loading: t('ai.generating'),
success: t('ai.generationQueued'),
error: t('ai.generationFailed'),
description: {
success: msg.ai.generationQueuedDesc,
error: msg.ai.creditsNotCharged,
success: t('ai.generationQueuedDesc'),
error: t('ai.creditsNotCharged'),
},
},
);
} catch (err) {
setLocalError(err instanceof Error ? err.message : msg.ai.generationFailed.title);
setLocalError(err instanceof Error ? err.message : t('ai.generationFailed'));
} finally {
setIsGenerating(false);
}
@@ -154,16 +155,16 @@ export default function AiImageNode({
const modelName =
getModel(nodeData.model ?? DEFAULT_MODEL_ID)?.name ?? "AI";
const renderErrorIcon = (category: AiErrorCategory) => {
switch (category) {
case "insufficient_credits":
const renderErrorIcon = (type: ErrorType) => {
switch (type) {
case "insufficientCredits":
return <Coins className="h-8 w-8 text-amber-500" />;
case "rate_limited":
case "rateLimited":
case "timeout":
return <Clock3 className="h-8 w-8 text-amber-500" />;
case "content_policy":
case "contentPolicy":
return <ShieldAlert className="h-8 w-8 text-destructive" />;
case "network":
case "networkError":
return <WifiOff className="h-8 w-8 text-destructive" />;
default:
return <AlertCircle className="h-8 w-8 text-destructive" />;
@@ -226,15 +227,10 @@ export default function AiImageNode({
{status === "error" && !isLoading && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-muted">
{renderErrorIcon(classifiedError.category)}
{renderErrorIcon(classifiedError.type)}
<p className="px-4 text-center text-xs font-medium text-destructive">
{classifiedError.message}
{classifiedError.rawMessage}
</p>
{classifiedError.detail && (
<p className="px-6 text-center text-[10px] text-muted-foreground">
{classifiedError.detail}
</p>
)}
{classifiedError.creditsNotCharged && (
<p className="px-6 text-center text-[10px] text-muted-foreground">
Credits not charged

View File

@@ -3,13 +3,13 @@
import { useCallback, useState } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { useAction } from "convex/react";
import { useTranslations } from "next-intl";
import { Download, Loader2 } from "lucide-react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import BaseNodeWrapper from "./base-node-wrapper";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
interface FrameNodeData {
@@ -19,6 +19,7 @@ interface FrameNodeData {
}
export default function FrameNode({ id, data, selected, width, height }: NodeProps) {
const t = useTranslations('toasts');
const nodeData = data as FrameNodeData;
const { queueNodeDataUpdate, status } = useCanvasSync();
const exportFrame = useAction(api.export.exportFrame);
@@ -54,23 +55,23 @@ export default function FrameNode({ id, data, selected, width, height }: NodePro
try {
const result = await exportFrame({ frameNodeId: id as Id<"nodes"> });
const fileLabel = `${label.trim() || "frame"}.png`;
toast.action(msg.export.frameExported.title, {
toast.action(t('export.frameExported'), {
description: fileLabel,
label: msg.export.download,
label: t('export.download'),
onClick: () => {
window.open(result.url, "_blank", "noopener,noreferrer");
},
successLabel: msg.export.downloaded,
successLabel: t('export.downloaded'),
type: "success",
});
} catch (error) {
const m = error instanceof Error ? error.message : "";
if (m.includes("No images found")) {
toast.error(msg.export.frameEmpty.title, msg.export.frameEmpty.desc);
setExportError(msg.export.frameEmpty.desc);
toast.error(t('export.frameEmptyTitle'), t('export.frameEmptyDesc'));
setExportError(t('export.frameEmptyDesc'));
} else {
toast.error(msg.export.exportFailed.title, m || undefined);
setExportError(m || msg.export.exportFailed.title);
toast.error(t('export.exportFailed'), m || undefined);
setExportError(m || t('export.exportFailed'));
}
} finally {
setIsExporting(false);

View File

@@ -9,11 +9,11 @@ import {
type DragEvent,
} from "react";
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
import { useTranslations } from "next-intl";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import BaseNodeWrapper from "./base-node-wrapper";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import { computeMediaNodeSize } from "@/lib/canvas-utils";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import { useMutation } from "convex/react";
@@ -73,6 +73,7 @@ export default function ImageNode({
width,
height,
}: NodeProps<ImageNode>) {
const t = useTranslations('toasts');
const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync();
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -121,17 +122,17 @@ export default function ImageNode({
const uploadFile = useCallback(
async (file: File) => {
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
const { title, desc } = msg.canvas.uploadFormatError(
file.type || file.name.split(".").pop() || "—",
toast.error(
t('canvas.uploadFailed'),
t('canvas.uploadFormatError', { format: file.type || file.name.split(".").pop() || "—" }),
);
toast.error(title, desc);
return;
}
if (file.size > MAX_IMAGE_BYTES) {
const { title, desc } = msg.canvas.uploadSizeError(
Math.round(MAX_IMAGE_BYTES / (1024 * 1024)),
toast.error(
t('canvas.uploadFailed'),
t('canvas.uploadSizeError', { maxMb: Math.round(MAX_IMAGE_BYTES / (1024 * 1024)) }),
);
toast.error(title, desc);
return;
}
if (status.isOffline) {
@@ -188,11 +189,11 @@ export default function ImageNode({
});
}
toast.success(msg.canvas.imageUploaded.title);
toast.success(t('canvas.imageUploaded'));
} catch (err) {
console.error("Upload failed:", err);
toast.error(
msg.canvas.uploadFailed.title,
t('canvas.uploadFailed'),
err instanceof Error ? err.message : undefined,
);
} finally {

View File

@@ -10,6 +10,7 @@ import {
type Node,
} from "@xyflow/react";
import { useAction } from "convex/react";
import { useTranslations } from "next-intl";
import { useAuthQuery } from "@/hooks/use-auth-query";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
@@ -38,7 +39,6 @@ import {
import { Sparkles, Loader2, Coins } from "lucide-react";
import { useRouter } from "next/navigation";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import { classifyError } from "@/lib/ai-errors";
type PromptNodeData = {
@@ -57,6 +57,7 @@ export default function PromptNode({
data,
selected,
}: NodeProps<PromptNode>) {
const t = useTranslations('toasts');
const nodeData = data as PromptNodeData;
const router = useRouter();
const { getEdges, getNode } = useReactFlow();
@@ -166,13 +167,9 @@ export default function PromptNode({
}
if (availableCredits !== null && !hasEnoughCredits) {
const { title, desc } = msg.ai.insufficientCredits(
creditCost,
availableCredits,
);
toast.action(title, {
description: desc,
label: msg.billing.topUp,
toast.action(t('ai.insufficientCreditsTitle'), {
description: t('ai.insufficientCreditsDesc', { needed: creditCost, available: availableCredits }),
label: t('billing.topUp'),
onClick: () => router.push("/settings/billing"),
type: "warning",
});
@@ -256,30 +253,30 @@ export default function PromptNode({
aspectRatio,
}),
{
loading: msg.ai.generating.title,
success: msg.ai.generationQueued.title,
error: msg.ai.generationFailed.title,
loading: t('ai.generating'),
success: t('ai.generationQueued'),
error: t('ai.generationFailed'),
description: {
success: msg.ai.generationQueuedDesc,
error: msg.ai.creditsNotCharged,
success: t('ai.generationQueuedDesc'),
error: t('ai.creditsNotCharged'),
},
},
);
} catch (err) {
const classified = classifyError(err);
if (classified.category === "daily_cap") {
if (classified.type === "dailyCap") {
toast.error(
msg.billing.dailyLimitReached(0).title,
t('billing.dailyLimitReachedTitle'),
"Morgen stehen wieder Generierungen zur Verfügung.",
);
} else if (classified.category === "concurrency") {
} else if (classified.type === "concurrency") {
toast.warning(
msg.ai.concurrentLimitReached.title,
msg.ai.concurrentLimitReached.desc,
t('ai.concurrentLimitReachedTitle'),
t('ai.concurrentLimitReachedDesc'),
);
} else {
setError(classified.message || msg.ai.generationFailed.title);
setError(classified.rawMessage || t('ai.generationFailed'));
}
} finally {
setIsGenerating(false);