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:
@@ -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
16
lib/rate-limit.ts
Normal 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
29
lib/redis.ts
Normal 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
139
lib/toast-messages.ts
Normal 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;
|
||||
169
lib/toast.ts
169
lib/toast.ts
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user