feat: integrate Sentry for error tracking and enhance user notifications

- Added Sentry integration for error tracking across various components, including error boundaries and user actions.
- Updated global error handling to capture exceptions and provide detailed feedback to users.
- Enhanced user notifications with toast messages for actions such as credit management, image generation, and canvas exports.
- Improved user experience by displaying relevant messages during interactions, ensuring better visibility of system states and errors.
This commit is contained in:
Matthias
2026-03-27 18:14:04 +01:00
parent 5da0204163
commit 2f89465e82
35 changed files with 2822 additions and 186 deletions

View File

@@ -1,5 +1,7 @@
import { convexBetterAuthNextJs } from "@convex-dev/better-auth/nextjs";
import { api } from "@/convex/_generated/api";
export const {
handler, // Route Handler für /api/auth/*
preloadAuthQuery, // SSR: Query mit Auth vorladen
@@ -17,3 +19,8 @@ export const {
isAuthError: (error) => /auth/i.test(String(error)),
},
});
/** Aktueller User für SSR (z. B. Sentry `setUser`), oder `null`. */
export async function getAuthUser() {
return fetchAuthQuery(api.auth.safeGetAuthUser, {});
}

16
lib/rate-limit.ts Normal file
View File

@@ -0,0 +1,16 @@
import { redis } from "./redis";
export async function rateLimit(
key: string,
limit: number,
windowSeconds: number
): Promise<{ success: boolean; remaining: number }> {
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, windowSeconds);
}
return {
success: count <= limit,
remaining: Math.max(0, limit - count),
};
}

29
lib/redis.ts Normal file
View File

@@ -0,0 +1,29 @@
import Redis from "ioredis";
function createRedis(): Redis {
const url = process.env.REDIS_URL?.trim();
if (url) {
return new Redis(url, {
retryStrategy: (times) => Math.min(times * 100, 3000),
});
}
return new Redis({
host: process.env.REDIS_HOST ?? "127.0.0.1",
port: Number.parseInt(process.env.REDIS_PORT ?? "6379", 10),
password: process.env.REDIS_PASSWORD || undefined,
retryStrategy: (times) => Math.min(times * 100, 3000),
});
}
const globalForRedis = globalThis as unknown as { redis?: Redis };
export const redis = globalForRedis.redis ?? createRedis();
redis.on("error", (err) => {
console.error("[Redis] Connection error:", err);
});
if (process.env.NODE_ENV !== "production") {
globalForRedis.redis = redis;
}

139
lib/toast-messages.ts Normal file
View File

@@ -0,0 +1,139 @@
// Zentrales Dictionary für alle Toast-Strings.
// Spätere i18n: diese Datei gegen Framework-Lookup ersetzen.
export const msg = {
canvas: {
imageUploaded: { title: "Bild hochgeladen" },
uploadFailed: { title: "Upload fehlgeschlagen" },
uploadFormatError: (format: string) => ({
title: "Upload fehlgeschlagen",
desc: `Format „${format}“ wird nicht unterstützt. Erlaubt: PNG, JPG, WebP.`,
}),
uploadSizeError: (maxMb: number) => ({
title: "Upload fehlgeschlagen",
desc: `Maximale Dateigröße: ${maxMb} MB.`,
}),
nodeRemoved: { title: "Element entfernt" },
nodesRemoved: (count: number) => ({
title: count === 1 ? "Element entfernt" : `${count} Elemente entfernt`,
}),
},
ai: {
generating: { title: "Bild wird generiert…" },
generated: { title: "Bild generiert" },
generatedDesc: (credits: number) => `${credits} Credits verbraucht`,
generationFailed: { title: "Generierung fehlgeschlagen" },
creditsNotCharged: "Credits wurden nicht abgebucht",
insufficientCredits: (needed: number, available: number) => ({
title: "Nicht genügend Credits",
desc: `${needed} Credits benötigt, ${available} verfügbar.`,
}),
modelUnavailable: {
title: "Modell vorübergehend nicht verfügbar",
desc: "Versuche ein anderes Modell oder probiere es später erneut.",
},
contentPolicy: {
title: "Anfrage durch Inhaltsrichtlinie blockiert",
desc: "Versuche, den Prompt umzuformulieren.",
},
timeout: {
title: "Generierung abgelaufen",
desc: "Credits wurden nicht abgebucht.",
},
openrouterIssues: {
title: "OpenRouter möglicherweise gestört",
desc: "Mehrere Generierungen fehlgeschlagen.",
},
},
export: {
frameExported: { title: "Frame exportiert" },
exportingFrames: { title: "Frames werden exportiert…" },
zipReady: { title: "ZIP bereit" },
exportFailed: { title: "Export fehlgeschlagen" },
frameEmpty: {
title: "Export fehlgeschlagen",
desc: "Frame hat keinen sichtbaren Inhalt.",
},
noFramesOnCanvas: {
title: "Export fehlgeschlagen",
desc: "Keine Frames auf dem Canvas — zuerst einen Frame anlegen.",
},
download: "Herunterladen",
downloaded: "Heruntergeladen!",
},
auth: {
welcomeBack: { title: "Willkommen zurück" },
welcomeOnDashboard: { title: "Schön, dass du da bist" },
checkEmail: (email: string) => ({
title: "E-Mail prüfen",
desc: `Bestätigungslink an ${email} gesendet.`,
}),
sessionExpired: {
title: "Sitzung abgelaufen",
desc: "Bitte erneut anmelden.",
},
signedOut: { title: "Abgemeldet" },
signIn: "Anmelden",
initialSetup: {
title: "Startguthaben aktiv",
desc: "Du kannst loslegen.",
},
},
billing: {
subscriptionActivated: (credits: number) => ({
title: "Abo aktiviert",
desc: `${credits} Credits deinem Guthaben hinzugefügt.`,
}),
creditsAdded: (credits: number) => ({
title: "Credits hinzugefügt",
desc: `+${credits} Credits`,
}),
subscriptionCancelled: (periodEnd: string) => ({
title: "Abo gekündigt",
desc: `Deine Credits bleiben bis ${periodEnd} verfügbar.`,
}),
paymentFailed: {
title: "Zahlung fehlgeschlagen",
desc: "Bitte Zahlungsmethode aktualisieren.",
},
dailyLimitReached: (limit: number) => ({
title: "Tageslimit erreicht",
desc: `Maximal ${limit} Generierungen pro Tag in deinem Tarif.`,
}),
lowCredits: (remaining: number) => ({
title: "Credits fast aufgebraucht",
desc: `Noch ${remaining} Credits übrig.`,
}),
topUp: "Aufladen",
upgrade: "Upgrade",
manage: "Verwalten",
redirectingToCheckout: {
title: "Weiterleitung…",
desc: "Du wirst zum sicheren Checkout weitergeleitet.",
},
openingPortal: {
title: "Portal wird geöffnet…",
desc: "Du wirst zur Aboverwaltung weitergeleitet.",
},
testGrantFailed: { title: "Gutschrift fehlgeschlagen" },
},
system: {
reconnected: { title: "Verbindung wiederhergestellt" },
connectionLost: {
title: "Verbindung verloren",
desc: "Änderungen werden möglicherweise nicht gespeichert.",
},
copiedToClipboard: { title: "In Zwischenablage kopiert" },
},
dashboard: {
renameEmpty: { title: "Name ungültig", desc: "Name darf nicht leer sein." },
renameSuccess: { title: "Arbeitsbereich umbenannt" },
renameFailed: { title: "Umbenennen fehlgeschlagen" },
},
} as const;

View File

@@ -1,82 +1,109 @@
import type { ReactNode } from "react"
import { isValidElement } from "react"
import { toast as sonnerToast, type ExternalToast } from "sonner"
import { gooeyToast, type GooeyPromiseData } from "goey-toast";
const SUCCESS_DURATION = 4000
const ERROR_DURATION = 6000
const DURATION = {
success: 4000,
successShort: 2000,
error: 6000,
warning: 5000,
info: 4000,
} as const;
type SonnerPromiseInput<T> = Parameters<typeof sonnerToast.promise<T>>[0]
type SonnerPromiseOptions<T> = Parameters<typeof sonnerToast.promise<T>>[1]
type SonnerPromiseData<T> = NonNullable<SonnerPromiseOptions<T>>
function hasMessage(
value: unknown,
): value is {
message: ReactNode
duration?: number
} {
return (
typeof value === "object" &&
value !== null &&
!isValidElement(value) &&
"message" in value
)
}
function withStateDuration<T>(state: unknown, duration: number): unknown {
if (state === undefined) {
return undefined
}
if (typeof state === "function") {
return async (value: T) => {
const result = await state(value)
return withStateDuration(result, duration)
}
}
if (hasMessage(state)) {
return {
...state,
duration: state.duration ?? duration,
}
}
return {
message: state as ReactNode,
duration,
}
}
export type ToastDurationOverrides = {
duration?: number;
};
export const toast = {
success(message: ReactNode, options?: ExternalToast) {
return sonnerToast.success(message, {
...options,
duration: options?.duration ?? SUCCESS_DURATION,
})
success(
message: string,
description?: string,
opts?: ToastDurationOverrides,
) {
return gooeyToast.success(message, {
description,
duration: opts?.duration ?? DURATION.success,
});
},
error(message: ReactNode, options?: ExternalToast) {
return sonnerToast.error(message, {
...options,
duration: options?.duration ?? ERROR_DURATION,
})
error(
message: string,
description?: string,
opts?: ToastDurationOverrides,
) {
return gooeyToast.error(message, {
description,
duration: opts?.duration ?? DURATION.error,
});
},
loading(message: ReactNode, options?: ExternalToast) {
return sonnerToast.loading(message, options)
warning(
message: string,
description?: string,
opts?: ToastDurationOverrides,
) {
return gooeyToast.warning(message, {
description,
duration: opts?.duration ?? DURATION.warning,
});
},
dismiss(id?: number | string) {
return sonnerToast.dismiss(id)
info(
message: string,
description?: string,
opts?: ToastDurationOverrides,
) {
return gooeyToast.info(message, {
description,
duration: opts?.duration ?? DURATION.info,
});
},
promise<T>(promise: SonnerPromiseInput<T>, options?: SonnerPromiseOptions<T>) {
return sonnerToast.promise(promise, {
...options,
success: withStateDuration<T>(options?.success, SUCCESS_DURATION) as SonnerPromiseData<T>["success"],
error: withStateDuration<T>(options?.error, ERROR_DURATION) as SonnerPromiseData<T>["error"],
})
promise<T>(promise: Promise<T>, data: GooeyPromiseData<T>) {
return gooeyToast.promise(promise, data);
},
}
action(
message: string,
opts: {
description?: string;
label: string;
onClick: () => void;
successLabel?: string;
type?: "success" | "info" | "warning";
duration?: number;
},
) {
const t = opts.type ?? "info";
return gooeyToast[t](message, {
description: opts.description,
duration: opts.duration ?? (t === "success" ? DURATION.success : DURATION.info),
action: {
label: opts.label,
onClick: opts.onClick,
successLabel: opts.successLabel,
},
});
},
update(
id: string | number,
opts: {
title?: string;
description?: string;
type?: "default" | "success" | "error" | "warning" | "info";
},
) {
gooeyToast.update(id, opts);
},
dismiss(id?: string | number) {
gooeyToast.dismiss(id);
},
};
export const toastDuration = {
success: SUCCESS_DURATION,
error: ERROR_DURATION,
} as const
success: DURATION.success,
successShort: DURATION.successShort,
error: DURATION.error,
warning: DURATION.warning,
info: DURATION.info,
} as const;