diff --git a/actions/themes.ts b/actions/themes.ts index 175a8cd2..a2fd4ec2 100644 --- a/actions/themes.ts +++ b/actions/themes.ts @@ -1,163 +1,150 @@ "use server"; import { z } from "zod"; -import { revalidatePath } from "next/cache"; import { db } from "@/db"; import { theme as themeTable } from "@/db/schema"; import { eq, and } from "drizzle-orm"; import cuid from "cuid"; import { auth } from "@/lib/auth"; -import { headers } from "next/headers"; // Keep for session, but actions handle auth differently +import { headers } from "next/headers"; import { themeStylesSchema, type ThemeStyles } from "@/types/theme"; import { cache } from "react"; +import { + UnauthorizedError, + ValidationError, + ThemeNotFoundError, + ThemeLimitError, +} from "@/types/errors"; -// Helper to get user ID (Consider centralizing auth checks) -async function getCurrentUserId(): Promise { +// Helper to get user ID with better error handling +async function getCurrentUserId(): Promise { const session = await auth.api.getSession({ - headers: await headers(), // you need to pass the headers object. + headers: await headers(), }); - return session?.user?.id ?? null; + + if (!session?.user?.id) { + throw new UnauthorizedError(); + } + + return session.user.id; +} + +// Log errors for observability +function logError(error: Error, context: Record) { + console.error("Theme action error:", error, context); + + // TODO: Add server-side error reporting to PostHog or your preferred service + // For production, you'd want to send critical errors to an external service + if (error.name === "UnauthorizedError" || error.name === "ValidationError") { + // These are expected errors, log but don't report + console.warn("Expected error:", { error: error.message, context }); + } else { + // Unexpected errors should be reported + console.error("Unexpected error:", { error: error.message, stack: error.stack, context }); + } } const createThemeSchema = z.object({ - name: z.string().min(1, "Theme name cannot be empty"), + name: z.string().min(1, "Theme name cannot be empty").max(50, "Theme name too long"), styles: themeStylesSchema, }); const updateThemeSchema = z.object({ - id: z.string(), // ID is needed to know which theme to update - name: z.string().min(1, "Theme name cannot be empty").optional(), + id: z.string().min(1, "Theme ID required"), + name: z.string().min(1, "Theme name cannot be empty").max(50, "Theme name too long").optional(), styles: themeStylesSchema.optional(), }); +// Layer 1: Clean server actions with proper error handling export async function getThemes() { - const userId = await getCurrentUserId(); - if (!userId) { - throw new Error("Unauthorized"); - } try { - const userThemes = await db - .select() - .from(themeTable) - .where(eq(themeTable.userId, userId)); + const userId = await getCurrentUserId(); + const userThemes = await db.select().from(themeTable).where(eq(themeTable.userId, userId)); return userThemes; } catch (error) { - console.error("Error fetching themes:", error); - throw new Error("Failed to fetch themes."); // Propagate a generic error + logError(error as Error, { action: "getThemes" }); + throw error; } } -// Wrap getTheme with React.cache export const getTheme = cache(async (themeId: string) => { try { - const [theme] = await db - .select() - .from(themeTable) - .where(eq(themeTable.id, themeId)) - .limit(1); + if (!themeId) { + throw new ValidationError("Theme ID required"); + } + + const [theme] = await db.select().from(themeTable).where(eq(themeTable.id, themeId)).limit(1); + + if (!theme) { + throw new ThemeNotFoundError(); + } return theme; } catch (error) { - console.error("Error fetching theme:", error); - throw new Error("Failed to fetch theme."); + logError(error as Error, { action: "getTheme", themeId }); + throw error; } }); -// Action to create a new theme -export async function createTheme(formData: { - name: string; - styles: ThemeStyles; -}) { - const userId = await getCurrentUserId(); - if (!userId) { - throw new Error("Unauthorized"); - } - - const validation = createThemeSchema.safeParse(formData); - if (!validation.success) { - // Return validation errors for the client to handle - return { - success: false, - error: "Invalid input", - details: validation.error.format(), - }; - } - - // Check if user already has 10 themes - const userThemes = await db - .select() - .from(themeTable) - .where(eq(themeTable.userId, userId)); - - if (userThemes.length >= 10) { - return { - success: false, - error: "Theme limit reached", - message: "You cannot have more than 10 themes yet.", - }; - } - - const { name, styles } = validation.data; - const newThemeId = cuid(); - const now = new Date(); - +export async function createTheme(formData: { name: string; styles: ThemeStyles }) { try { + const userId = await getCurrentUserId(); + + const validation = createThemeSchema.safeParse(formData); + if (!validation.success) { + throw new ValidationError("Invalid input", validation.error.format()); + } + + // Check theme limit + const userThemes = await db.select().from(themeTable).where(eq(themeTable.userId, userId)); + if (userThemes.length >= 10) { + throw new ThemeLimitError("You cannot have more than 10 themes yet."); + } + + const { name, styles } = validation.data; + const newThemeId = cuid(); + const now = new Date(); + const [insertedTheme] = await db .insert(themeTable) .values({ id: newThemeId, userId: userId, name: name, - styles: styles, // Already validated + styles: styles, createdAt: now, updatedAt: now, }) .returning(); - revalidatePath("/"); // Or a more specific path where themes are displayed - return { - success: true, - theme: insertedTheme, - }; + return insertedTheme; } catch (error) { - console.error("Error creating theme:", error); - return { success: false, error: "Internal Server Error" }; + logError(error as Error, { action: "createTheme", formData: { name: formData.name } }); + throw error; } } -// Action to update an existing theme -export async function updateTheme(formData: { - id: string; - name?: string; - styles?: ThemeStyles; -}) { - const userId = await getCurrentUserId(); - if (!userId) { - throw new Error("Unauthorized"); - } - - const validation = updateThemeSchema.safeParse(formData); - if (!validation.success) { - return { - success: false, - error: "Invalid input", - details: validation.error.format(), - }; - } - - const { id: themeId, name, styles } = validation.data; - - if (!name && !styles) { - return { success: false, error: "No update data provided" }; - } - - const updateData: Partial = { - updatedAt: new Date(), - }; - if (name) updateData.name = name; - if (styles) updateData.styles = styles; // Already validated - +export async function updateTheme(formData: { id: string; name?: string; styles?: ThemeStyles }) { try { + const userId = await getCurrentUserId(); + + const validation = updateThemeSchema.safeParse(formData); + if (!validation.success) { + throw new ValidationError("Invalid input", validation.error.format()); + } + + const { id: themeId, name, styles } = validation.data; + + if (!name && !styles) { + throw new ValidationError("No update data provided"); + } + + const updateData: Partial = { + updatedAt: new Date(), + }; + if (name) updateData.name = name; + if (styles) updateData.styles = styles; + const [updatedTheme] = await db .update(themeTable) .set(updateData) @@ -165,45 +152,36 @@ export async function updateTheme(formData: { .returning(); if (!updatedTheme) { - return { success: false, error: "Theme not found or not owned by user" }; + throw new ThemeNotFoundError("Theme not found or not owned by user"); } - revalidatePath("/"); // Or a more specific path - return { - success: true, - theme: updatedTheme, - }; + return updatedTheme; } catch (error) { - console.error(`Error updating theme ${themeId}:`, error); - return { success: false, error: "Internal Server Error" }; + logError(error as Error, { action: "updateTheme", themeId: formData.id }); + throw error; } } -// Action to delete a theme export async function deleteTheme(themeId: string) { - const userId = await getCurrentUserId(); - if (!userId) { - throw new Error("Unauthorized"); - } - - if (!themeId) { - return { success: false, error: "Theme ID required" }; - } - try { - const [deletedInfo] = await db + const userId = await getCurrentUserId(); + + if (!themeId) { + throw new ValidationError("Theme ID required"); + } + + const [deletedTheme] = await db .delete(themeTable) .where(and(eq(themeTable.id, themeId), eq(themeTable.userId, userId))) - .returning({ id: themeTable.id }); + .returning({ id: themeTable.id, name: themeTable.name }); - if (!deletedInfo) { - return { success: false, error: "Theme not found or not owned by user" }; + if (!deletedTheme) { + throw new ThemeNotFoundError("Theme not found or not owned by user"); } - revalidatePath("/dashboard"); // Or a more specific path - return { success: true, deletedId: themeId }; + return deletedTheme; } catch (error) { - console.error(`Error deleting theme ${themeId}:`, error); - return { success: false, error: "Internal Server Error" }; + logError(error as Error, { action: "deleteTheme", themeId }); + throw error; } } diff --git a/app/dashboard/components/theme-card.tsx b/app/dashboard/components/theme-card.tsx index e6048714..414416aa 100644 --- a/app/dashboard/components/theme-card.tsx +++ b/app/dashboard/components/theme-card.tsx @@ -10,18 +10,10 @@ import { DropdownMenuTrigger, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; -import { - MoreVertical, - Trash2, - Edit, - Loader2, - Zap, - ExternalLink, - Copy, -} from "lucide-react"; +import { MoreVertical, Trash2, Edit, Loader2, Zap, ExternalLink, Copy } from "lucide-react"; import { useMemo } from "react"; import { useEditorStore } from "@/store/editor-store"; -import { useThemeActions } from "@/hooks/use-theme-actions"; +import { useDeleteTheme } from "@/hooks/themes"; import Link from "next/link"; import { toast } from "@/components/ui/use-toast"; interface ThemeCardProps { @@ -46,11 +38,11 @@ const swatchDefinitions: SwatchDefinition[] = [ export function ThemeCard({ theme, className }: ThemeCardProps) { const { themeState, setThemeState } = useEditorStore(); - const { deleteTheme, isDeletingTheme } = useThemeActions(); + const deleteThemeMutation = useDeleteTheme(); const mode = themeState.currentMode; const handleDelete = () => { - deleteTheme(theme.id); + deleteThemeMutation.mutate(theme.id); }; const handleQuickApply = () => { @@ -74,27 +66,24 @@ export function ThemeCard({ theme, className }: ThemeCardProps) { // Get background color, fallback to a default if necessary (e.g., white) bg: theme.styles[mode][def.bgKey] || "#ffffff", // Get foreground color, fallback to main foreground or a default (e.g., black) - fg: - theme.styles[mode][def.fgKey] || - theme.styles[mode].foreground || - "#000000", + fg: theme.styles[mode][def.fgKey] || theme.styles[mode].foreground || "#000000", })); }, [mode, theme.styles]); return ( -
+
{colorSwatches.map((swatch) => (
@@ -114,12 +103,10 @@ export function ThemeCard({ theme, className }: ThemeCardProps) { ))}
-
+
-

- {theme.name} -

-

+

{theme.name}

+

{new Date(theme.createdAt).toLocaleDateString("en-US", { day: "numeric", month: "short", @@ -129,11 +116,11 @@ export function ThemeCard({ theme, className }: ThemeCardProps) {

-
- +
+
- + Quick Apply @@ -157,10 +144,10 @@ export function ThemeCard({ theme, className }: ThemeCardProps) { - {isDeletingTheme ? ( + {deleteThemeMutation.isPending ? ( ) : ( diff --git a/app/layout.tsx b/app/layout.tsx index fec6cadb..753cc23c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,7 @@ import { ThemeProvider } from "@/components/theme-provider"; import { ThemeScript } from "@/components/theme-script"; import { Toaster } from "@/components/ui/toaster"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { QueryProvider } from "@/lib/query-client"; import type { Metadata, Viewport } from "next"; import { NuqsAdapter } from "nuqs/adapters/next/app"; import { Suspense } from "react"; @@ -74,13 +75,15 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - - - - - {children} - - + + + + + + {children} + + + diff --git a/components/editor/theme-edit-actions.tsx b/components/editor/theme-edit-actions.tsx index d8d4d136..73f8a18f 100644 --- a/components/editor/theme-edit-actions.tsx +++ b/components/editor/theme-edit-actions.tsx @@ -1,7 +1,7 @@ import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { useThemeActions } from "@/hooks/use-theme-actions"; +import { useUpdateTheme } from "@/hooks/themes"; import { useEditorStore } from "@/store/editor-store"; import { Theme } from "@/types/theme"; import { Check, X } from "lucide-react"; @@ -17,10 +17,9 @@ interface ThemeEditActionsProps { const ThemeEditActions: React.FC = ({ theme, disabled = false }) => { const router = useRouter(); const searchParams = useSearchParams(); - const { updateTheme } = useThemeActions(); + const updateThemeMutation = useUpdateTheme(); const { themeState, applyThemePreset } = useEditorStore(); const [isNameDialogOpen, setIsNameDialogOpen] = useState(false); - const [isSaving, setIsSaving] = useState(false); const mainEditorUrl = `/editor/theme?${searchParams}`; @@ -31,7 +30,6 @@ const ThemeEditActions: React.FC = ({ theme, disabled = f }; const handleSaveTheme = async (newName: string) => { - setIsSaving(true); const dataToUpdate: { id: string; name?: string; @@ -52,19 +50,18 @@ const ThemeEditActions: React.FC = ({ theme, disabled = f if (!dataToUpdate.name && !dataToUpdate.styles) { setIsNameDialogOpen(false); - setIsSaving(false); return; } - const result = await updateTheme(dataToUpdate); - setIsSaving(false); - - if (result) { - setIsNameDialogOpen(false); - router.push(mainEditorUrl); - applyThemePreset(result?.id || themeState?.preset || "default"); - } else { - console.error("Failed to update theme"); + try { + const result = await updateThemeMutation.mutateAsync(dataToUpdate); + if (result) { + setIsNameDialogOpen(false); + router.push(mainEditorUrl); + applyThemePreset(result?.id || themeState?.preset || "default"); + } + } catch (error) { + console.error("Failed to update theme:", error); } }; @@ -126,7 +123,7 @@ const ThemeEditActions: React.FC = ({ theme, disabled = f open={isNameDialogOpen} onOpenChange={setIsNameDialogOpen} onSave={handleSaveTheme} - isSaving={isSaving} + isSaving={updateThemeMutation.isPending} initialThemeName={theme.name} title="Save Theme Changes" description="Confirm or update the theme name before saving." diff --git a/hooks/themes/index.ts b/hooks/themes/index.ts new file mode 100644 index 00000000..80b6e1f8 --- /dev/null +++ b/hooks/themes/index.ts @@ -0,0 +1,2 @@ +export { useThemesData, useThemeData, usePrefetchThemes, themeKeys } from "./use-themes-data"; +export { useCreateTheme, useUpdateTheme, useDeleteTheme } from "./use-theme-mutations"; diff --git a/hooks/themes/use-theme-mutations.ts b/hooks/themes/use-theme-mutations.ts new file mode 100644 index 00000000..76431d43 --- /dev/null +++ b/hooks/themes/use-theme-mutations.ts @@ -0,0 +1,179 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createTheme, updateTheme, deleteTheme } from "@/actions/themes"; +import { themeKeys } from "./use-themes-data"; +import { ThemeStyles, Theme } from "@/types/theme"; +import { toast } from "@/components/ui/use-toast"; +import { useThemePresetStore } from "@/store/theme-preset-store"; +import posthog from "posthog-js"; + +function handleMutationError(error: Error, operation: string) { + console.error(`Theme ${operation} error:`, error); + + if (error.name !== "UnauthorizedError" && error.name !== "ValidationError") { + try { + posthog.capture("theme_mutation_error", { + operation, + error: error.message, + errorName: error.name, + }); + } catch (posthogError) { + console.error("Failed to log to PostHog:", posthogError); + } + } + + const getErrorMessage = (error: Error) => { + switch (error.name) { + case "UnauthorizedError": + return "Please sign in to continue."; + case "ValidationError": + return error.message || "Invalid input provided."; + case "ThemeNotFoundError": + return "Theme not found."; + case "ThemeLimitError": + return error.message || "Theme limit reached."; + default: + return "An unexpected error occurred. Please try again."; + } + }; + + toast({ + title: `Failed to ${operation} theme`, + description: getErrorMessage(error), + variant: "destructive", + }); + + return error; +} + +export function useCreateTheme() { + const queryClient = useQueryClient(); + const { registerPreset } = useThemePresetStore(); + + return useMutation({ + mutationFn: (data: { name: string; styles: ThemeStyles }) => createTheme(data), + onSuccess: (data) => { + queryClient.setQueryData(themeKeys.lists(), (old: Theme[] | undefined) => { + return old ? [...old, data] : [data]; + }); + + registerPreset(data.id, { + label: data.name, + source: "SAVED", + createdAt: data.createdAt.toISOString(), + styles: data.styles, + }); + + toast({ + title: "Theme created", + description: `"${data.name}" has been created successfully.`, + }); + }, + onError: (error) => { + handleMutationError(error as Error, "create"); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: themeKeys.lists() }); + }, + }); +} + +export function useUpdateTheme() { + const queryClient = useQueryClient(); + const { updatePreset } = useThemePresetStore(); + + return useMutation({ + mutationFn: (data: { id: string; name?: string; styles?: ThemeStyles }) => updateTheme(data), + onMutate: async (updatedTheme) => { + await queryClient.cancelQueries({ queryKey: themeKeys.lists() }); + await queryClient.cancelQueries({ queryKey: themeKeys.detail(updatedTheme.id) }); + + const previousThemes = queryClient.getQueryData(themeKeys.lists()); + const previousTheme = queryClient.getQueryData(themeKeys.detail(updatedTheme.id)); + + queryClient.setQueryData(themeKeys.lists(), (old: Theme[] | undefined) => { + if (!old) return []; + return old.map((theme) => + theme.id === updatedTheme.id + ? { + ...theme, + ...(updatedTheme.name && { name: updatedTheme.name }), + ...(updatedTheme.styles && { styles: updatedTheme.styles }), + updatedAt: new Date(), + } + : theme + ); + }); + + return { previousThemes, previousTheme }; + }, + onSuccess: (data) => { + queryClient.setQueryData(themeKeys.detail(data.id), data); + queryClient.setQueryData(themeKeys.lists(), (old: Theme[] | undefined) => { + if (!old) return [data]; + return old.map((theme) => (theme.id === data.id ? data : theme)); + }); + + updatePreset(data.id, { + label: data.name, + source: "SAVED", + createdAt: data.createdAt.toISOString(), + styles: data.styles, + }); + + toast({ + title: "Theme updated", + description: `"${data.name}" has been updated successfully.`, + }); + }, + onError: (error, variables, context) => { + if (context?.previousThemes) { + queryClient.setQueryData(themeKeys.lists(), context.previousThemes); + } + if (context?.previousTheme) { + queryClient.setQueryData(themeKeys.detail(variables.id), context.previousTheme); + } + handleMutationError(error as Error, "update"); + }, + onSettled: (data, error, variables) => { + queryClient.invalidateQueries({ queryKey: themeKeys.lists() }); + queryClient.invalidateQueries({ queryKey: themeKeys.detail(variables.id) }); + }, + }); +} + +export function useDeleteTheme() { + const queryClient = useQueryClient(); + const { unregisterPreset } = useThemePresetStore(); + + return useMutation({ + mutationFn: (themeId: string) => deleteTheme(themeId), + onMutate: async (themeId) => { + await queryClient.cancelQueries({ queryKey: themeKeys.lists() }); + const previousThemes = queryClient.getQueryData(themeKeys.lists()); + + queryClient.setQueryData(themeKeys.lists(), (old: Theme[] | undefined) => { + return old ? old.filter((theme) => theme.id !== themeId) : []; + }); + + return { previousThemes, themeId }; + }, + onSuccess: (data, themeId) => { + unregisterPreset(themeId); + queryClient.removeQueries({ queryKey: themeKeys.detail(themeId) }); + + toast({ + title: "Theme deleted", + description: `"${data.name}" has been deleted successfully.`, + }); + }, + onError: (error, _themeId, context) => { + if (context?.previousThemes) { + queryClient.setQueryData(themeKeys.lists(), context.previousThemes); + } + handleMutationError(error as Error, "delete"); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: themeKeys.lists() }); + }, + }); +} diff --git a/hooks/themes/use-themes-data.ts b/hooks/themes/use-themes-data.ts new file mode 100644 index 00000000..a500d54a --- /dev/null +++ b/hooks/themes/use-themes-data.ts @@ -0,0 +1,44 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { getThemes, getTheme } from "@/actions/themes"; +import { Theme } from "@/types/theme"; + +export const themeKeys = { + all: ["themes"] as const, + lists: () => [...themeKeys.all, "list"] as const, + list: (filters: Record) => [...themeKeys.lists(), { filters }] as const, + details: () => [...themeKeys.all, "detail"] as const, + detail: (id: string) => [...themeKeys.details(), { id }] as const, +}; + +export function useThemesData(initialData?: Theme[]) { + return useQuery({ + queryKey: themeKeys.lists(), + queryFn: getThemes, + initialData, + staleTime: 1000 * 60 * 5, // 5 minutes + }); +} + +export function useThemeData(themeId: string | null, initialData?: Theme) { + return useQuery({ + queryKey: themeKeys.detail(themeId!), + queryFn: () => getTheme(themeId!), + enabled: !!themeId, + initialData, + staleTime: 1000 * 60 * 10, // 10 minutes for individual themes + }); +} + +export function usePrefetchThemes() { + const queryClient = useQueryClient(); + + return (themeIds: string[]) => { + themeIds.forEach((id) => { + queryClient.prefetchQuery({ + queryKey: themeKeys.detail(id), + queryFn: () => getTheme(id), + staleTime: 1000 * 60 * 10, + }); + }); + }; +} diff --git a/hooks/use-dialog-actions.tsx b/hooks/use-dialog-actions.tsx index e2341c21..11c702a8 100644 --- a/hooks/use-dialog-actions.tsx +++ b/hooks/use-dialog-actions.tsx @@ -8,7 +8,7 @@ import { ThemeSaveDialog } from "@/components/editor/theme-save-dialog"; import { toast } from "@/components/ui/use-toast"; import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation"; import { usePostLoginAction } from "@/hooks/use-post-login-action"; -import { useThemeActions } from "@/hooks/use-theme-actions"; +import { useCreateTheme } from "@/hooks/themes"; import { authClient } from "@/lib/auth-client"; import { useAuthStore } from "@/store/auth-store"; import { useEditorStore } from "@/store/editor-store"; @@ -53,7 +53,7 @@ function useDialogActionsStore(): DialogActionsContextType { const { getPreset } = useThemePresetStore(); const { data: session } = authClient.useSession(); const { openAuthDialog } = useAuthStore(); - const { createTheme, isCreatingTheme } = useThemeActions(); + const createThemeMutation = useCreateTheme(); const { loading: aiGenerateLoading } = useAIThemeGeneration(); const posthog = usePostHog(); @@ -104,7 +104,7 @@ function useDialogActionsStore(): DialogActionsContextType { }; try { - const theme = await createTheme(themeData); + const theme = await createThemeMutation.mutateAsync(themeData); posthog.capture("CREATE_THEME", { theme_id: theme?.id, theme_name: theme?.name, @@ -162,7 +162,7 @@ function useDialogActionsStore(): DialogActionsContextType { shareDialogOpen, shareUrl, dialogKey, - isCreatingTheme, + isCreatingTheme: createThemeMutation.isPending, aiGenerateLoading, // Dialog actions diff --git a/hooks/use-theme-actions.ts b/hooks/use-theme-actions.ts deleted file mode 100644 index ae9e5015..00000000 --- a/hooks/use-theme-actions.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { useState, useCallback } from "react"; -import { type ThemeStyles } from "@/types/theme"; -import { toast } from "@/components/ui/use-toast"; -import { - createTheme as createThemeAction, - updateTheme as updateThemeAction, - deleteTheme as deleteThemeAction, -} from "@/actions/themes"; -import { Theme } from "@/types/theme"; -import { tryCatch } from "@/utils/try-catch"; -import { useThemePresetStore } from "@/store/theme-preset-store"; - -type MutationState = { - isLoading: boolean; - error: Error | null; - data: T | null; -}; - -type ThemeMutationResult = { - success: boolean; - theme?: Theme; - error?: string; - details?: any; -}; - -const handleMutationError = ( - error: any, - setError: (error: Error | null) => void, - setIsAuthRequired: (value: boolean) => void -) => { - console.error("Mutation error:", error); - - if (error.message === "Unauthorized") { - setIsAuthRequired(true); - toast({ - title: "Authentication Required", - description: "Please sign in to continue.", - variant: "default", - }); - } else { - setError(error); - toast({ - title: "Operation Failed", - description: error.message || "An unexpected error occurred.", - variant: "destructive", - }); - } -}; - -const handleMutationSuccess = (theme: Theme | undefined, operation: string) => { - if (theme) { - toast({ - title: `Theme ${operation}`, - description: `Theme "${ - theme.name - }" ${operation.toLowerCase()} successfully.`, - }); - } -}; - -// --- Generic Fetcher --- -// A generic fetcher function to handle different methods and bodies -async function fetcher(url: string, options?: RequestInit): Promise { - const response = await fetch(url, options); - if (!response.ok) { - let errorData; - try { - errorData = await response.json(); - } catch (e) { - // If response is not JSON, use status text - errorData = { error: response.statusText || "Request failed" }; - } - // Include status code in the error object for better handling - const error: any = new Error( - errorData?.error || `An error occurred: ${response.status}` - ); - error.status = response.status; - error.info = errorData; // Attach full error info if available - throw error; - } - // Handle cases where the response might be empty (e.g., DELETE 204) - if ( - response.status === 204 || - response.headers.get("content-length") === "0" - ) { - return undefined as unknown as T; - } - return response.json(); -} - -export function useThemeActions() { - const { registerPreset, updatePreset, unregisterPreset } = - useThemePresetStore(); - const [isAuthRequired, setIsAuthRequired] = useState(false); - - const [createState, setCreateState] = useState>({ - isLoading: false, - error: null, - data: null, - }); - - const [updateState, setUpdateState] = useState>({ - isLoading: false, - error: null, - data: null, - }); - - const [deleteState, setDeleteState] = useState>({ - isLoading: false, - error: null, - data: null, - }); - - const executeMutation = async ( - action: () => Promise, - setState: React.Dispatch>>, - successHandler: (result: ThemeMutationResult) => T | null - ) => { - setState((prev) => ({ ...prev, isLoading: true, error: null })); - setIsAuthRequired(false); - - const [error, result] = await tryCatch(action()); - - if (error) { - handleMutationError( - error, - (err) => - setState((prev) => ({ ...prev, error: err, isLoading: false })), - setIsAuthRequired - ); - return null; - } - - if (result.success) { - const data = successHandler(result); - setState((prev) => ({ ...prev, isLoading: false, data })); - return data; - } else { - const error = new Error(result.error || "Operation failed"); - setState((prev) => ({ ...prev, isLoading: false, error })); - toast({ - title: "Operation Failed", - description: result.error || "Could not complete the operation.", - variant: "destructive", - }); - return null; - } - }; - - const createTheme = useCallback( - async (data: { name: string; styles: ThemeStyles }) => { - return executeMutation( - () => createThemeAction(data), - setCreateState, - (result) => { - if (result.theme) { - const theme: Theme = result.theme; - handleMutationSuccess(theme, "Created"); - registerPreset(theme.id, { - label: theme.name, - source: "SAVED", - createdAt: theme.createdAt.toISOString(), - styles: theme.styles, - }); - return theme; - } - return null; - } - ); - }, - [] - ); - - const updateTheme = useCallback( - async (data: { id: string; name?: string; styles?: ThemeStyles }) => { - return executeMutation( - () => updateThemeAction(data), - setUpdateState, - (result) => { - if (result.theme) { - const theme: Theme = result.theme; - handleMutationSuccess(theme, "Updated"); - updatePreset(theme.id, { - label: theme.name, - source: "SAVED", - createdAt: theme.createdAt.toISOString(), - styles: theme.styles, - }); - return theme; - } - return null; - } - ); - }, - [] - ); - - const deleteTheme = useCallback(async (themeId: string) => { - return executeMutation( - () => deleteThemeAction(themeId), - setDeleteState, - (result) => { - if (result.success) { - handleMutationSuccess(result.theme, "Deleted"); - unregisterPreset(themeId); - toast({ - title: "Theme Deleted Successfully", - }); - return true; - } - return false; - } - ); - }, []); - - return { - createTheme, - updateTheme, - deleteTheme, - isCreatingTheme: createState.isLoading, - isUpdatingTheme: updateState.isLoading, - isDeletingTheme: deleteState.isLoading, - createError: createState.error, - updateError: updateState.error, - deleteError: deleteState.error, - isMutating: - createState.isLoading || updateState.isLoading || deleteState.isLoading, - mutationError: createState.error || updateState.error || deleteState.error, - isAuthRequired, - setIsAuthRequired, - }; -} diff --git a/lib/query-client.tsx b/lib/query-client.tsx new file mode 100644 index 00000000..d06b47c1 --- /dev/null +++ b/lib/query-client.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { useState, ReactNode } from "react"; +import posthog from "posthog-js"; + +function logClientError(error: Error, context: Record) { + console.error("Query error:", error, context); + + if (error.name !== "UnauthorizedError" && error.name !== "ValidationError") { + try { + posthog.capture("query_error", { + error: error.message, + errorName: error.name, + stack: error.stack, + ...context, + }); + } catch (posthogError) { + console.error("Failed to log to PostHog:", posthogError); + } + } +} + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime) + retry: (failureCount, error: Error) => { + // Don't retry on authentication or validation errors + if (error?.name === "UnauthorizedError" || error?.name === "ValidationError") { + return false; + } + // Retry up to 3 times for other errors + return failureCount < 3; + }, + refetchOnWindowFocus: false, + }, + mutations: { + retry: (failureCount, error: Error) => { + // Don't retry mutations on client errors (4xx) + if (error?.name === "UnauthorizedError" || error?.name === "ValidationError") { + return false; + } + // Only retry once for server errors (5xx) + return failureCount < 1; + }, + onError: (error: Error, variables: unknown, context: unknown) => { + logClientError(error, { + type: "mutation", + variables: JSON.stringify(variables), + context, + }); + }, + }, + }, + }); +} + +interface QueryProviderProps { + children: ReactNode; +} + +export function QueryProvider({ children }: QueryProviderProps) { + const [queryClient] = useState(() => createQueryClient()); + + return ( + + {children} + {process.env.NODE_ENV === "development" && } + + ); +} diff --git a/package.json b/package.json index adb4d9c1..0355922a 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ "@radix-ui/react-toggle": "^1.1.6", "@radix-ui/react-toggle-group": "^1.1.6", "@radix-ui/react-tooltip": "^1.2.3", + "@tanstack/react-query": "^5.81.2", + "@tanstack/react-query-devtools": "^5.81.2", "@tanstack/react-table": "^8.21.3", "@tiptap/extension-character-count": "^2.12.0", "@tiptap/extension-mention": "^2.11.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 929718f5..563ede1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,12 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.3 version: 1.2.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/react-query': + specifier: ^5.81.2 + version: 5.81.2(react@19.1.0) + '@tanstack/react-query-devtools': + specifier: ^5.81.2 + version: 5.81.2(@tanstack/react-query@5.81.2(react@19.1.0))(react@19.1.0) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -2600,6 +2606,23 @@ packages: '@tailwindcss/postcss@4.1.4': resolution: {integrity: sha512-bjV6sqycCEa+AQSt2Kr7wpGF1bOZJ5wsqnLEkqSbM/JEHxx/yhMH8wHmdkPyApF9xhHeMSwnnkDUUMMM/hYnXw==} + '@tanstack/query-core@5.81.2': + resolution: {integrity: sha512-QLYkPdrudoMATDFa3MiLEwRhNnAlzHWDf0LKaXUqJd0/+QxN8uTPi7bahRlxoAyH0UbLMBdeDbYzWALj7THOtw==} + + '@tanstack/query-devtools@5.81.2': + resolution: {integrity: sha512-jCeJcDCwKfoyyBXjXe9+Lo8aTkavygHHsUHAlxQKKaDeyT0qyQNLKl7+UyqYH2dDF6UN/14873IPBHchcsU+Zg==} + + '@tanstack/react-query-devtools@5.81.2': + resolution: {integrity: sha512-TX0OQ4cbgX6z2uN8c9x0QUNbyePGyUGdcgrGnV6TYEJc7KPT8PqeASuzoA5NGw1CiMGvyFAkIGA2KipvhM9d1g==} + peerDependencies: + '@tanstack/react-query': ^5.81.2 + react: ^18 || ^19 + + '@tanstack/react-query@5.81.2': + resolution: {integrity: sha512-pe8kFlTrL2zFLlcAj2kZk9UaYYHDk9/1hg9EBaoO3cxDhOZf1FRGJeziSXKrVZyxIfs7b3aoOj/bw7Lie0mDUg==} + peerDependencies: + react: ^18 || ^19 + '@tanstack/react-table@8.21.3': resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} engines: {node: '>=12'} @@ -8409,6 +8432,21 @@ snapshots: postcss: 8.5.3 tailwindcss: 4.1.4 + '@tanstack/query-core@5.81.2': {} + + '@tanstack/query-devtools@5.81.2': {} + + '@tanstack/react-query-devtools@5.81.2(@tanstack/react-query@5.81.2(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/query-devtools': 5.81.2 + '@tanstack/react-query': 5.81.2(react@19.1.0) + react: 19.1.0 + + '@tanstack/react-query@5.81.2(react@19.1.0)': + dependencies: + '@tanstack/query-core': 5.81.2 + react: 19.1.0 + '@tanstack/react-table@8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@tanstack/table-core': 8.21.3 diff --git a/types/errors.ts b/types/errors.ts new file mode 100644 index 00000000..7c3b9b42 --- /dev/null +++ b/types/errors.ts @@ -0,0 +1,30 @@ +export class UnauthorizedError extends Error { + constructor(message = "Unauthorized") { + super(message); + this.name = "UnauthorizedError"; + } +} + +export class ValidationError extends Error { + constructor( + message: string, + public details?: any + ) { + super(message); + this.name = "ValidationError"; + } +} + +export class ThemeNotFoundError extends Error { + constructor(message = "Theme not found") { + super(message); + this.name = "ThemeNotFoundError"; + } +} + +export class ThemeLimitError extends Error { + constructor(message = "Theme limit reached") { + super(message); + this.name = "ThemeLimitError"; + } +}