From cb371ba11878b67e912b16d4fd51213c5fa3809a Mon Sep 17 00:00:00 2001 From: Kaylahray Date: Sat, 29 Aug 2026 12:18:18 +0100 Subject: [PATCH] feat(frontend): wire Next.js app to backend auth, puzzles, and dashboard --- frontend/app/auth/signin/page.tsx | 154 ++++++----------- frontend/app/auth/signup/page.tsx | 163 +++--------------- frontend/app/dashboard/page.tsx | 5 + frontend/app/page.tsx | 17 +- frontend/app/puzzle-list/page.tsx | 13 +- frontend/app/puzzles/[id]/page.tsx | 3 +- frontend/app/puzzles/[id]/solve/page.tsx | 16 +- frontend/components/ClientLayout.tsx | 40 +++-- frontend/components/SideNav.tsx | 4 +- frontend/components/puzzles/PuzzleCard.tsx | 9 +- .../components/puzzles/PuzzleInfoCard.tsx | 20 ++- frontend/features/dashboard/dashboard.api.ts | 73 +++----- frontend/hooks/useAuth.ts | 12 +- frontend/hooks/useGoogleAuth.ts | 100 +++++++++++ frontend/hooks/useGuestSession.ts | 87 +++++----- frontend/lib/api/authApi.ts | 82 +++++++++ frontend/lib/api/client.ts | 14 +- frontend/lib/api/config.ts | 42 +++++ frontend/lib/api/error-handler.ts | 40 ++++- frontend/lib/api/interceptors.ts | 37 ++-- frontend/lib/api/puzzleApi.ts | 42 +++-- frontend/lib/features/auth/authSlice.ts | 64 +++---- frontend/lib/types/puzzles.ts | 45 +++-- frontend/package.json | 3 +- 24 files changed, 609 insertions(+), 476 deletions(-) create mode 100644 frontend/hooks/useGoogleAuth.ts create mode 100644 frontend/lib/api/authApi.ts create mode 100644 frontend/lib/api/config.ts diff --git a/frontend/app/auth/signin/page.tsx b/frontend/app/auth/signin/page.tsx index d8fd1443..62551bc1 100644 --- a/frontend/app/auth/signin/page.tsx +++ b/frontend/app/auth/signin/page.tsx @@ -11,6 +11,9 @@ import ErrorBoundary from '@/components/error/ErrorBoundary'; import { useToast } from '@/components/ui/ToastProvider'; import { useStellarWalletAuth } from '@/hooks/useStellarWalletAuth'; import { useAuth } from '@/hooks/useAuth'; +import { useGoogleAuth } from '@/hooks/useGoogleAuth'; +import { useGuestSession } from '@/hooks/useGuestSession'; +import { signIn } from '@/lib/api/authApi'; import { WalletType } from '@/lib/stellar/types'; import WalletModal, { WalletType as ModalWalletType } from '@/components/ui/WalletModal'; import { useWalletModal } from '@/hooks/useWalletModal'; @@ -26,6 +29,8 @@ const SignInPage = () => { clearError, } = useStellarWalletAuth(); const { loginSuccess, loginFailure, setLoading } = useAuth(); + const { signInWithGoogle } = useGoogleAuth(); + const { startGuestSession, isStarting: isGuestStarting } = useGuestSession(); const { isOpen: isWalletModalOpen, openModal: openWalletModal, closeModal: closeWalletModal } = useWalletModal(); const [formData, setFormData] = useState({ username: '', @@ -78,123 +83,59 @@ const SignInPage = () => { } try { - const response = await fetch('http://localhost:3000/auth/signIn', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email: formData.username, - password: formData.password - }), - }); - - // Checking if response is ok before trying to parse JSON - if (!response.ok) { - try { - const errorData = await response.json(); - // Handle specific error messages from server - if (response.status === 401) { - const errorMsg = 'Invalid email or password.'; - showError('Login Failed', errorMsg); - loginFailure(errorMsg); - setIsLoading(false); - setLoading(false); - } else if (response.status === 404) { - const errorMsg = 'Account not found.'; - showError('Account Not Found', errorMsg); - loginFailure(errorMsg); - setIsLoading(false); - setLoading(false); - } else if (response.status === 400) { - const errorMsg = errorData.message || 'Invalid input.'; - showError('Invalid Input', errorMsg); - loginFailure(errorMsg); - setIsLoading(false); - setLoading(false); - } else if (response.status >= 500) { - const errorMsg = 'Server error. Please try again later.'; - showError('Server Error', errorMsg); - loginFailure(errorMsg); - setIsLoading(false); - setLoading(false); - } else { - const errorMsg = errorData.message || 'Login failed. Please try again.'; - showError('Login Failed', errorMsg); - loginFailure(errorMsg); - setIsLoading(false); - setLoading(false); - } - } catch { - // If response isn't JSON, use status text or default message - if (response.status === 401) { - const errorMsg = 'Invalid email or password.'; - showError('Login Failed', errorMsg); - loginFailure(errorMsg); - setIsLoading(false); - setLoading(false); - } else if (response.status === 404) { - const errorMsg = 'Account not found.'; - showError('Account Not Found', errorMsg); - loginFailure(errorMsg); - setIsLoading(false); - setLoading(false); - } else { - const errorMsg = `Login failed: ${response.statusText || 'Please try again.'}`; - showError('Login Failed', errorMsg); - loginFailure(errorMsg); - setIsLoading(false); - setLoading(false); - } - } - setIsLoading(false); - setLoading(false); - return; - } - - // Parse JSON only if response is ok - const data = await response.json(); + const data = await signIn(formData.username, formData.password); if (data.accessToken && data.refreshToken) { - // Update Redux state with both tokens - const user = { - id: data.user?.id || formData.username, + const user = data.user ?? { + id: formData.username, email: formData.username, - username: data.user?.username || formData.username.split('@')[0], + username: formData.username.split('@')[0], }; - + loginSuccess(user, data.accessToken, data.refreshToken); - - // Show success toast showSuccess('Login Successful', 'Welcome back!'); - setIsLoading(false); - setLoading(false); - - // Redirect to dashboard router.push('/dashboard'); - } else { - const errorMsg = 'Invalid response from server. Please try again.'; - showError('Invalid Response', errorMsg); - loginFailure(errorMsg); - setIsLoading(false); - setLoading(false); + return; } - } catch (error) { - console.error('Sign in error:', error); - const errorMsg = 'Network error.'; - showError('Network Error', errorMsg); + + const errorMsg = 'Invalid response from server. Please try again.'; + showError('Invalid Response', errorMsg); loginFailure(errorMsg); - setIsLoading(false); - setLoading(false); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Login failed. Please try again.'; + const isNetwork = message === 'Unexpected error occurred' || /network/i.test(message); + showError(isNetwork ? 'Network Error' : 'Login Failed', message); + loginFailure(message); } finally { setIsLoading(false); setLoading(false); } }; - const handleGoogleSignIn = () => { - showInfo('Google Sign-In', 'Redirecting to Google authentication...'); - window.location.href = "http://localhost:3000/auth/google-authentication"; + const handleGoogleSignIn = async () => { + try { + showInfo('Google Sign-In', 'Opening Google authentication...'); + await signInWithGoogle(); + showSuccess('Login Successful', 'Welcome back!'); + router.push('/dashboard'); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Google sign-in failed'; + showError('Google Sign-In Failed', message); + } + }; + + const handleGuestPlay = async () => { + try { + await startGuestSession(); + showSuccess('Guest session started', 'You have 15 minutes to browse puzzles.'); + router.push('/puzzles'); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Could not start a guest session'; + showError('Guest Play Failed', message); + } }; const handleWalletSelect = async (walletType: ModalWalletType) => { @@ -339,6 +280,15 @@ const SignInPage = () => { {isLoggingIn && 'Verifying...'} {!isConnecting && !isSigning && !isLoggingIn && 'Connect Wallet'} + + {/* Terms and Privacy */} diff --git a/frontend/app/auth/signup/page.tsx b/frontend/app/auth/signup/page.tsx index 0047b0c3..3775785f 100644 --- a/frontend/app/auth/signup/page.tsx +++ b/frontend/app/auth/signup/page.tsx @@ -10,6 +10,8 @@ import Image from "next/image"; import ErrorBoundary from "@/components/error/ErrorBoundary"; import { useToast } from "@/components/ui/ToastProvider"; import { useStellarWalletAuth } from "@/hooks/useStellarWalletAuth"; +import { useGoogleAuth } from "@/hooks/useGoogleAuth"; +import { register } from "@/lib/api/authApi"; import { WalletType } from "@/lib/stellar/types"; @@ -23,6 +25,7 @@ const SignUpPage = () => { connectAndLogin, clearError, } = useStellarWalletAuth(); + const { signInWithGoogle } = useGoogleAuth(); const [formData, setFormData] = useState({ username: "", fullName: "", @@ -115,156 +118,36 @@ const SignUpPage = () => { } try { - // Format request body to match server expectations - const requestBody = { + await register({ email: formData.email, username: formData.username, - fullname: formData.fullName, // Server expects lowercase 'n' + fullname: formData.fullName, password: formData.password, - userRole: "user", // Default role - provider: "local", // Local registration - }; - - console.log("Sending request with data:", requestBody); // Debug log - - const response = await fetch( - "http://localhost:3000/users", - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }, - ); + passwordConfirm: formData.password, + }); - // Log response details for debugging - console.log("Response status:", response.status); - console.log("Response headers:", response.headers); - - // Checking if response is ok before trying to parse JSON - if (!response.ok) { - let errorMessage = "Registration failed. Please try again."; - - try { - const errorData = await response.json(); - console.log("Error response data:", errorData); // Debug log - - // Safely extract error message - if (typeof errorData === "object" && errorData !== null) { - if (typeof errorData.message === "string") { - errorMessage = errorData.message; - } else if (typeof errorData.error === "string") { - errorMessage = errorData.error; - } else if ( - Array.isArray(errorData.errors) && - errorData.errors.length > 0 - ) { - errorMessage = errorData.errors[0]; - } - } - - // Handle specific error cases - if (response.status === 409) { - showError( - "Account Already Exists", - errorMessage || - "User already exists with this email or username.", - ); - } else if (response.status === 400) { - showError( - "Invalid Input", - errorMessage || - "Invalid input data. Please check your information.", - ); - } else if (response.status >= 500) { - showError("Server Error", "Server error. Please try again later."); - } else { - showError("Registration Failed", errorMessage); - } - } catch (parseError) { - console.error("Error parsing response:", parseError); - // If response isn't JSON, use status-based messages - if (response.status === 409) { - showError( - "Account Already Exists", - "An account with this email or username already exists.", - ); - } else if (response.status === 400) { - showError( - "Invalid Input", - "Please check your input and try again.", - ); - } else { - showError( - "Registration Failed", - `Error ${response.status}: ${response.statusText || "Please try again."}`, - ); - } - } - setIsLoading(false); - return; - } - - // Parse JSON only if response is ok - const data = await response.json(); - console.log("Success response data:", data); // Debug log - - if ( - data.accessToken || - data.id || - data.email || - data.message === "User created successfully" || - data.success - ) { - // If we get a token, store it - if (data.accessToken) { - try { - localStorage.setItem("accessToken", data.accessToken); - } catch (storageError) { - console.warn("Could not save token to localStorage:", storageError); - } - } - - // Show success toast - showSuccess("Registration Successful", "Welcome to Mind Block!"); - - // Redirect to signin page or dashboard based on whether we got a token - setTimeout(() => { - if (data.accessToken) { - router.push("/dashboard"); - } else { - router.push("/auth/signin"); - } - }, 1000); // Small delay to show success message - } else { - showError( - "Invalid Response", - "Invalid response from server. Please try again.", - ); - } + showSuccess("Account created", "You can now sign in."); + router.push("/auth/signin"); } catch (error) { - console.error("Sign up error:", error); - if (error instanceof TypeError && error.message.includes("fetch")) { - showError( - "Network Error", - "Could not connect to the server. Please check your internet connection and try again.", - ); - } else { - showError( - "Network Error", - "An unexpected error occurred. Please try again.", - ); - } + const message = + error instanceof Error ? error.message : "Registration failed. Please try again."; + showError("Registration Failed", message); } finally { setIsLoading(false); } }; - const handleGoogleSignUp = () => { - showInfo("Google Sign-Up", "Redirecting to Google authentication..."); - window.location.href = - "http://localhost:3000/auth/google-authentication"; + const handleGoogleSignUp = async () => { + try { + showInfo("Google Sign-Up", "Opening Google authentication..."); + await signInWithGoogle(); + showSuccess("Login Successful", "Welcome to Mind Block!"); + router.push("/dashboard"); + } catch (error) { + const message = + error instanceof Error ? error.message : "Google sign-up failed"; + showError("Google Sign-Up Failed", message); + } }; const handleWalletConnect = async () => { diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index c2c68e2c..4c3ef18b 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/navigation"; import DailyQuestCard from "@/components/dashboard/DailyQuestCard"; import CategoryCard from "@/components/dashboard/CategoryCard"; +import OnboardingFunnelChart from "@/src/components/analytics/OnboardingFunnelChart"; import Image from "next/image"; import { Flame, Gem, User } from "lucide-react"; import { useDashboard } from "@/features/dashboard"; @@ -111,6 +112,10 @@ const Dashboard = () => { ))} + +
+ +
); diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 74076e0f..247ba19c 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -36,19 +36,16 @@ const MindBlockLanding = () => {
-
- Have an account? - -
+
diff --git a/frontend/app/puzzle-list/page.tsx b/frontend/app/puzzle-list/page.tsx index 91bf3d10..d76176b2 100644 --- a/frontend/app/puzzle-list/page.tsx +++ b/frontend/app/puzzle-list/page.tsx @@ -4,7 +4,12 @@ import React, { useCallback, useEffect, useState, useMemo } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import FilterBar from '@/components/puzzles/FilterBar'; import { usePuzzles } from '@/hooks/usePuzzles'; -import type { PuzzleDifficulty, PuzzleFilters } from '@/lib/types/puzzles'; +import { + puzzleDescription, + puzzleTitle, + type PuzzleDifficulty, + type PuzzleFilters, +} from '@/lib/types/puzzles'; import { Puzzle as PuzzleIcon, Clock, Zap } from 'lucide-react'; import Pagination from '@/components/ui/Pagination'; import { EmptyState, ErrorState, LoadingState } from '@/components/ui/StateDisplay'; @@ -268,10 +273,10 @@ function PuzzleListContent() { ))} diff --git a/frontend/app/puzzles/[id]/page.tsx b/frontend/app/puzzles/[id]/page.tsx index d14c5888..cbb761c3 100644 --- a/frontend/app/puzzles/[id]/page.tsx +++ b/frontend/app/puzzles/[id]/page.tsx @@ -3,6 +3,7 @@ import React, { useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { usePuzzle } from "@/hooks/usePuzzles"; +import { puzzleTitle } from "@/lib/types/puzzles"; import HintsSection from "@/components/puzzles/HintsSection"; import PuzzleHeader from "@/components/puzzles/PuzzleHeader"; import PuzzleInfoCard from "@/components/puzzles/PuzzleInfoCard"; @@ -27,7 +28,7 @@ export default function PuzzleDetailPage() { return (
- +
diff --git a/frontend/app/puzzles/[id]/solve/page.tsx b/frontend/app/puzzles/[id]/solve/page.tsx index 3bbb8ce1..b653fe5e 100644 --- a/frontend/app/puzzles/[id]/solve/page.tsx +++ b/frontend/app/puzzles/[id]/solve/page.tsx @@ -15,6 +15,8 @@ import { PuzzleSummary, SubmitAttemptResult, } from "@/lib/types/challengeAttempt"; +import { useStreak } from "@/hooks/useStreak"; +import { useAuth } from "@/hooks/useAuth"; const STORAGE_KEY = "activeGameSession"; @@ -86,6 +88,8 @@ export default function SolvePuzzlePage() { const [selected, setSelected] = useState(null); const [result, setResult] = useState(null); const [startedAtMs, setStartedAtMs] = useState(Date.now()); + const { fetchStreak } = useStreak({ autoFetch: false }); + const { isAuthenticated } = useAuth(); useEffect(() => { let cancelled = false; @@ -194,6 +198,9 @@ export default function SolvePuzzlePage() { setResult(res); setAttempt(res.attempt); setPhase("result"); + if (res.isCorrect) { + void fetchStreak(); + } } catch (err) { setErrorMessage( err instanceof ChallengeAttemptApiError @@ -202,7 +209,7 @@ export default function SolvePuzzlePage() { ); setPhase("error"); } - }, [attempt, selected, startedAtMs]); + }, [attempt, selected, startedAtMs, fetchStreak]); const handleContinue = useCallback(() => { if (!result?.nextChallenge) return; @@ -265,6 +272,13 @@ export default function SolvePuzzlePage() { {puzzle.question} + {!isAuthenticated && ( +

+ Sign in to submit an answer and earn XP. Guest sessions can browse + puzzles only. +

+ )} +
{puzzle.options.map((optionText, index) => { const isSelected = selected === optionText; diff --git a/frontend/components/ClientLayout.tsx b/frontend/components/ClientLayout.tsx index 80f33071..2816630a 100644 --- a/frontend/components/ClientLayout.tsx +++ b/frontend/components/ClientLayout.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from "react"; +import { usePathname } from "next/navigation"; import SideNav from "@/components/SideNav"; import { Menu } from "lucide-react"; import ErrorBoundary from "@/components/error/ErrorBoundary"; @@ -12,22 +13,21 @@ export default function ClientLayout({ children: React.ReactNode; }) { const [sidebarOpen, setSidebarOpen] = useState(false); + const pathname = usePathname(); + const isChromeHidden = pathname === "/" || pathname.startsWith("/auth"); const { restoreSession } = useAuth(); - // Restore session on app initialization useEffect(() => { restoreSession(); }, [restoreSession]); - // Set up automatic token refresh - const { refreshToken: refreshTokenAction, isAuthenticated, token } = useAuth(); - + const { refreshToken: refreshTokenAction, isAuthenticated, token } = + useAuth(); + useEffect(() => { if (!isAuthenticated || !token) return; - // Refresh token 5 minutes before it expires (assuming 1 hour expiration) - const REFRESH_INTERVAL = 55 * 60 * 1000; // 55 minutes - + const REFRESH_INTERVAL = 55 * 60 * 1000; const intervalId = setInterval(() => { refreshTokenAction(); }, REFRESH_INTERVAL); @@ -37,21 +37,23 @@ export default function ClientLayout({ return (
- setSidebarOpen(false)} /> - + > + + + + )}
- - {children} - + {children}
); -} \ No newline at end of file +} diff --git a/frontend/components/SideNav.tsx b/frontend/components/SideNav.tsx index e24c8a1e..02d3817d 100644 --- a/frontend/components/SideNav.tsx +++ b/frontend/components/SideNav.tsx @@ -2,11 +2,13 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Home, Trophy, User, Bell, LogOut } from "lucide-react"; +import { Home, Trophy, User, Bell, LogOut, Puzzle, Flame } from "lucide-react"; import { useAuth } from "@/hooks/useAuth"; const navItems = [ { label: "Home", href: "/dashboard", icon: Home }, + { label: "Puzzles", href: "/puzzles", icon: Puzzle }, + { label: "Streak", href: "/streak", icon: Flame }, { label: "Leaderboard", href: "/leaderboard", icon: Trophy }, { label: "Profile", href: "/profile", icon: User }, { label: "Feeds", href: "/feeds", icon: Bell }, diff --git a/frontend/components/puzzles/PuzzleCard.tsx b/frontend/components/puzzles/PuzzleCard.tsx index cad653e7..f342523f 100644 --- a/frontend/components/puzzles/PuzzleCard.tsx +++ b/frontend/components/puzzles/PuzzleCard.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { useRouter } from 'next/navigation'; import type { Puzzle } from '@/lib/types/puzzles'; +import { puzzleTitle, puzzleDescription } from '@/lib/types/puzzles'; interface PuzzleCardProps { puzzle: Puzzle; @@ -37,14 +38,14 @@ const DIFFICULTY_CONFIG = { const PuzzleCard: React.FC = ({ puzzle }) => { const router = useRouter(); - const typeConfig = TYPE_CONFIG[puzzle.type] ?? TYPE_CONFIG.logic; + const typeConfig = TYPE_CONFIG[puzzle.type ?? "logic"]; const diffConfig = DIFFICULTY_CONFIG[puzzle.difficulty] ?? DIFFICULTY_CONFIG.BEGINNER; return (