diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 4edaf55..5f11f56 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -7,6 +7,7 @@ import CompletionFeatureProvider from "@/providers/CompletionFeatureProvider"; import DashboardFeatureProvider from "@/providers/DashboardFeatureProvider"; import ErrorBoundary from "@/components/error/ErrorBoundary"; import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider"; +import QueryProvider from "@/providers/QueryProvider"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -31,6 +32,7 @@ export default function RootLayout({ + @@ -40,6 +42,7 @@ export default function RootLayout({ + diff --git a/frontend/app/puzzles/page.tsx b/frontend/app/puzzles/page.tsx index 983ebb8..b76a983 100644 --- a/frontend/app/puzzles/page.tsx +++ b/frontend/app/puzzles/page.tsx @@ -1,9 +1,114 @@ -import React from 'react' +'use client'; + +import React, { useState } from 'react'; +import { usePuzzles } from '@/hooks/usePuzzles'; +import PuzzleCard from '@/components/puzzles/PuzzleCard'; +import FilterBar from '@/components/puzzles/FilterBar'; +import type { PuzzleFilters } from '@/lib/types/puzzles'; + +const DEFAULT_FILTERS: PuzzleFilters = { + categoryId: '', + difficulty: 'ALL', +}; + +// Skeleton card to match PuzzleCard proportions +const PuzzleCardSkeleton: React.FC = () => ( +
+ {/* Badge row */} +
+
+
+
+ {/* Title */} +
+ {/* Description lines */} +
+
+
+
+ {/* Footer */} +
+
+
+
+
+); + +const PuzzleListPage: React.FC = () => { + const [filters, setFilters] = useState(DEFAULT_FILTERS); + + const queryParams = { + ...(filters.categoryId ? { categoryId: filters.categoryId } : {}), + ...(filters.difficulty !== 'ALL' ? { difficulty: filters.difficulty } : {}), + }; + + const { data: puzzles, isLoading, isError, refetch } = usePuzzles(queryParams); -const page = () => { return ( -
page
- ) -} +
+
+ + {/* Page header */} +
+

Puzzles

+

+ Challenge yourself with logic, coding, and blockchain puzzles. +

+
+ + {/* Filter bar */} +
+ +
+ + {/* Error state */} + {isError && ( +
+

Failed to load puzzles.

+ +
+ )} + + {/* Skeleton grid while loading */} + {isLoading && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ )} + + {/* Puzzle grid */} + {!isLoading && !isError && ( + <> + {puzzles && puzzles.length > 0 ? ( + <> +

+ {puzzles.length} puzzle{puzzles.length !== 1 ? 's' : ''} found +

+
+ {puzzles.map((puzzle) => ( + + ))} +
+ + ) : ( +
+ +

No puzzles found

+

Try adjusting your filters.

+
+ )} + + )} +
+
+ ); +}; -export default page \ No newline at end of file +export default PuzzleListPage; diff --git a/frontend/components/puzzles/PuzzleCard.tsx b/frontend/components/puzzles/PuzzleCard.tsx new file mode 100644 index 0000000..cad653e --- /dev/null +++ b/frontend/components/puzzles/PuzzleCard.tsx @@ -0,0 +1,97 @@ +'use client'; + +import React from 'react'; +import { useRouter } from 'next/navigation'; +import type { Puzzle } from '@/lib/types/puzzles'; + +interface PuzzleCardProps { + puzzle: Puzzle; +} + +const TYPE_CONFIG = { + logic: { + label: 'Logic', + icon: '🧠', + className: 'bg-purple-500/15 text-purple-300 border-purple-500/25', + }, + coding: { + label: 'Coding', + icon: '💻', + className: 'bg-blue-500/15 text-blue-300 border-blue-500/25', + }, + blockchain: { + label: 'Blockchain', + icon: '⛓️', + className: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/25', + }, +} as const; + +const DIFFICULTY_CONFIG = { + ALL: { label: 'All', className: 'bg-slate-500/15 text-slate-300 border-slate-500/25', dot: 'bg-slate-400' }, + BEGINNER: { label: 'Beginner', className: 'bg-green-500/15 text-green-300 border-green-500/25', dot: 'bg-green-400' }, + INTERMEDIATE: { label: 'Intermediate', className: 'bg-yellow-500/15 text-yellow-300 border-yellow-500/25', dot: 'bg-yellow-400' }, + ADVANCED: { label: 'Advanced', className: 'bg-orange-500/15 text-orange-300 border-orange-500/25', dot: 'bg-orange-400' }, + EXPERT: { label: 'Expert', className: 'bg-red-500/15 text-red-300 border-red-500/25', dot: 'bg-red-500' }, +} as const; + +const PuzzleCard: React.FC = ({ puzzle }) => { + const router = useRouter(); + + const typeConfig = TYPE_CONFIG[puzzle.type] ?? TYPE_CONFIG.logic; + const diffConfig = DIFFICULTY_CONFIG[puzzle.difficulty] ?? DIFFICULTY_CONFIG.BEGINNER; + + return ( + + ); +}; + +export default PuzzleCard; diff --git a/frontend/hooks/useReducedMotionCheck.ts b/frontend/hooks/useReducedMotionCheck.ts index 1e49bce..95f27e3 100644 --- a/frontend/hooks/useReducedMotionCheck.ts +++ b/frontend/hooks/useReducedMotionCheck.ts @@ -1,4 +1,4 @@ -import { usePrefersReducedMotion } from 'framer-motion'; +import { usePrefersReducedMotion } from '@/lib/animations/hooks'; const useReducedMotionCheck = () => { const prefersReducedMotion = usePrefersReducedMotion(); diff --git a/frontend/lib/animations/hooks.ts b/frontend/lib/animations/hooks.ts index 4284fc7..4006f32 100644 --- a/frontend/lib/animations/hooks.ts +++ b/frontend/lib/animations/hooks.ts @@ -5,7 +5,7 @@ import { useReducedMotion } from 'framer-motion'; * Hook to determine if animations should be disabled based on user preferences. */ export const usePrefersReducedMotion = (): boolean => { - return useReducedMotion(); + return useReducedMotion() ?? false; }; /** diff --git a/frontend/providers/QueryProvider.tsx b/frontend/providers/QueryProvider.tsx new file mode 100644 index 0000000..e6f7ee0 --- /dev/null +++ b/frontend/providers/QueryProvider.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { useState } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +export default function QueryProvider({ children }: { children: React.ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + }, + }, + }) + ); + + return {children}; +}