diff --git a/src/app/(auth)/connect/page.tsx b/src/app/(auth)/connect/page.tsx index 65d9f25..6f95752 100644 --- a/src/app/(auth)/connect/page.tsx +++ b/src/app/(auth)/connect/page.tsx @@ -4,9 +4,9 @@ import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/hooks/use-auth"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; -import { Wallet, Shield, ArrowRight, Loader2, AlertCircle } from "lucide-react"; +import { Wallet, Shield, Loader2, AlertCircle } from "lucide-react"; import { isFreighterInstalled } from "@/lib/stellar/wallet"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useToastContext } from "@/components/shared/toast"; /** @@ -16,41 +16,71 @@ import { useToastContext } from "@/components/shared/toast"; */ export default function ConnectPage() { const router = useRouter(); - const { isAuthenticated, isConnecting, connectWallet, error } = useAuth(); + const { + isAuthenticated, + isConnecting, + connectionStage, + connectWallet, + error, + walletError, + } = useAuth(); const { addToast } = useToastContext(); const [freighterInstalled, setFreighterInstalled] = useState( null ); + const connectingFromPage = useRef(false); useEffect(() => { - isFreighterInstalled().then(setFreighterInstalled); + let active = true; + isFreighterInstalled().then((installed) => { + if (active) setFreighterInstalled(installed); + }); + return () => { + active = false; + }; }, []); useEffect(() => { - if (isAuthenticated) { - router.push("/dashboard"); - } + if (!isAuthenticated || connectingFromPage.current) return; + let active = true; + const redirect = async () => { + try { + const { getProfile } = await import("@/lib/api/auth"); + const { useAuthStore } = await import("@/store/auth-store"); + const jwt = useAuthStore.getState().jwt; + const profile = jwt ? await getProfile(jwt) : null; + if (active) { + router.replace(profile?.displayName ? "/dashboard" : "/onboarding"); + } + } catch { + if (active) router.replace("/onboarding"); + } + }; + void redirect(); + return () => { + active = false; + }; }, [isAuthenticated, router]); const handleConnect = async () => { + connectingFromPage.current = true; try { await connectWallet(); - addToast("Wallet connected successfully!", "success"); + } catch { + connectingFromPage.current = false; + return; + } + + addToast("Wallet connected successfully!", "success"); + try { const { getProfile } = await import("@/lib/api/auth"); const { useAuthStore } = await import("@/store/auth-store"); const jwt = useAuthStore.getState().jwt; - if (jwt) { - const profile = await getProfile(jwt); - if (profile.displayName) { - router.push("/dashboard"); - } else { - router.push("/onboarding"); - } - } else { - router.push("/onboarding"); - } + const profile = jwt ? await getProfile(jwt) : null; + router.replace(profile?.displayName ? "/dashboard" : "/onboarding"); } catch { - // Error is already displayed inline via the error state from useAuth + addToast("Wallet connected, but your profile could not be loaded. Continue setup to get started.", "error"); + router.replace("/onboarding"); } }; @@ -97,10 +127,49 @@ export default function ConnectPage() { aria-live="polite" > -

{error}

+
+

{walletError?.message ?? error}

+ {walletError?.resolution && ( +

{walletError.resolution}

+ )} +
)} +
    + {[ + ["detect", "Detect Freighter"], + ["connect", "Connect wallet"], + ["sign", "Sign challenge"], + ["verify", "Verify connection"], + ].map(([stage, label], index) => { + const stages = ["detect", "connect", "sign", "verify"]; + const activeIndex = stages.indexOf(connectionStage); + const active = connectionStage === stage; + const complete = + connectionStage === "complete" || + (activeIndex >= 0 && index < activeIndex); + return ( +
  1. + {active && isConnecting ? ( + + ) : ( + + )} + {label} +
  2. + ); + })} +
+ , - /* Step 1: Background */ -
-
- -

Your Background

-

- Help us personalize your learning path. -

-
-
- {backgrounds.map((bg) => ( - - ))} -
-
- - -
-
, - - /* Step 2: Goals */ + /* Step 1: Learning goals */
- +

Learning Goals

Select what you want to achieve (pick up to 3). @@ -180,10 +141,10 @@ export default function OnboardingPage() { {goals.map((goal) => (

- -
, - /* Step 3: Pace */ -
+ /* Step 2: Pace and language */ +
-

Learning Pace

+ +

Your Preferences

- How much time can you dedicate? + Choose a learning pace and language.

+

Learning pace

{paces.map((p) => ( ))}
+
+

Language

+
+ {languages.map((item) => ( + + ))} +
+
+
+ + +
+
, + + /* Step 3: Review */ +
+
+ +

Review Your Profile

+

+ Make sure everything looks right before you continue. +

+
+
+
+
Display name
+
{displayName.trim()}
+
+
+
Learning goals
+
{selectedGoals.join(", ")}
+
+
+
Pace
+
{paces.find((item) => item.value === pace)?.label}
+
+
+
Language
+
{languages.find((item) => item.value === language)?.label}
+
+
{steps[step]} diff --git a/src/app/courses/[courseId]/modules/[moduleId]/page.tsx b/src/app/courses/[courseId]/modules/[moduleId]/page.tsx index 5bcae3a..95983c6 100644 --- a/src/app/courses/[courseId]/modules/[moduleId]/page.tsx +++ b/src/app/courses/[courseId]/modules/[moduleId]/page.tsx @@ -34,7 +34,8 @@ export default function ModulePage({ ); const { addToast } = useToastContext(); const [completing, setCompleting] = useState(false); - const [completed, setCompleted] = useState(false); + const progressPercent = + courseProgress?.progressPercent ?? enrollment?.progress ?? 0; const sortedModules = course ? [...course.modules].sort((a, b) => a.order - b.order) @@ -47,15 +48,14 @@ export default function ModulePage({ ? sortedModules[currentIndex + 1] : null; const isComplete = - completed || Boolean(module?.isCompleted) || + Boolean(courseProgress?.completedModuleIds.includes(moduleId)) || Boolean(enrollment?.completedModules.includes(moduleId)); const handleComplete = async () => { setCompleting(true); try { await complete(); - setCompleted(true); addToast("Module marked as complete!", "success"); } catch (err) { console.error("Failed to mark complete:", err); @@ -83,15 +83,20 @@ export default function ModulePage({ ); } + const content = parseModuleContent(module); + return (
{/* Breadcrumb */} {/* Progress */} - {courseProgress && ( + {(courseProgress || enrollment) && (
- + +

+ {progressPercent}% complete +

)} @@ -104,10 +109,10 @@ export default function ModulePage({

{module.description}

- {module.contentType === "video" ? ( + {content.type === "video" ? (
- {(parseModuleContent(module) as any).url ? ( -
- ) : module.contentType === "interactive" ? ( + ) : content.type === "interactive" ? (

Interactive Challenge

-

Complete the interactive task to proceed.

+ {content.instructions ? ( +

{content.instructions}

+ ) : ( +

+ Complete challenge {content.challengeId}, then mark this module complete. +

+ )}
) : ( -
+
)}
diff --git a/src/lib/api/courses.test.ts b/src/lib/api/courses.test.ts new file mode 100644 index 0000000..0b6192b --- /dev/null +++ b/src/lib/api/courses.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { parseModuleContent } from "./courses"; +import type { Module, ModuleContent } from "@/types/course"; + +function createModule( + contentType: Module["contentType"], + content: Module["content"] +): Module { + return { + id: "module-1", + courseId: "course-1", + title: "Module", + description: "Description", + order: 1, + contentType, + content, + estimatedMinutes: 10, + }; +} + +describe("parseModuleContent", () => { + it("parses string-backed text content", () => { + expect(parseModuleContent(createModule("text", "

Lesson

"))).toEqual({ + type: "text", + body: "

Lesson

", + }); + }); + + it("parses string-backed video and interactive content", () => { + expect(parseModuleContent(createModule("video", "/lesson.mp4"))).toEqual({ + type: "video", + url: "/lesson.mp4", + }); + expect(parseModuleContent(createModule("interactive", "challenge-1"))).toEqual({ + type: "interactive", + challengeId: "challenge-1", + }); + }); + + it("preserves structured module content", () => { + const content: ModuleContent = { + type: "interactive", + challengeId: "challenge-2", + instructions: "Complete the task", + }; + expect(parseModuleContent(createModule("interactive", content))).toBe(content); + }); +}); diff --git a/src/lib/api/courses.ts b/src/lib/api/courses.ts index d4138ff..af1931f 100644 --- a/src/lib/api/courses.ts +++ b/src/lib/api/courses.ts @@ -202,14 +202,19 @@ export async function getRecommendedCourses( * this helper needs to change. */ export function parseModuleContent(module: Module): ModuleContent { + if (typeof module.content === "object" && module.content !== null) { + return module.content; + } + + const content = module.content; switch (module.contentType) { case "video": - return { type: "video", url: module.content as string }; + return { type: "video", url: content }; case "interactive": - return { type: "interactive", challengeId: module.content as string }; + return { type: "interactive", challengeId: content }; case "text": default: - return { type: "text", body: module.content as string }; + return { type: "text", body: content }; } } diff --git a/src/lib/hooks/use-auth.ts b/src/lib/hooks/use-auth.ts index ba72fa4..339db93 100644 --- a/src/lib/hooks/use-auth.ts +++ b/src/lib/hooks/use-auth.ts @@ -23,6 +23,8 @@ export interface WalletError { resolution: string; } +export type WalletConnectionStage = "idle" | "detect" | "connect" | "sign" | "verify" | "complete"; + // Helper to classify wallet errors function classifyWalletError(err: unknown, currentNetwork: string): WalletError { const msg = err instanceof Error ? err.message : String(err); @@ -118,9 +120,12 @@ export function useAuth() { networkRef.current = network; const [walletError, setWalletError] = useState(null); + const [connectionStage, setConnectionStage] = + useState("idle"); const connectWallet = useCallback(async () => { setIsConnecting(true); + setConnectionStage("detect"); clearError(); setWalletError(null); @@ -140,16 +145,19 @@ export function useAuth() { } // Connect to Freighter + setConnectionStage("connect"); const address = await connectFreighter(); // Get challenge from backend const challenge = await getChallenge(address); // Sign challenge with Freighter + setConnectionStage("sign"); const passphrase = getNetworkPassphrase(networkRef.current); const signedChallenge = await signChallenge(challenge, passphrase); // Verify signature and get JWT + setConnectionStage("verify"); const tokens = await verifySignature(address, signedChallenge); // Store in Zustand. The refresh token is kept so useTokenRefresh can @@ -160,6 +168,7 @@ export function useAuth() { tokens.expiresIn, tokens.refreshToken ); + setConnectionStage("complete"); return address; } catch (err) { @@ -198,6 +207,7 @@ export function useAuth() { jwt, isAuthenticated, isConnecting, + connectionStage, error, walletError, walletInfo, diff --git a/src/lib/utils/__tests__/format.test.ts b/src/lib/utils/__tests__/format.test.ts index 91a3e71..739b2b0 100644 --- a/src/lib/utils/__tests__/format.test.ts +++ b/src/lib/utils/__tests__/format.test.ts @@ -88,6 +88,11 @@ describe("truncate", () => { it("handles empty strings", () => { expect(truncate("", 12)).toBe(""); }); + + it("keeps the result within the maximum when the suffix is longer", () => { + expect(truncate("Course Title", 2)).toBe(".."); + expect(truncate("Course Title", 0)).toBe(""); + }); }); describe("pluralize", () => { diff --git a/src/lib/utils/format.ts b/src/lib/utils/format.ts index 455ad73..5a071ed 100644 --- a/src/lib/utils/format.ts +++ b/src/lib/utils/format.ts @@ -166,9 +166,13 @@ export function capitalize(str: string): string { */ export function truncate(text: string, maxLength: number, suffix = "..."): string { if (!text) return ""; - if (maxLength <= 0) return ""; + if (!Number.isFinite(maxLength) || maxLength <= 0) return ""; if (text.length <= maxLength) return text; - return `${text.slice(0, maxLength - suffix.length).trimEnd()}${suffix}`; + + const limit = Math.floor(maxLength); + const truncatedSuffix = suffix.slice(0, limit); + const contentLength = limit - truncatedSuffix.length; + return `${text.slice(0, contentLength).trimEnd()}${truncatedSuffix}`; } /** diff --git a/src/types/api.ts b/src/types/api.ts index edbb60b..8637704 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -67,6 +67,7 @@ export interface UserProfile { background: string; learningGoals: string[]; preferredPace: "slow" | "moderate" | "fast"; + language?: string; avatarUrl?: string; createdAt: string; updatedAt: string;