Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/app/courses/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import { useCourses, useInfiniteCourses } from "@/lib/hooks/use-courses";
import { useEnrollments, useInfiniteCourses } from "@/lib/hooks/use-courses";
import { useDebounce } from "@/lib/hooks/use-debounce";
import { useCourseStore } from "@/store/course-store";
import { CourseCard } from "@/components/course/course-card";
Expand Down Expand Up @@ -29,7 +29,7 @@ const categories = [
const difficulties = ["All", "Beginner", "Intermediate", "Advanced"];

export default function CoursesPage() {
const { enrollments } = useCourses();
const { enrollments } = useEnrollments();
const progress = useCourseStore((s) => s.progress);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
Expand Down
74 changes: 51 additions & 23 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
"use client";

import { Component, type ReactNode } from "react";
import { Component, useState, type ReactNode } from "react";
import { useRequireAuth } from "@/lib/hooks/use-require-auth";
import { useCourses, useRecommendedCourses } from "@/lib/hooks/use-courses";
import {
useCourses,
useEnrollments,
useRecommendedCourses,
} from "@/lib/hooks/use-courses";
import { useRewards } from "@/lib/hooks/use-rewards";
import { useCredentials } from "@/lib/hooks/use-credentials";
import { Card, CardContent } from "@/components/ui/card";
Expand All @@ -11,7 +15,12 @@ import { CourseProgressCard } from "@/components/course/course-progress-card";
import { BalanceDisplay } from "@/components/wallet/balance-display";
import { RewardHistory } from "@/components/rewards/reward-history";
import { CredentialCard } from "@/components/credentials/credential-card";
import { DashboardSkeleton } from "@/components/shared/loading-skeleton";
import {
CourseGridSkeleton,
CredentialCardSkeleton,
DashboardSkeleton,
RewardsSkeleton,
} from "@/components/shared/loading-skeleton";
import { EmptyState } from "@/components/shared/empty-state";
import { StatsCard } from "@/components/shared/stats-card";
import { useCourseStore } from "@/store/course-store";
Expand Down Expand Up @@ -69,24 +78,31 @@ function SectionError({ label, message }: { label: string; message: string }) {

export default function DashboardPage() {
const { ready } = useRequireAuth();
const { courses, enrollments, loading: coursesLoading, error: coursesError } = useCourses();
const { recommended, loading: recommendedLoading, error: recommendedError } = useRecommendedCourses();
const { balances, history, loading: rewardsLoading, error: rewardsError } = useRewards();
const { credentials, loading: credentialsLoading, error: credentialsError } = useCredentials();
const progress = useCourseStore((s) => s.progress);
const [tab, setTab] = useState("overview");

if (!ready) {
return (
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<DashboardSkeleton />
</div>
);
}
const onOverview = tab === "overview";
const onCourses = tab === "courses";
const onCredentials = tab === "credentials";
const onRewards = tab === "rewards";

const isLoading =
coursesLoading || rewardsLoading || credentialsLoading || recommendedLoading;
// Each section fetches only when its tab (or the overview summary) needs it.
// State is kept across tab switches and fetches are memoized (#519).
const { enrollments } = useEnrollments();
const { courses, loading: coursesLoading, error: coursesError } = useCourses({
enabled: onCourses,
});
const { recommended, loading: recommendedLoading, error: recommendedError } = useRecommendedCourses({
enabled: onOverview,
});
const { balances, history, loading: rewardsLoading, error: rewardsError } = useRewards({
enabled: onOverview || onRewards,
});
const { credentials, loading: credentialsLoading, error: credentialsError } = useCredentials({
enabled: onOverview || onCredentials,
});
const progress = useCourseStore((s) => s.progress);

if (isLoading) {
if (!ready) {
return (
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<DashboardSkeleton />
Expand Down Expand Up @@ -115,7 +131,7 @@ export default function DashboardPage() {
</p>
</div>

<Tabs defaultValue="overview" className="w-full">
<Tabs value={tab} onValueChange={setTab} className="w-full">
<TabsList className="mb-8">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="courses">Courses</TabsTrigger>
Expand Down Expand Up @@ -157,7 +173,9 @@ export default function DashboardPage() {
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Recommended for You
</h2>
{recommendedError ? (
{recommendedLoading ? (
<CourseGridSkeleton count={3} />
) : recommendedError ? (
<SectionError label="Recommendations" message={recommendedError} />
) : recommended.length === 0 ? (
<Card>
Expand Down Expand Up @@ -187,7 +205,9 @@ export default function DashboardPage() {
</Button>
</Link>
</div>
{coursesError ? (
{coursesLoading ? (
<CourseGridSkeleton count={3} />
) : coursesError ? (
<SectionError label="Courses" message={coursesError} />
) : enrolledCourses.length === 0 ? (
<EmptyState
Expand Down Expand Up @@ -227,7 +247,13 @@ export default function DashboardPage() {
<h2 className="text-lg font-semibold text-gray-900 mb-4">
My Credentials
</h2>
{credentialsError ? (
{credentialsLoading ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<CredentialCardSkeleton />
<CredentialCardSkeleton />
<CredentialCardSkeleton />
</div>
) : credentialsError ? (
<SectionError label="Credentials" message={credentialsError} />
) : credentials.length === 0 ? (
<EmptyState
Expand All @@ -248,7 +274,9 @@ export default function DashboardPage() {

<TabsContent value="rewards" className="focus-visible:outline-none focus-visible:ring-0">
<SectionErrorBoundary label="Rewards">
{rewardsError ? (
{rewardsLoading ? (
<RewardsSkeleton />
) : rewardsError ? (
<SectionError label="Rewards" message={rewardsError} />
) : (
<>
Expand Down
4 changes: 2 additions & 2 deletions src/components/wallet/balance-display.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,10 @@ export function BalanceDisplay({ className, compact = false }: BalanceDisplayPro
Token Balance
</div>
<Link
href="/transactions"
href="/rewards"
className="flex items-center gap-1 text-xs text-stellar-purple hover:underline dark:text-purple-400"
>
View Transactions <ExternalLink className="h-3 w-3" />
View Rewards <ExternalLink className="h-3 w-3" />
</Link>
</div>

Expand Down
89 changes: 75 additions & 14 deletions src/lib/hooks/use-courses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,15 +121,71 @@ function invalidateEnrollmentsCache(jwt?: string | null) {
enrollmentsCache.clear();
}

export function useCourses() {
/**
* Enrollment state only — no courses request. Use this when a screen already
* gets courses from another source (e.g. useInfiniteCourses) and only needs
* the enrolled badges (#517).
*/
export function useEnrollments() {
const jwt = useAuthStore((s) => s.jwt);
const { enrollments, setEnrollments, enroll: addEnrollment } = useCourseStore();
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);

const fetchEnrollments = useCallback(async () => {
if (!jwt) return;

const cached = getCachedEnrollments(jwt);
if (cached) {
setEnrollments(cached);
return;
}

const controller = new AbortController();
abortRef.current = controller;

try {
const data = await loadEnrollments(jwt, controller.signal);
setEnrollments(data);
setError(null);
} catch (err) {
if (isAbortError(err)) return;
setError(
err instanceof Error ? err.message : "Failed to fetch enrollments"
);
}
}, [jwt, setEnrollments]);

const enroll = useCallback(
async (courseId: string) => {
if (!jwt) throw new Error("Not authenticated");
const enrollment = await enrollInCourse(courseId, jwt);
addEnrollment(enrollment);
invalidateEnrollmentsCache(jwt);
return enrollment;
},
[jwt, addEnrollment]
);

useEffect(() => {
fetchEnrollments();
return () => abortRef.current?.abort();
}, [fetchEnrollments]);

return { enrollments, enroll, fetchEnrollments, error };
}

export function useCourses(options?: { enabled?: boolean }) {
const enabled = options?.enabled ?? true;
const jwt = useAuthStore((s) => s.jwt);
const { enrollments, setEnrollments, enroll: addEnrollment } = useCourseStore();
const [courses, setCourses] = useState<Course[]>(() => {
return getCachedCourses(coursesCacheKey()) ?? [];
});
const [loading, setLoading] = useState(() => {
return getCachedCourses(coursesCacheKey()) === null;
const [fetched, setFetched] = useState(() => {
return getCachedCourses(coursesCacheKey()) !== null;
});
const loading = enabled && !fetched;
const [error, setError] = useState<string | null>(null);
const coursesAbortRef = useRef<AbortController | null>(null);
const enrollmentsAbortRef = useRef<AbortController | null>(null);
Expand All @@ -140,24 +196,23 @@ export function useCourses() {
const cached = getCachedCourses(key);
if (cached) {
setCourses(cached);
setLoading(false);
setFetched(true);
setError(null);
return;
}

const controller = new AbortController();
coursesAbortRef.current = controller;

setLoading(true);
setError(null);
try {
const data = await loadCourses(params, controller.signal);
setCourses(data);
setFetched(true);
} catch (err) {
if (isAbortError(err)) return;
setFetched(true);
setError(err instanceof Error ? err.message : "Failed to fetch courses");
} finally {
setLoading(false);
}
},
[]
Expand Down Expand Up @@ -200,14 +255,16 @@ export function useCourses() {
);

useEffect(() => {
if (!enabled) return;
fetchCourses();
return () => coursesAbortRef.current?.abort();
}, [fetchCourses]);
}, [fetchCourses, enabled]);

useEffect(() => {
if (!enabled) return;
fetchEnrollments();
return () => enrollmentsAbortRef.current?.abort();
}, [fetchEnrollments]);
}, [fetchEnrollments, enabled]);

return {
courses,
Expand Down Expand Up @@ -390,15 +447,19 @@ export function useModule(courseId: string, moduleId: string) {
return { module, loading, error, complete };
}

export function useRecommendedCourses() {
export function useRecommendedCourses(options?: { enabled?: boolean }) {
const enabled = options?.enabled ?? true;
const jwt = useAuthStore((s) => s.jwt);
const [recommended, setRecommended] = useState<RecommendedCourse[]>([]);
const [loading, setLoading] = useState(true);
const [fetched, setFetched] = useState(false);
const loading = enabled && !fetched;
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!enabled) return;
if (fetched) return;
if (!jwt) {
setLoading(false);
setFetched(true);
return;
}
const controller = new AbortController();
Expand All @@ -410,9 +471,9 @@ export function useRecommendedCourses() {
const message = err instanceof Error ? err.message : "Failed to load recommendations";
setError(message);
})
.finally(() => setLoading(false));
.finally(() => setFetched(true));
return () => controller.abort();
}, [jwt]);
}, [jwt, enabled, fetched]);

return { recommended, loading, error };
}
15 changes: 9 additions & 6 deletions src/lib/hooks/use-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,22 @@ import {
import { isAbortError } from "@/lib/api/client";
import type { CredentialNFT } from "@/types/stellar";

export function useCredentials() {
export function useCredentials(options?: { enabled?: boolean }) {
const enabled = options?.enabled ?? true;
const jwt = useAuthStore((s) => s.jwt);
const [credentials, setCredentials] = useState<CredentialNFT[]>([]);
const [loading, setLoading] = useState(true);
const [fetched, setFetched] = useState(false);
const loading = enabled && !fetched;
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);

const fetchCredentials = useCallback(async () => {
if (!jwt) {
setLoading(false);
setFetched(true);
return;
}
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
setError(null);
try {
const data = await getCredentials(jwt, controller.signal);
Expand All @@ -36,7 +37,7 @@ export function useCredentials() {
const message = err instanceof Error ? err.message : "Failed to fetch credentials";
setError(message);
} finally {
setLoading(false);
setFetched(true);
}
}, [jwt]);

Expand All @@ -58,9 +59,11 @@ export function useCredentials() {
);

useEffect(() => {
if (!enabled) return;
if (fetched) return;
fetchCredentials();
return () => abortRef.current?.abort();
}, [fetchCredentials]);
}, [fetchCredentials, enabled, fetched]);

return { credentials, loading, error, mint, refetch: fetchCredentials };
}
Expand Down
Loading
Loading