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:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -9,7 +10,6 @@ import { api } from "@/convex/_generated/api";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { normalizeTier, TIER_MONTHLY_CREDITS } from "@/lib/polar-products";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
const TIER_LABELS: Record<keyof typeof TIER_MONTHLY_CREDITS, string> = {
|
||||
free: "Free",
|
||||
@@ -19,6 +19,7 @@ const TIER_LABELS: Record<keyof typeof TIER_MONTHLY_CREDITS, string> = {
|
||||
};
|
||||
|
||||
export function ManageSubscription() {
|
||||
const t = useTranslations('toasts');
|
||||
const subscription = useAuthQuery(api.credits.getSubscription);
|
||||
const tier = normalizeTier(subscription?.tier);
|
||||
|
||||
@@ -45,8 +46,8 @@ export function ManageSubscription() {
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
toast.info(
|
||||
msg.billing.openingPortal.title,
|
||||
msg.billing.openingPortal.desc,
|
||||
t('billing.openingPortalTitle'),
|
||||
t('billing.openingPortalDesc'),
|
||||
);
|
||||
void authClient.customer.portal();
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -13,18 +14,18 @@ import {
|
||||
TIER_MONTHLY_CREDITS,
|
||||
} from "@/lib/polar-products";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
const TIER_ORDER = ["free", "starter", "pro", "max"] as const;
|
||||
|
||||
export function PricingCards() {
|
||||
const t = useTranslations('toasts');
|
||||
const subscription = useAuthQuery(api.credits.getSubscription);
|
||||
const currentTier = normalizeTier(subscription?.tier);
|
||||
|
||||
async function handleCheckout(polarProductId: string) {
|
||||
toast.info(
|
||||
msg.billing.redirectingToCheckout.title,
|
||||
msg.billing.redirectingToCheckout.desc,
|
||||
t('billing.redirectingToCheckoutTitle'),
|
||||
t('billing.redirectingToCheckoutDesc'),
|
||||
);
|
||||
await authClient.checkout({ products: [polarProductId] });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CreditCard, Zap } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -9,18 +10,18 @@ import { authClient } from "@/lib/auth-client";
|
||||
import { TOPUP_PRODUCTS } from "@/lib/polar-products";
|
||||
import { calculateCustomTopup } from "@/lib/topup-calculator";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
const CUSTOM_TOPUP_PRODUCT_ID = "POLAR_PRODUCT_ID_TOPUP_CUSTOM";
|
||||
|
||||
export function TopupPanel() {
|
||||
const t = useTranslations('toasts');
|
||||
const [customAmount, setCustomAmount] = useState(20);
|
||||
const { credits, bonusRate } = calculateCustomTopup(customAmount);
|
||||
|
||||
async function handleTopup(polarProductId: string) {
|
||||
toast.info(
|
||||
msg.billing.redirectingToCheckout.title,
|
||||
msg.billing.redirectingToCheckout.desc,
|
||||
t('billing.redirectingToCheckoutTitle'),
|
||||
t('billing.redirectingToCheckoutDesc'),
|
||||
);
|
||||
await authClient.checkout({ products: [polarProductId] });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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_";
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowUpRight, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -34,6 +34,7 @@ interface CanvasCardProps {
|
||||
}
|
||||
|
||||
export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
|
||||
const t = useTranslations('toasts');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editName, setEditName] = useState(canvas.name);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -60,8 +61,7 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
|
||||
const handleSave = useCallback(async () => {
|
||||
const trimmedName = editName.trim();
|
||||
if (!trimmedName) {
|
||||
const { title, desc } = msg.dashboard.renameEmpty;
|
||||
toast.error(title, desc);
|
||||
toast.error(t('dashboard.renameEmptyTitle'), t('dashboard.renameEmptyDesc'));
|
||||
return;
|
||||
}
|
||||
if (trimmedName === canvas.name) {
|
||||
@@ -74,15 +74,15 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateCanvas({ canvasId: canvas._id, name: trimmedName });
|
||||
toast.success(msg.dashboard.renameSuccess.title);
|
||||
toast.success(t('dashboard.renameSuccess'));
|
||||
setIsEditing(false);
|
||||
} catch {
|
||||
toast.error(msg.dashboard.renameFailed.title);
|
||||
toast.error(t('dashboard.renameFailed'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
saveInFlightRef.current = false;
|
||||
}
|
||||
}, [editName, canvas.name, canvas._id, updateCanvas]);
|
||||
}, [t, editName, canvas.name, canvas._id, updateCanvas]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -115,14 +115,14 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
|
||||
setDeleteBusy(true);
|
||||
try {
|
||||
await removeCanvas({ canvasId: canvas._id });
|
||||
toast.success(msg.dashboard.deleteSuccess.title);
|
||||
toast.success(t('dashboard.deleteSuccess'));
|
||||
setDeleteOpen(false);
|
||||
} catch {
|
||||
toast.error(msg.dashboard.deleteFailed.title);
|
||||
toast.error(t('dashboard.deleteFailed'));
|
||||
} finally {
|
||||
setDeleteBusy(false);
|
||||
}
|
||||
}, [canvas._id, removeCanvas]);
|
||||
}, [t, canvas._id, removeCanvas]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
import { useFormatter, useTranslations } from "next-intl";
|
||||
import { CreditCard } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -10,10 +11,8 @@ 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";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier-Config — monatliches Credit-Kontingent pro Tier (in Cent)
|
||||
@@ -42,7 +41,12 @@ const TIER_BADGE_STYLES: Record<string, string> = {
|
||||
const LOW_CREDITS_THRESHOLD = 20;
|
||||
|
||||
export function CreditOverview() {
|
||||
const t = useTranslations('toasts');
|
||||
const router = useRouter();
|
||||
const format = useFormatter();
|
||||
|
||||
const formatEurFromCents = (cents: number) =>
|
||||
format.number(cents / 100, { style: "currency", currency: "EUR" });
|
||||
const balance = useAuthQuery(api.credits.getBalance);
|
||||
const subscription = useAuthQuery(api.credits.getSubscription);
|
||||
const usageStats = useAuthQuery(api.credits.getUsageStats);
|
||||
@@ -56,14 +60,13 @@ export function CreditOverview() {
|
||||
if (typeof window !== "undefined" && sessionStorage.getItem(key)) return;
|
||||
sessionStorage.setItem(key, "1");
|
||||
|
||||
const { title, desc } = msg.billing.lowCredits(available);
|
||||
toast.action(title, {
|
||||
description: desc,
|
||||
label: msg.billing.topUp,
|
||||
toast.action(t('billing.lowCreditsTitle'), {
|
||||
description: t('billing.lowCreditsDesc', { remaining: available }),
|
||||
label: t('billing.topUp'),
|
||||
onClick: () => router.push("/settings/billing"),
|
||||
type: "warning",
|
||||
});
|
||||
}, [balance, router]);
|
||||
}, [t, balance, router]);
|
||||
|
||||
// ── Loading State ──────────────────────────────────────────────────────
|
||||
if (
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
import { useFormatter } from "next-intl";
|
||||
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";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -45,10 +45,28 @@ function truncatedDescription(text: string, maxLen = 40) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RecentTransactions() {
|
||||
const format = useFormatter();
|
||||
const transactions = useAuthQuery(api.credits.getRecentTransactions, {
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const formatEurFromCents = (cents: number) =>
|
||||
format.number(cents / 100, { style: "currency", currency: "EUR" });
|
||||
|
||||
const formatRelativeTime = (timestamp: number) => {
|
||||
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 "Gerade eben";
|
||||
if (minutes < 60) return `vor ${minutes} Min.`;
|
||||
if (hours < 24) return `vor ${hours} Std.`;
|
||||
if (days < 7) return days === 1 ? "vor 1 Tag" : `vor ${days} Tagen`;
|
||||
return format.dateTime(timestamp, { day: "numeric", month: "short" });
|
||||
};
|
||||
|
||||
// ── Loading State ──────────────────────────────────────────────────────
|
||||
if (transactions === undefined) {
|
||||
return (
|
||||
@@ -102,7 +120,7 @@ export function RecentTransactions() {
|
||||
Letzte Aktivität
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{transactions.map((t) => {
|
||||
{transactions.map((t: NonNullable<typeof transactions>[number]) => {
|
||||
const isCredit = t.amount > 0;
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useMutation } from "convex/react";
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
/**
|
||||
* Initialisiert die Credit-Balance für neue User.
|
||||
@@ -14,6 +14,7 @@ import { msg } from "@/lib/toast-messages";
|
||||
* dass jeder eingeloggte User eine Balance + Free-Subscription hat.
|
||||
*/
|
||||
export function InitUser() {
|
||||
const t = useTranslations('toasts');
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const balance = useAuthQuery(api.credits.getBalance);
|
||||
@@ -34,12 +35,12 @@ export function InitUser() {
|
||||
|
||||
void initBalance()
|
||||
.then(() => {
|
||||
toast.success(msg.auth.initialSetup.title, msg.auth.initialSetup.desc);
|
||||
toast.success(t('auth.initialSetupTitle'), t('auth.initialSetupDesc'));
|
||||
})
|
||||
.catch(() => {
|
||||
initStartedRef.current = false;
|
||||
});
|
||||
}, [session?.user, balance, initBalance]);
|
||||
}, [t, session?.user, balance, initBalance]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
24
components/locale-switcher.tsx
Normal file
24
components/locale-switcher.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function LocaleSwitcher() {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
function switchLocale() {
|
||||
const next = locale === 'de' ? 'en' : 'de';
|
||||
document.cookie = `NEXT_LOCALE=${next}; path=/; max-age=31536000; SameSite=Lax`;
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={switchLocale}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={locale === 'de' ? 'Switch to English' : 'Auf Deutsch wechseln'}
|
||||
>
|
||||
{locale === 'de' ? 'EN' : 'DE'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { ConvexReactClient } from "convex/react";
|
||||
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
|
||||
import { AuthUIProvider } from "@daveyplate/better-auth-ui";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import type { AbstractIntlMessages } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { GooeyToaster } from "goey-toast";
|
||||
@@ -35,37 +37,43 @@ function SentryAuthUserSync() {
|
||||
export function Providers({
|
||||
children,
|
||||
initialToken,
|
||||
locale,
|
||||
messages,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
initialToken?: string | null;
|
||||
locale?: string;
|
||||
messages?: AbstractIntlMessages;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ConvexBetterAuthProvider
|
||||
client={convex}
|
||||
authClient={authClient}
|
||||
initialToken={initialToken}
|
||||
>
|
||||
<SentryAuthUserSync />
|
||||
<AuthUIProvider
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<ConvexBetterAuthProvider
|
||||
client={convex}
|
||||
authClient={authClient}
|
||||
navigate={router.push}
|
||||
replace={router.replace}
|
||||
onSessionChange={() => router.refresh()}
|
||||
Link={Link}
|
||||
initialToken={initialToken}
|
||||
>
|
||||
{children}
|
||||
<GooeyToaster
|
||||
position="bottom-right"
|
||||
theme="dark"
|
||||
visibleToasts={4}
|
||||
maxQueue={8}
|
||||
queueOverflow="drop-oldest"
|
||||
/>
|
||||
</AuthUIProvider>
|
||||
</ConvexBetterAuthProvider>
|
||||
<SentryAuthUserSync />
|
||||
<AuthUIProvider
|
||||
authClient={authClient}
|
||||
navigate={router.push}
|
||||
replace={router.replace}
|
||||
onSessionChange={() => router.refresh()}
|
||||
Link={Link}
|
||||
>
|
||||
{children}
|
||||
<GooeyToaster
|
||||
position="bottom-right"
|
||||
theme="dark"
|
||||
visibleToasts={4}
|
||||
maxQueue={8}
|
||||
queueOverflow="drop-oldest"
|
||||
/>
|
||||
</AuthUIProvider>
|
||||
</ConvexBetterAuthProvider>
|
||||
</NextIntlClientProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user