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 (
router.push(`/puzzles/${puzzle.id}`)}
className="w-full text-left bg-[#0A1628] border border-gray-800 rounded-xl p-5 flex flex-col gap-4 hover:border-gray-600 hover:bg-[#0d1e38] transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500/50 group"
- aria-label={`Open puzzle: ${puzzle.title}`}
+ aria-label={`Open puzzle: ${puzzleTitle(puzzle)}`}
>
{/* Badges row */}
@@ -67,12 +68,12 @@ const PuzzleCard: React.FC
= ({ puzzle }) => {
{/* Title */}
- {puzzle.title}
+ {puzzleTitle(puzzle)}
{/* Description */}
- {puzzle.description}
+ {puzzleDescription(puzzle)}
{/* Footer: category + time limit */}
diff --git a/frontend/components/puzzles/PuzzleInfoCard.tsx b/frontend/components/puzzles/PuzzleInfoCard.tsx
index 9d15086f..9d1f3605 100644
--- a/frontend/components/puzzles/PuzzleInfoCard.tsx
+++ b/frontend/components/puzzles/PuzzleInfoCard.tsx
@@ -1,13 +1,18 @@
"use client";
import React from "react";
-import { Puzzle } from "@/lib/types/puzzles";
+import { Puzzle, puzzleDescription } from "@/lib/types/puzzles";
interface PuzzleInfoCardProps {
puzzle: Puzzle;
}
const difficultyConfig = {
+ BEGINNER: { color: "bg-emerald-500/20 text-emerald-400", stars: "★☆☆" },
+ INTERMEDIATE: { color: "bg-yellow-500/20 text-yellow-400", stars: "★★☆" },
+ ADVANCED: { color: "bg-orange-500/20 text-orange-400", stars: "★★★" },
+ EXPERT: { color: "bg-rose-500/20 text-rose-400", stars: "★★★" },
+ ALL: { color: "bg-slate-500/20 text-slate-400", stars: "☆☆☆" },
easy: { color: "bg-emerald-500/20 text-emerald-400", stars: "★☆☆" },
medium: { color: "bg-yellow-500/20 text-yellow-400", stars: "★★☆" },
hard: { color: "bg-rose-500/20 text-rose-400", stars: "★★★" },
@@ -20,12 +25,11 @@ const typeConfig = {
};
export default function PuzzleInfoCard({ puzzle }: PuzzleInfoCardProps) {
- const difficulty = puzzle.difficulty as keyof typeof difficultyConfig;
- const type = puzzle.type as keyof typeof typeConfig;
- const diffConfig = difficultyConfig[difficulty];
- const typeConfig_ = typeConfig[type];
- const pointsReward =
- difficulty === "easy" ? 10 : difficulty === "medium" ? 25 : 50;
+ const difficulty = (puzzle.difficulty || "BEGINNER") as keyof typeof difficultyConfig;
+ const type = (puzzle.type || "logic") as keyof typeof typeConfig;
+ const diffConfig = difficultyConfig[difficulty] ?? difficultyConfig.BEGINNER;
+ const typeConfig_ = typeConfig[type] ?? typeConfig.logic;
+ const pointsReward = puzzle.points || 10;
return (
@@ -35,7 +39,7 @@ export default function PuzzleInfoCard({ puzzle }: PuzzleInfoCardProps) {
Description
- {puzzle.description}
+ {puzzleDescription(puzzle)}
diff --git a/frontend/features/dashboard/dashboard.api.ts b/frontend/features/dashboard/dashboard.api.ts
index 5cab649e..d13daff1 100644
--- a/frontend/features/dashboard/dashboard.api.ts
+++ b/frontend/features/dashboard/dashboard.api.ts
@@ -1,13 +1,7 @@
-/**
- * Dashboard API Module
- *
- * Handles all API calls related to dashboard data fetching.
- * Includes endpoints for stats and categories.
- */
+import { fetchCurrentUser } from "@/lib/api/authApi";
+import { API_BASE_URL, authHeaders } from "@/lib/api/config";
+import { fetchStreak } from "@/lib/api/streakApi";
-const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000";
-
-// Types for API responses
export interface DashboardStats {
streak: number;
points: number;
@@ -34,28 +28,9 @@ export interface CategoriesResponse {
error?: string;
}
-// Helper function to get auth headers
-function getAuthHeaders(): Record {
- if (typeof window === "undefined") {
- return {};
- }
-
- const token = window.localStorage.getItem("accessToken");
-
- if (!token) {
- return {};
- }
-
- return {
- Authorization: `Bearer ${token}`,
- };
-}
-
-// Helper function to handle API responses
async function handleResponse(response: Response): Promise {
const contentType = response.headers.get("Content-Type");
const isJson = contentType && contentType.includes("application/json");
-
const data = isJson ? await response.json() : null;
if (!response.ok) {
@@ -68,37 +43,31 @@ async function handleResponse(response: Response): Promise {
return data as T;
}
-/**
- * Fetch dashboard stats including streak, points, and daily quest progress
- * GET /dashboard/stats
- */
export async function fetchDashboardStats(): Promise {
- const headers: HeadersInit = {
- "Content-Type": "application/json",
- ...getAuthHeaders(),
- };
-
- const response = await fetch(`${API_BASE_URL}/dashboard/stats`, {
- method: "GET",
- headers,
- });
+ const [streak, user, questStatus] = await Promise.all([
+ fetchStreak().catch(() => null),
+ fetchCurrentUser().catch(() => null),
+ fetch(`${API_BASE_URL}/daily-quest/status`, {
+ headers: authHeaders(),
+ })
+ .then((res) => (res.ok ? res.json() : null))
+ .catch(() => null),
+ ]);
- return handleResponse(response);
+ return {
+ streak: streak?.currentStreak ?? 0,
+ points: user?.xp ?? 0,
+ dailyQuestProgress: {
+ completed: questStatus?.completedQuestions ?? 0,
+ total: questStatus?.totalQuestions ?? 10,
+ },
+ };
}
-/**
- * Fetch all available categories
- * GET /categories
- */
export async function fetchCategories(): Promise {
- const headers: HeadersInit = {
- "Content-Type": "application/json",
- ...getAuthHeaders(),
- };
-
const response = await fetch(`${API_BASE_URL}/categories`, {
method: "GET",
- headers,
+ headers: authHeaders(),
});
return handleResponse(response);
diff --git a/frontend/hooks/useAuth.ts b/frontend/hooks/useAuth.ts
index 49a74c1d..f5cc5e08 100644
--- a/frontend/hooks/useAuth.ts
+++ b/frontend/hooks/useAuth.ts
@@ -22,6 +22,7 @@ import {
refreshToken,
type User,
} from '../lib/features/auth/authSlice';
+import { logoutSession } from '../lib/api/authApi';
// Auth hook that provides auth state and actions
export function useAuth() {
@@ -48,21 +49,12 @@ export function useAuth() {
const handleLogout = useCallback(async () => {
if (refreshTokenValue) {
try {
- // Call backend logout endpoint to invalidate the session
- await fetch('/api/auth/logout', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ refreshToken: refreshTokenValue }),
- });
+ await logoutSession(refreshTokenValue);
} catch (error) {
console.error('Logout error:', error);
}
}
- // Clear local state regardless of backend response
dispatch(logout());
- // Redirect to signin page
window.location.href = '/auth/signin';
}, [dispatch, refreshTokenValue]);
diff --git a/frontend/hooks/useGoogleAuth.ts b/frontend/hooks/useGoogleAuth.ts
new file mode 100644
index 00000000..a33e67ab
--- /dev/null
+++ b/frontend/hooks/useGoogleAuth.ts
@@ -0,0 +1,100 @@
+"use client";
+
+import { useCallback, useRef } from "react";
+import { authenticateWithGoogle } from "../lib/api/authApi";
+import { useAuth } from "./useAuth";
+
+declare global {
+ interface Window {
+ google?: {
+ accounts: {
+ id: {
+ initialize: (config: {
+ client_id: string;
+ callback: (response: { credential: string }) => void;
+ }) => void;
+ prompt: () => void;
+ };
+ };
+ };
+ }
+}
+
+function loadGoogleScript(): Promise {
+ if (typeof window === "undefined") {
+ return Promise.reject(new Error("Google sign-in is browser-only"));
+ }
+ if (window.google?.accounts?.id) {
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve, reject) => {
+ const existing = document.querySelector(
+ 'script[src="https://accounts.google.com/gsi/client"]',
+ );
+ if (existing) {
+ existing.addEventListener("load", () => resolve());
+ existing.addEventListener("error", () =>
+ reject(new Error("Failed to load Google Identity")),
+ );
+ return;
+ }
+
+ const script = document.createElement("script");
+ script.src = "https://accounts.google.com/gsi/client";
+ script.async = true;
+ script.defer = true;
+ script.onload = () => resolve();
+ script.onerror = () => reject(new Error("Failed to load Google Identity"));
+ document.head.appendChild(script);
+ });
+}
+
+export function useGoogleAuth() {
+ const { loginSuccess } = useAuth();
+ const pending = useRef | null>(null);
+
+ const signInWithGoogle = useCallback(async () => {
+ const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
+ if (!clientId) {
+ throw new Error(
+ "Google sign-in is not configured. Set NEXT_PUBLIC_GOOGLE_CLIENT_ID.",
+ );
+ }
+
+ await loadGoogleScript();
+ if (!window.google?.accounts?.id) {
+ throw new Error("Google Identity is unavailable");
+ }
+
+ if (pending.current) {
+ return pending.current;
+ }
+
+ pending.current = new Promise((resolve, reject) => {
+ window.google!.accounts.id.initialize({
+ client_id: clientId,
+ callback: async ({ credential }) => {
+ try {
+ const tokens = await authenticateWithGoogle(credential);
+ const user = tokens.user ?? {
+ id: "google-user",
+ email: undefined,
+ };
+ loginSuccess(user, tokens.accessToken, tokens.refreshToken);
+ resolve();
+ } catch (error) {
+ reject(error);
+ } finally {
+ pending.current = null;
+ }
+ },
+ });
+ window.google!.accounts.id.prompt();
+ });
+
+ return pending.current;
+ }, [loginSuccess]);
+
+ return { signInWithGoogle };
+}
diff --git a/frontend/hooks/useGuestSession.ts b/frontend/hooks/useGuestSession.ts
index e4554edd..97ebec4a 100644
--- a/frontend/hooks/useGuestSession.ts
+++ b/frontend/hooks/useGuestSession.ts
@@ -1,41 +1,45 @@
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback } from "react";
+import {
+ createGuestSession,
+ getGuestSessionStatus,
+ type GuestSessionResponse,
+} from "../lib/api/authApi";
+import { GUEST_SESSION_KEY } from "../lib/api/config";
-export interface GuestSessionData {
- sessionId: string;
- createdAt: number;
- expiresAt: number;
- hintsUsed: number;
- maxHints: number;
-}
-
-const GUEST_KEY = 'mb_guest_session';
+export type GuestSessionData = GuestSessionResponse;
export function useGuestSession() {
const [session, setSession] = useState(null);
const [timeLeft, setTimeLeft] = useState(0);
const [isExpired, setIsExpired] = useState(false);
+ const [isStarting, setIsStarting] = useState(false);
const [showSignupModal, setShowSignupModal] = useState(false);
- // Initialize or load guest session
useEffect(() => {
- const stored = localStorage.getItem(GUEST_KEY);
- if (stored) {
- try {
- const parsed: GuestSessionData = JSON.parse(stored);
- if (Date.now() > parsed.expiresAt) {
- setIsExpired(true);
- setShowSignupModal(true);
- } else {
- setSession(parsed);
- setTimeLeft(Math.max(0, Math.floor((parsed.expiresAt - Date.now()) / 1000)));
- }
- } catch {
- localStorage.removeItem(GUEST_KEY);
- }
+ const stored = localStorage.getItem(GUEST_SESSION_KEY);
+ if (!stored) return;
+
+ try {
+ const parsed: GuestSessionData = JSON.parse(stored);
+ void getGuestSessionStatus(parsed.sessionId)
+ .then((status: { valid?: boolean; expired?: boolean; session?: GuestSessionData }) => {
+ if (status.expired || !status.valid) {
+ setIsExpired(true);
+ setShowSignupModal(true);
+ return;
+ }
+ const live = status.session ?? parsed;
+ setSession(live);
+ setTimeLeft(Math.max(0, Math.floor((live.expiresAt - Date.now()) / 1000)));
+ })
+ .catch(() => {
+ localStorage.removeItem(GUEST_SESSION_KEY);
+ });
+ } catch {
+ localStorage.removeItem(GUEST_SESSION_KEY);
}
}, []);
- // Countdown timer effect
useEffect(() => {
if (!session || isExpired) return;
@@ -54,19 +58,18 @@ export function useGuestSession() {
return () => clearInterval(timer);
}, [session, isExpired]);
- const startGuestSession = useCallback(() => {
- const now = Date.now();
- const newSession: GuestSessionData = {
- sessionId: `guest_${Math.random().toString(36).substring(2, 9)}`,
- createdAt: now,
- expiresAt: now + 15 * 60 * 1000,
- hintsUsed: 0,
- maxHints: 2,
- };
- localStorage.setItem(GUEST_KEY, JSON.stringify(newSession));
- setSession(newSession);
- setIsExpired(false);
- setTimeLeft(15 * 60);
+ const startGuestSession = useCallback(async () => {
+ setIsStarting(true);
+ try {
+ const created = await createGuestSession();
+ localStorage.setItem(GUEST_SESSION_KEY, JSON.stringify(created));
+ setSession(created);
+ setIsExpired(false);
+ setTimeLeft(Math.max(0, Math.floor((created.expiresAt - Date.now()) / 1000)));
+ return created;
+ } finally {
+ setIsStarting(false);
+ }
}, []);
const useHint = useCallback((): boolean => {
@@ -75,15 +78,14 @@ export function useGuestSession() {
setShowSignupModal(true);
return false;
}
-
const updated = { ...session, hintsUsed: session.hintsUsed + 1 };
setSession(updated);
- localStorage.setItem(GUEST_KEY, JSON.stringify(updated));
+ localStorage.setItem(GUEST_SESSION_KEY, JSON.stringify(updated));
return true;
}, [session, isExpired]);
const clearGuestSession = useCallback(() => {
- localStorage.removeItem(GUEST_KEY);
+ localStorage.removeItem(GUEST_SESSION_KEY);
setSession(null);
setIsExpired(false);
setTimeLeft(0);
@@ -93,6 +95,7 @@ export function useGuestSession() {
session,
timeLeft,
isExpired,
+ isStarting,
showSignupModal,
setShowSignupModal,
startGuestSession,
diff --git a/frontend/lib/api/authApi.ts b/frontend/lib/api/authApi.ts
new file mode 100644
index 00000000..5f939ea1
--- /dev/null
+++ b/frontend/lib/api/authApi.ts
@@ -0,0 +1,82 @@
+import api from "./client";
+import { API_BASE_URL, authHeaders } from "./config";
+import type { User } from "../features/auth/authSlice";
+
+export interface AuthTokens {
+ accessToken: string;
+ refreshToken: string;
+ user?: User;
+}
+
+export interface GuestSessionResponse {
+ sessionId: string;
+ role: string;
+ createdAt: number;
+ expiresAt: number;
+ hintsUsed: number;
+ maxHints: number;
+ isConverted: boolean;
+}
+
+export async function signIn(
+ email: string,
+ password: string,
+): Promise {
+ const { data } = await api.post("/auth/signIn", {
+ email,
+ password,
+ });
+ return data;
+}
+
+export async function register(payload: {
+ email: string;
+ username: string;
+ password: string;
+ passwordConfirm: string;
+ fullname?: string;
+}): Promise {
+ const { data } = await api.post("/auth/register", payload);
+ return data;
+}
+
+export async function authenticateWithGoogle(
+ idToken: string,
+): Promise {
+ const { data } = await api.post("/auth/google-authentication", {
+ token: idToken,
+ });
+ return data;
+}
+
+export async function createGuestSession(): Promise {
+ const { data } = await api.post("/auth/guest-session");
+ return data;
+}
+
+export async function getGuestSessionStatus(sessionId: string) {
+ const { data } = await api.get(`/auth/guest-session/${sessionId}/status`);
+ return data;
+}
+
+export async function fetchCurrentUser(): Promise {
+ const { data } = await api.get("/auth/me");
+ return data;
+}
+
+export async function refreshAccessToken(
+ refreshToken: string,
+): Promise {
+ const { data } = await api.post("/auth/refreshToken", {
+ refreshToken,
+ });
+ return data;
+}
+
+export async function logoutSession(refreshToken: string): Promise {
+ await fetch(`${API_BASE_URL}/auth/logout`, {
+ method: "POST",
+ headers: authHeaders(),
+ body: JSON.stringify({ refreshToken }),
+ });
+}
diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts
index cd447bc9..19495233 100644
--- a/frontend/lib/api/client.ts
+++ b/frontend/lib/api/client.ts
@@ -1,13 +1,15 @@
-// frontend/lib/api/client.ts
-import axios from 'axios';
-import {setupInterceptors} from './interceptors'
+import axios from "axios";
+import { API_BASE_URL } from "./config";
+import { setupInterceptors } from "./interceptors";
const api = axios.create({
- baseURL: process.env.NEXT_PUBLIC_API_URL,
+ baseURL: API_BASE_URL,
+ headers: {
+ "Content-Type": "application/json",
+ },
});
-// Prevent API calls during SSR
-if (typeof window !== 'undefined') {
+if (typeof window !== "undefined") {
setupInterceptors(api);
}
diff --git a/frontend/lib/api/config.ts b/frontend/lib/api/config.ts
new file mode 100644
index 00000000..9d7261d5
--- /dev/null
+++ b/frontend/lib/api/config.ts
@@ -0,0 +1,42 @@
+export const API_BASE_URL = (
+ process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"
+).replace(/\/$/, "");
+
+export const ACCESS_TOKEN_KEY = "accessToken";
+export const REFRESH_TOKEN_KEY = "refreshToken";
+export const GUEST_SESSION_KEY = "mb_guest_session";
+
+export function getAccessToken(): string | null {
+ if (typeof window === "undefined") return null;
+ return window.localStorage.getItem(ACCESS_TOKEN_KEY);
+}
+
+export function getRefreshToken(): string | null {
+ if (typeof window === "undefined") return null;
+ return window.localStorage.getItem(REFRESH_TOKEN_KEY);
+}
+
+export function storeTokens(accessToken: string, refreshToken?: string) {
+ if (typeof window === "undefined") return;
+ window.localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
+ if (refreshToken) {
+ window.localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
+ }
+}
+
+export function clearTokens() {
+ if (typeof window === "undefined") return;
+ window.localStorage.removeItem(ACCESS_TOKEN_KEY);
+ window.localStorage.removeItem(REFRESH_TOKEN_KEY);
+}
+
+export function authHeaders(): Record {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ };
+ const token = getAccessToken();
+ if (token) {
+ headers.Authorization = `Bearer ${token}`;
+ }
+ return headers;
+}
diff --git a/frontend/lib/api/error-handler.ts b/frontend/lib/api/error-handler.ts
index 449a5ae4..7d2176a2 100644
--- a/frontend/lib/api/error-handler.ts
+++ b/frontend/lib/api/error-handler.ts
@@ -1,11 +1,33 @@
-// frontend/lib/api/error-handler.ts
-import { ApiError } from './types';
-
-export function handleApiError(error: any): ApiError {
- const response = error.response || {};
- return {
- message: response.data?.message || 'Unexpected error occurred',
- code: response.data?.code || 'UNKNOWN_ERROR',
- status: response.status || 500,
+import { ApiError } from "./types";
+
+export class NormalizedApiError extends Error implements ApiError {
+ code: string;
+ status: number;
+
+ constructor(message: string, code: string, status: number) {
+ super(message);
+ this.name = "NormalizedApiError";
+ this.code = code;
+ this.status = status;
+ }
+}
+
+export function handleApiError(error: unknown): NormalizedApiError {
+ const axiosError = error as {
+ response?: { data?: { message?: string; code?: string }; status?: number };
+ message?: string;
};
+ const response = axiosError.response || {};
+ const message =
+ (Array.isArray(response.data?.message)
+ ? response.data?.message[0]
+ : response.data?.message) ||
+ axiosError.message ||
+ "Unexpected error occurred";
+
+ return new NormalizedApiError(
+ message,
+ response.data?.code || "UNKNOWN_ERROR",
+ response.status || 500,
+ );
}
diff --git a/frontend/lib/api/interceptors.ts b/frontend/lib/api/interceptors.ts
index f83149f1..f1331294 100644
--- a/frontend/lib/api/interceptors.ts
+++ b/frontend/lib/api/interceptors.ts
@@ -1,10 +1,12 @@
-// frontend/lib/api/interceptors.ts
-import type { AxiosInstance } from 'axios';
-import { handleApiError } from './error-handler';
+import type { AxiosInstance, InternalAxiosRequestConfig } from "axios";
+import { getAccessToken } from "./config";
+import { handleApiError } from "./error-handler";
+
+type RetryConfig = InternalAxiosRequestConfig & { __retryCount?: number };
export function setupInterceptors(api: AxiosInstance) {
api.interceptors.request.use((config) => {
- const token = localStorage.getItem('jwt');
+ const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
@@ -14,17 +16,26 @@ export function setupInterceptors(api: AxiosInstance) {
api.interceptors.response.use(
(response) => response,
async (error) => {
- const { status } = error.response || {};
- if (status === 401) {
- window.location.href = '/login';
+ const status = error.response?.status as number | undefined;
+ const config = (error.config ?? {}) as RetryConfig;
+ const method = (config.method ?? "get").toLowerCase();
+
+ if (status === 401 && typeof window !== "undefined") {
+ if (!window.location.pathname.startsWith("/auth")) {
+ window.location.href = "/auth/signin";
+ }
+ return Promise.reject(handleApiError(error));
}
- // Retry logic: up to 3 attempts
- const config = error.config;
- if (!config.__retryCount) config.__retryCount = 0;
- if (config.__retryCount < 3) {
- config.__retryCount += 1;
- return api(config);
+ const retryable =
+ method === "get" &&
+ status !== undefined &&
+ status >= 500 &&
+ (config.__retryCount ?? 0) < 2;
+
+ if (retryable) {
+ config.__retryCount = (config.__retryCount ?? 0) + 1;
+ return api.request(config);
}
return Promise.reject(handleApiError(error));
diff --git a/frontend/lib/api/puzzleApi.ts b/frontend/lib/api/puzzleApi.ts
index b7891ed1..2eb928bd 100644
--- a/frontend/lib/api/puzzleApi.ts
+++ b/frontend/lib/api/puzzleApi.ts
@@ -1,22 +1,38 @@
-// frontend/lib/api/puzzleApi.ts
-import axios from 'axios';
-import { Puzzle, PuzzleQueryParams } from '../types/puzzles';
+import api from "./client";
+import {
+ Puzzle,
+ PuzzleListResponse,
+ PuzzleQueryParams,
+} from "../types/puzzles";
-const api = axios.create({
- baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000',
-});
+function normalizePuzzle(raw: Puzzle): Puzzle {
+ return {
+ ...raw,
+ title: raw.title || raw.question,
+ description: raw.description || raw.explanation || raw.question,
+ type: raw.type ?? "logic",
+ points: raw.points ?? 0,
+ timeLimit: raw.timeLimit ?? 60,
+ };
+}
-export async function getPuzzles(query: PuzzleQueryParams): Promise {
- const response = await api.get('/puzzles', { params: query });
- return response.data;
+export async function getPuzzles(
+ query: PuzzleQueryParams,
+): Promise {
+ const response = await api.get("/puzzles", {
+ params: query,
+ });
+ const body = response.data;
+ const rows = Array.isArray(body) ? body : (body?.data ?? []);
+ return rows.map(normalizePuzzle);
}
export async function getPuzzleById(id: string): Promise {
- const response = await api.get(`/puzzles/${id}`);
- return response.data;
+ const response = await api.get(`/puzzles/${id}`);
+ return normalizePuzzle(response.data);
}
export async function getDailyQuestPuzzles(): Promise {
- const response = await api.get('/puzzles/daily-quest');
- return response.data;
+ const response = await api.get("/puzzles/daily-quest");
+ return (response.data ?? []).map(normalizePuzzle);
}
diff --git a/frontend/lib/features/auth/authSlice.ts b/frontend/lib/features/auth/authSlice.ts
index 8f912247..29ac0de9 100644
--- a/frontend/lib/features/auth/authSlice.ts
+++ b/frontend/lib/features/auth/authSlice.ts
@@ -1,4 +1,9 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
+import {
+ API_BASE_URL,
+ ACCESS_TOKEN_KEY,
+ REFRESH_TOKEN_KEY,
+} from '../../api/config';
// Types
export interface User {
@@ -7,6 +12,8 @@ export interface User {
walletAddress?: string;
username?: string;
avatar?: string;
+ xp?: number;
+ level?: number;
createdAt?: string;
updatedAt?: string;
}
@@ -24,9 +31,9 @@ export interface AuthState {
// Initial state
const initialState: AuthState = {
user: null,
- token: typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null,
- refreshToken: typeof window !== 'undefined' ? localStorage.getItem('refreshToken') : null,
- isAuthenticated: typeof window !== 'undefined' ? !!localStorage.getItem('accessToken') : false,
+ token: typeof window !== 'undefined' ? localStorage.getItem(ACCESS_TOKEN_KEY) : null,
+ refreshToken: typeof window !== 'undefined' ? localStorage.getItem(REFRESH_TOKEN_KEY) : null,
+ isAuthenticated: typeof window !== 'undefined' ? !!localStorage.getItem(ACCESS_TOKEN_KEY) : false,
isLoading: false,
error: null,
isRestoring: false,
@@ -46,29 +53,27 @@ export const restoreSession = createAsyncThunk(
'auth/restoreSession',
async (_, { rejectWithValue, getState }) => {
try {
- const token = localStorage.getItem('accessToken');
+ const token = localStorage.getItem(ACCESS_TOKEN_KEY);
if (!token) {
throw new Error('No token found');
}
-
- // Validate token with backend by fetching user data
- const response = await fetch('/api/auth/me', {
+
+ const response = await fetch(`${API_BASE_URL}/auth/me`, {
headers: {
- 'Authorization': `Bearer ${token}`,
+ Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
-
+
if (!response.ok) {
throw new Error('Invalid token');
}
-
+
const user = await response.json();
return { token, user };
} catch (error) {
- // Clear invalid tokens
- localStorage.removeItem('accessToken');
- localStorage.removeItem('refreshToken');
+ localStorage.removeItem(ACCESS_TOKEN_KEY);
+ localStorage.removeItem(REFRESH_TOKEN_KEY);
return rejectWithValue('Session expired');
}
}
@@ -78,29 +83,27 @@ export const refreshToken = createAsyncThunk(
'auth/refreshToken',
async (_, { rejectWithValue, getState }) => {
try {
- const refreshTokenValue = localStorage.getItem('refreshToken');
+ const refreshTokenValue = localStorage.getItem(REFRESH_TOKEN_KEY);
if (!refreshTokenValue) {
throw new Error('No refresh token found');
}
-
- // Call refresh token endpoint
- const response = await fetch('/api/auth/refresh', {
+
+ const response = await fetch(`${API_BASE_URL}/auth/refreshToken`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken: refreshTokenValue }),
});
-
+
if (!response.ok) {
throw new Error('Failed to refresh token');
}
-
+
const data = await response.json();
-
- // Update stored tokens
- localStorage.setItem('accessToken', data.accessToken);
- localStorage.setItem('refreshToken', data.refreshToken);
+
+ localStorage.setItem(ACCESS_TOKEN_KEY, data.accessToken);
+ localStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken);
return {
accessToken: data.accessToken,
@@ -109,8 +112,8 @@ export const refreshToken = createAsyncThunk(
};
} catch (error) {
// Clear invalid tokens
- localStorage.removeItem('accessToken');
- localStorage.removeItem('refreshToken');
+ localStorage.removeItem(ACCESS_TOKEN_KEY);
+ localStorage.removeItem(REFRESH_TOKEN_KEY);
return rejectWithValue('Failed to refresh token');
}
}
@@ -131,8 +134,8 @@ const authSlice = createSlice({
// Store tokens in localStorage
if (typeof window !== 'undefined') {
- localStorage.setItem('accessToken', action.payload.token);
- localStorage.setItem('refreshToken', action.payload.refreshToken);
+ localStorage.setItem(ACCESS_TOKEN_KEY, action.payload.token);
+ localStorage.setItem(REFRESH_TOKEN_KEY, action.payload.refreshToken);
}
},
@@ -145,7 +148,8 @@ const authSlice = createSlice({
// Remove token from localStorage
if (typeof window !== 'undefined') {
- localStorage.removeItem('accessToken');
+ localStorage.removeItem(ACCESS_TOKEN_KEY);
+ localStorage.removeItem(REFRESH_TOKEN_KEY);
}
},
@@ -159,8 +163,8 @@ const authSlice = createSlice({
// Remove tokens from localStorage
if (typeof window !== 'undefined') {
- localStorage.removeItem('accessToken');
- localStorage.removeItem('refreshToken');
+ localStorage.removeItem(ACCESS_TOKEN_KEY);
+ localStorage.removeItem(REFRESH_TOKEN_KEY);
}
},
diff --git a/frontend/lib/types/puzzles.ts b/frontend/lib/types/puzzles.ts
index 49c065de..64ebd16b 100644
--- a/frontend/lib/types/puzzles.ts
+++ b/frontend/lib/types/puzzles.ts
@@ -1,9 +1,9 @@
export type PuzzleDifficulty =
- | 'ALL'
- | 'BEGINNER'
- | 'INTERMEDIATE'
- | 'ADVANCED'
- | 'EXPERT';
+ | "ALL"
+ | "BEGINNER"
+ | "INTERMEDIATE"
+ | "ADVANCED"
+ | "EXPERT";
export interface Category {
id: string;
@@ -15,12 +15,20 @@ export interface Category {
export interface Puzzle {
id: string;
- title: string;
- description: string;
- type: 'logic' | 'coding' | 'blockchain';
+ question: string;
+ options?: string[];
+ correctAnswer?: string;
difficulty: PuzzleDifficulty;
categoryId: string;
- timeLimit?: number;
+ category?: Category;
+ points: number;
+ timeLimit: number;
+ explanation?: string;
+ createdAt?: string;
+ /** Legacy UI fields — derived from `question` when the API omits them. */
+ title?: string;
+ description?: string;
+ type?: "logic" | "coding" | "blockchain";
}
export interface PuzzleQueryParams {
@@ -30,7 +38,24 @@ export interface PuzzleQueryParams {
limit?: number;
}
+export interface PuzzleListResponse {
+ data: Puzzle[];
+ meta: {
+ page: number;
+ limit: number;
+ total: number;
+ };
+}
+
export interface PuzzleFilters {
- categoryId: string; // '' means "All"
+ categoryId: string;
difficulty: PuzzleDifficulty;
}
+
+export function puzzleTitle(puzzle: Puzzle): string {
+ return puzzle.title || puzzle.question || "Untitled puzzle";
+}
+
+export function puzzleDescription(puzzle: Puzzle): string {
+ return puzzle.description || puzzle.explanation || puzzle.question || "";
+}
diff --git a/frontend/package.json b/frontend/package.json
index 79a19f67..9fdf1700 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
- "dev": "next dev --turbopack",
+ "dev": "next dev --turbopack --port 3001",
"build": "next build",
"start": "next start",
"lint": "eslint"
@@ -16,6 +16,7 @@
"@reduxjs/toolkit": "^2.11.2",
"@stellar/freighter-api": "^6.0.1",
"@tanstack/react-query": "^5.90.21",
+ "axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.42.2",