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
9 changes: 6 additions & 3 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,13 @@
}

/* Smooth transitions for page content */
#page-content {
#page-content,
#auth-content {
animation: fadeIn 0.2s ease-out;
}

#page-content.no-animation {
#page-content.no-animation,
#auth-content.no-animation {
animation: none !important;
}

Expand Down Expand Up @@ -157,7 +159,8 @@ html, body {
scroll-behavior: auto !important;
}

#page-content {
#page-content,
#auth-content {
animation: none;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/auth/auth-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
<main className="flex-1">
{/* key={pathname} remounts the div on route change so the fadeIn
animation replays instead of only running on initial load. */}
<div id="page-content" key={pathname}>
<div id="auth-content" key={pathname}>
{children}
</div>
</main>
Expand Down
10 changes: 9 additions & 1 deletion src/lib/hooks/use-credentials.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { renderHook, waitFor } from "@testing-library/react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { useCredentials, useCredentialDetail, useVerifyCredential } from "./use-credentials";
import { useAuthStore } from "@/store/auth-store";
Expand Down Expand Up @@ -129,4 +129,12 @@ describe("useVerifyCredential", () => {
expect(result.current.error).toBeTruthy();
});
});

it("stops loading when credential id is empty", async () => {
const { verifyCredential } = await import("@/lib/api/credentials");
const { result } = renderHook(() => useVerifyCredential(""));

await waitFor(() => expect(result.current.loading).toBe(false));
expect(verifyCredential).not.toHaveBeenCalled();
});
});
23 changes: 10 additions & 13 deletions src/lib/hooks/use-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,20 +101,15 @@ export function useVerifyCredential(credentialId: string) {
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!credentialId) return;
if (!credentialId) {
setLoading(false);
setVerification(null);
setError(null);
return;
}
const controller = new AbortController();
setLoading(true);
verifyCredential(credentialId)
.then((data) => {
// #507 — Handle both `valid` and `isValid` property names from API
const valid = data.valid ?? (data as any).isValid ?? false;
setVerification({
valid,
metadata: data.metadata,
verifiedAt: data.verifiedAt,
});
})
.catch((err) =>
setError(null);
verifyCredential(credentialId, controller.signal)
.then(setVerification)
.catch((err) => {
Expand All @@ -123,7 +118,9 @@ export function useVerifyCredential(credentialId: string) {
err instanceof Error ? err.message : "Verification failed"
);
})
.finally(() => setLoading(false));
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [credentialId]);

Expand Down
24 changes: 24 additions & 0 deletions src/lib/hooks/use-notifications.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,28 @@ describe("useNotifications", () => {

expect(getNotifications).toHaveBeenCalledTimes(2);
});

it("refetches when the JWT changes without refetching on unrelated renders", async () => {
const { getNotifications } = await import("@/lib/api/notifications");
vi.mocked(getNotifications).mockResolvedValue([]);

useAuthStore.setState({ jwt: "old-jwt" });
const { result, rerender } = renderHook(() => useNotifications());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(getNotifications).toHaveBeenCalledTimes(1);
expect(getNotifications).toHaveBeenLastCalledWith(
"old-jwt",
expect.any(AbortSignal)
);

rerender();
expect(getNotifications).toHaveBeenCalledTimes(1);

useAuthStore.setState({ jwt: "new-jwt" });
await waitFor(() => expect(getNotifications).toHaveBeenCalledTimes(2));
expect(getNotifications).toHaveBeenLastCalledWith(
"new-jwt",
expect.any(AbortSignal)
);
});
});
5 changes: 3 additions & 2 deletions src/lib/hooks/use-notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export function useNotifications() {
if (!token) {
setNotifications([]);
setLoading(false);
setError(null);
return;
}
const controller = new AbortController();
Expand All @@ -39,7 +40,7 @@ export function useNotifications() {
console.error("Failed to fetch notifications:", e);
setError(e instanceof Error ? e.message : "Failed to load notifications");
} finally {
setLoading(false);
if (!controller.signal.aborted) setLoading(false);
}
}, []);

Expand Down Expand Up @@ -79,7 +80,7 @@ export function useNotifications() {
useEffect(() => {
refetch();
return () => abortRef.current?.abort();
}, [refetch]);
}, [refetch, jwt]);

const unreadCount = notifications.filter((n) => !n.read).length;

Expand Down
45 changes: 45 additions & 0 deletions src/lib/hooks/use-quiz.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useAuthStore } from "@/store/auth-store";
import { getQuiz } from "@/lib/api/quizzes";
import { useQuiz } from "./use-quiz";

vi.mock("@/lib/api/quizzes", () => ({
getQuiz: vi.fn(),
}));

vi.mock("@/lib/api/client", () => ({
isAbortError: vi.fn((error) => error?.name === "AbortError"),
}));

describe("useQuiz", () => {
beforeEach(() => {
vi.clearAllMocks();
useAuthStore.setState({ jwt: null });
});

it("clears a previous course error when fetching a new course", async () => {
let resolveSecondFetch!: (quiz: { id: string }) => void;
vi.mocked(getQuiz)
.mockRejectedValueOnce(new Error("Course A failed"))
.mockImplementationOnce(
() => new Promise((resolve) => { resolveSecondFetch = resolve; }) as never
);

const { result, rerender } = renderHook(
({ courseId }) => useQuiz(courseId),
{ initialProps: { courseId: "course-a" } }
);

await waitFor(() => expect(result.current.error).toBe("Course A failed"));

rerender({ courseId: "course-b" });
await waitFor(() => expect(result.current.loading).toBe(true));
expect(result.current.error).toBeNull();

await act(async () => {
resolveSecondFetch({ id: "quiz-b" });
});
await waitFor(() => expect(result.current.quiz).toEqual({ id: "quiz-b" }));
});
});
12 changes: 1 addition & 11 deletions src/lib/hooks/use-quiz.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useAuthStore } from "@/store/auth-store";
import { getQuiz } from "@/lib/api/quizzes";
import { isAbortError } from "@/lib/api/client";
Expand All @@ -21,14 +20,10 @@ export function useQuiz(courseId: string) {
setFetchTick((t) => t + 1);
}, []);

const fetchQuiz = useCallback(() => {
if (!courseId) return;
setLoading(true);
setError(null);
getQuiz(courseId, jwt ?? undefined)
useEffect(() => {
if (!courseId) {
setLoading(false);
setError(null);
return;
}
abortRef.current?.abort();
Expand All @@ -48,15 +43,10 @@ export function useQuiz(courseId: string) {
return () => controller.abort();
}, [courseId, jwt, fetchTick]);

useEffect(() => {
fetchQuiz();
}, [fetchQuiz]);

return {
quiz,
loading,
error,
refetch,
refetch: fetchQuiz,
};
}