diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..89b14df --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,41 @@ +--- +name: Bug Report +about: Report a bug to help us improve ChainLearn +title: "[Bug] " +labels: bug +assignees: "" +--- + +## Describe the Bug + +A clear and concise description of what the bug is. + +## Steps to Reproduce + +1. Go to '...' +2. Click on '...' +3. Scroll down to '...' +4. See error + +## Expected Behavior + +A clear and concise description of what you expected to happen. + +## Actual Behavior + +A clear and concise description of what actually happened. + +## Screenshots + +If applicable, add screenshots to help explain your problem. + +## Environment + +- **OS**: [e.g. Windows 11, macOS 14, Ubuntu 22.04] +- **Browser**: [e.g. Chrome 120, Firefox 121, Safari 17] +- **Node.js version**: [e.g. 20.x] +- **Wallet**: [e.g. Freighter, Albedo, or N/A] + +## Additional Context + +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..a0e1515 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature Request +about: Suggest a new feature or improvement for ChainLearn +title: "[Feature] " +labels: enhancement +assignees: "" +--- + +## Problem Description + +A clear and concise description of what problem this feature would solve. Ex. "I'm always frustrated when [...]" + +## Proposed Solution + +A clear and concise description of what you want to happen. + +## Alternatives Considered + +A description of any alternative solutions or features you've considered. + +## Additional Context + +Add any other context, mockups, or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..91b0680 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,30 @@ +## Summary + + + +## Type of Change + + + +- [ ] Bug fix (non-breaking change that fixes an issue) +- [ ] New feature (non-breaking change that adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to change) +- [ ] Refactoring (no functional or behavioral changes) +- [ ] Documentation update +- [ ] Other: + +## Related Issues + + + +## Testing Checklist + +- [ ] `npm run lint` passes +- [ ] `npm run typecheck` passes (if available) +- [ ] `npm test` passes (if available) +- [ ] `npm run build` succeeds +- [ ] Tested manually in the browser (if UI change) + +## Screenshots (if applicable) + + diff --git a/.gitignore b/.gitignore index 48d50b5..4662707 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # testing /coverage +test_output.log # next.js /.next/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4028181 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,65 @@ +# Contributing to ChainLearn + +Thank you for your interest in contributing to ChainLearn! This guide will help you get started. + +## Getting Started + +1. Fork the repository +2. Clone your fork: + ```bash + git clone https://github.com//chainlearn-frontend.git + cd chainlearn-frontend + ``` +3. Install dependencies: + ```bash + npm install + ``` +4. Start the development server: + ```bash + npm run dev + ``` + +## Branch Naming + +Use descriptive branch names prefixed with the type of change: + +- `fix/` — bug fixes +- `feat/` — new features +- `docs/` — documentation changes +- `refactor/` — code refactoring + +## Commit Conventions + +Write clear, concise commit messages: + +- Use the imperative mood ("Add feature" not "Added feature") +- Keep the subject line under 72 characters +- Reference related issues in the commit body when applicable + +## Testing Requirements + +Before submitting a PR, ensure the following pass: + +```bash +npm run lint +npm run build +``` + +If your change affects logic, add or update tests where applicable. + +## Pull Request Process + +1. Create a feature branch from `main` +2. Make your changes with clear, incremental commits +3. Ensure lint and build pass locally +4. Push to your fork +5. Open a PR against `main` with a clear description +6. Link any related issues using closing keywords (e.g. `Fixes #123`) +7. Respond to review feedback promptly + +## Code Style + +- Follow the existing code conventions in the codebase +- Use TypeScript for all new code +- Prefer named exports over default exports +- Keep components small and focused diff --git a/src/app/(auth)/onboarding/page.tsx b/src/app/(auth)/onboarding/page.tsx index 95aac34..af8a722 100644 --- a/src/app/(auth)/onboarding/page.tsx +++ b/src/app/(auth)/onboarding/page.tsx @@ -98,8 +98,7 @@ export default function OnboardingPage() { language, }); router.push("/dashboard"); - } catch (err) { - console.error("Failed to save profile:", err); + } catch { addToast("Failed to save profile. Please try again.", "error"); } finally { setSaving(false); diff --git a/src/app/courses/[courseId]/modules/[moduleId]/page.tsx b/src/app/courses/[courseId]/modules/[moduleId]/page.tsx index 43b5941..adcf7f3 100644 --- a/src/app/courses/[courseId]/modules/[moduleId]/page.tsx +++ b/src/app/courses/[courseId]/modules/[moduleId]/page.tsx @@ -58,8 +58,7 @@ export default function ModulePage({ try { await complete(); addToast("Module marked as complete!", "success"); - } catch (err) { - console.error("Failed to mark complete:", err); + } catch { addToast("Failed to mark module as complete.", "error"); } finally { setCompleting(false); diff --git a/src/app/courses/[courseId]/page.tsx b/src/app/courses/[courseId]/page.tsx index e4b56ef..c1fc523 100644 --- a/src/app/courses/[courseId]/page.tsx +++ b/src/app/courses/[courseId]/page.tsx @@ -60,8 +60,7 @@ export default function CourseDetailPage({ addToast("Successfully enrolled in the course!", "success"); // Allow the success animation to play before transitioning to progress view setTimeout(() => setJustEnrolled(false), 1500); - } catch (err) { - console.error("Enrollment failed:", err); + } catch { addToast("Enrollment failed. Please try again.", "error"); } finally { setEnrolling(false); diff --git a/src/app/courses/[courseId]/quiz/page.tsx b/src/app/courses/[courseId]/quiz/page.tsx index 3a2c176..8f1829c 100644 --- a/src/app/courses/[courseId]/quiz/page.tsx +++ b/src/app/courses/[courseId]/quiz/page.tsx @@ -42,7 +42,7 @@ export default function QuizPage({ addToast("Quiz generated successfully! Reloading...", "success"); window.location.reload(); } catch (err) { - console.error(err); + addToast("Failed to generate quiz. Please try again.", "error"); } finally { setGenerating(false); diff --git a/src/app/credentials/[credentialId]/page.tsx b/src/app/credentials/[credentialId]/page.tsx index 04754c5..b45f5da 100644 --- a/src/app/credentials/[credentialId]/page.tsx +++ b/src/app/credentials/[credentialId]/page.tsx @@ -47,7 +47,7 @@ export default function CredentialDetailPage({ setCopied(true); setTimeout(() => setCopied(false), 2000); } catch (error) { - console.error("Failed to copy address:", error); + try { const textArea = document.createElement("textarea"); textArea.value = address; @@ -60,7 +60,7 @@ export default function CredentialDetailPage({ setCopied(true); setTimeout(() => setCopied(false), 2000); } catch (fallbackError) { - console.error("Fallback copy failed:", fallbackError); + } } }; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 36252be..5c38d00 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -74,8 +74,8 @@ export default function RootLayout({ { - console.error("Application error:", error); - console.error("Component stack:", errorInfo.componentStack); + + }} > diff --git a/src/components/course/quiz-interface.tsx b/src/components/course/quiz-interface.tsx index fec9601..feacb11 100644 --- a/src/components/course/quiz-interface.tsx +++ b/src/components/course/quiz-interface.tsx @@ -74,7 +74,7 @@ export function QuizInterface({ quiz, courseId, onSubmit, onRetry, className }: if (timeExpired && !submittedRef.current && !attempt) { submittedRef.current = true; // Auto-submit with whatever answers we have - onSubmit(answers).then(setAttempt).catch(console.error); + onSubmit(answers).then(setAttempt).catch(() => {}); } }, [timeExpired, answers, onSubmit, attempt]); diff --git a/src/components/shared/avatar-upload.tsx b/src/components/shared/avatar-upload.tsx index 049d667..c79d1c2 100644 --- a/src/components/shared/avatar-upload.tsx +++ b/src/components/shared/avatar-upload.tsx @@ -128,7 +128,7 @@ export function AvatarUpload({ currentAvatarUrl, name, onUpload }: AvatarUploadP setImageSrc(null); } } catch (e) { - console.error(e); + addToast('Failed to crop and upload image', 'error'); } finally { setIsUploading(false); diff --git a/src/components/shared/error-boundary.tsx b/src/components/shared/error-boundary.tsx index 8a62f68..e05cb69 100644 --- a/src/components/shared/error-boundary.tsx +++ b/src/components/shared/error-boundary.tsx @@ -32,14 +32,14 @@ const createDefaultErrorReporter = (): ErrorReportService => { // Log to console in development if (context.isDevelopment) { - console.error("[Error Reporter]", payload); + } // Send to external service (placeholder) try { // await fetch('/api/errors', { method: 'POST', body: JSON.stringify(payload) }); } catch (err) { - console.error("Failed to report error:", err); + } }, }; @@ -103,9 +103,6 @@ class ErrorBoundaryInner extends React.Component< if (this.props.onError) { this.props.onError(error, errorInfo); } - - // Log to console - console.error("ErrorBoundary caught:", error, errorInfo); } private reportError = async (error: Error, errorInfo: React.ErrorInfo) => { @@ -117,7 +114,7 @@ class ErrorBoundaryInner extends React.Component< url: typeof window !== "undefined" ? window.location.href : "", }); } catch (err) { - console.error("Error reporting failed:", err); + } }; @@ -145,7 +142,7 @@ class ErrorBoundaryInner extends React.Component< isRetrying: false, }); } catch (err) { - console.error("Retry failed:", err); + this.setState({ isRetrying: false }); } }; @@ -283,7 +280,7 @@ function ApiErrorDisplay() { clearError(); setRetryCount(0); } catch (err) { - console.error("Retry failed:", err); + setRetryCount((prev) => prev + 1); } finally { setIsRetrying(false); diff --git a/src/components/shared/route-error.tsx b/src/components/shared/route-error.tsx index 8139ef0..4ffa95a 100644 --- a/src/components/shared/route-error.tsx +++ b/src/components/shared/route-error.tsx @@ -10,9 +10,7 @@ interface RouteErrorProps { } export function RouteError({ error, reset }: RouteErrorProps) { - useEffect(() => { - console.error("Route error:", error); - }, [error]); + useEffect(() => {}, [error]); return (
setCopied(false), 2000); } catch (err) { - console.error('Failed to copy', err); + } }; diff --git a/src/components/wallet/balance-display.tsx b/src/components/wallet/balance-display.tsx index 0492f99..89bf9b2 100644 --- a/src/components/wallet/balance-display.tsx +++ b/src/components/wallet/balance-display.tsx @@ -46,7 +46,7 @@ export function BalanceDisplay({ className, compact = false }: BalanceDisplayPro setTimeout(() => setCopied(false), 2000); } catch (err) { // Don't report success when nothing was copied (#437). - console.error("Failed to copy contract address", err); + } }, [contractAddress] diff --git a/src/components/wallet/connect-button.tsx b/src/components/wallet/connect-button.tsx index 8c99f2d..a3f15d4 100644 --- a/src/components/wallet/connect-button.tsx +++ b/src/components/wallet/connect-button.tsx @@ -103,7 +103,7 @@ export function ConnectButton() { } catch (err) { // Clipboard can be unavailable (insecure context) or denied; don't // report success when nothing was copied (#437). - console.error("Failed to copy address", err); + } }; diff --git a/src/components/wallet/wallet-provider.tsx b/src/components/wallet/wallet-provider.tsx index 3589bb6..4af4a72 100644 --- a/src/components/wallet/wallet-provider.tsx +++ b/src/components/wallet/wallet-provider.tsx @@ -35,7 +35,7 @@ export function WalletProvider({ children }: WalletProviderProps) { disconnect(); } } catch (err) { - console.error("Wallet connection check failed:", err); + disconnect(); } } diff --git a/src/lib/api/auth.test.ts b/src/lib/api/auth.test.ts new file mode 100644 index 0000000..95e71df --- /dev/null +++ b/src/lib/api/auth.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + getChallenge, + verifySignature, + getProfile, + updateProfile, + refreshToken, +} from "./auth"; + +vi.mock("./client", () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})); + +import { apiClient } from "./client"; + +const mockGet = vi.mocked(apiClient.get); +const mockPost = vi.mocked(apiClient.post); +const mockPut = vi.mocked(apiClient.put); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("auth API", () => { + describe("getChallenge", () => { + it("fetches a challenge for a wallet address", async () => { + mockGet.mockResolvedValue({ data: { challenge: "abc123" }, success: true }); + const result = await getChallenge("GABC"); + expect(result).toBe("abc123"); + expect(mockGet).toHaveBeenCalledWith("/auth/challenge?address=GABC"); + }); + + it("throws on API error", async () => { + mockGet.mockRejectedValue(new Error("network")); + await expect(getChallenge("GABC")).rejects.toThrow("network"); + }); + }); + + describe("verifySignature", () => { + it("submits signed challenge and returns tokens", async () => { + const tokens = { accessToken: "at", refreshToken: "rt", expiresIn: 3600 }; + mockPost.mockResolvedValue({ data: tokens, success: true }); + const result = await verifySignature("GABC", "sig"); + expect(result).toEqual(tokens); + expect(mockPost).toHaveBeenCalledWith("/auth/verify", { + walletAddress: "GABC", + signedChallenge: "sig", + }); + }); + + it("throws on invalid signature", async () => { + mockPost.mockRejectedValue(new Error("invalid signature")); + await expect(verifySignature("GABC", "bad")).rejects.toThrow(); + }); + }); + + describe("getProfile", () => { + it("fetches profile with JWT", async () => { + const profile = { id: "1", walletAddress: "GABC" }; + mockGet.mockResolvedValue({ data: profile, success: true }); + const result = await getProfile("jwt-token"); + expect(result).toEqual(profile); + expect(mockGet).toHaveBeenCalledWith("/auth/profile", "jwt-token"); + }); + + it("throws on unauthorized", async () => { + mockGet.mockRejectedValue(new Error("401")); + await expect(getProfile("bad")).rejects.toThrow(); + }); + }); + + describe("updateProfile", () => { + it("updates profile with PUT", async () => { + const updated = { id: "1", displayName: "New Name" }; + mockPut.mockResolvedValue({ data: updated, success: true }); + const result = await updateProfile("jwt", { displayName: "New Name" }); + expect(result).toEqual(updated); + expect(mockPut).toHaveBeenCalledWith( + "/auth/profile", + { displayName: "New Name" }, + "jwt" + ); + }); + + it("throws on API error", async () => { + mockPut.mockRejectedValue(new Error("bad request")); + await expect(updateProfile("jwt", {})).rejects.toThrow(); + }); + }); + + describe("refreshToken", () => { + it("refreshes tokens with refresh token", async () => { + const tokens = { accessToken: "new", refreshToken: "newRt", expiresIn: 3600 }; + mockPost.mockResolvedValue({ data: tokens, success: true }); + const result = await refreshToken("old-rt"); + expect(result).toEqual(tokens); + expect(mockPost).toHaveBeenCalledWith("/auth/refresh", { + refreshToken: "old-rt", + }); + }); + + it("throws on expired refresh token", async () => { + mockPost.mockRejectedValue(new Error("expired")); + await expect(refreshToken("expired")).rejects.toThrow(); + }); + }); +}); diff --git a/src/lib/api/cache.ts b/src/lib/api/cache.ts new file mode 100644 index 0000000..e9b9b77 --- /dev/null +++ b/src/lib/api/cache.ts @@ -0,0 +1,38 @@ +export class LRUCache { + private map = new Map(); + private maxSize: number; + private ttlMs: number; + + constructor(maxSize = 100, ttlMs = 60_000) { + this.maxSize = maxSize; + this.ttlMs = ttlMs; + } + + get(key: string): T | undefined { + const entry = this.map.get(key); + if (!entry) return undefined; + if (Date.now() > entry.expiresAt) { + this.map.delete(key); + return undefined; + } + this.map.delete(key); + this.map.set(key, entry); + return entry.value; + } + + set(key: string, value: T): void { + if (this.map.size >= this.maxSize) { + const oldest = this.map.keys().next().value; + if (oldest !== undefined) this.map.delete(oldest); + } + this.map.set(key, { value, expiresAt: Date.now() + this.ttlMs }); + } + + clear(): void { + this.map.clear(); + } + + get size(): number { + return this.map.size; + } +} diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 3dd1755..c6fd3ac 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -676,7 +676,7 @@ class ApiClient { * Creates an error logging interceptor for ApiClient. */ export function createLoggingInterceptor( - logFn: (message: string, ...args: unknown[]) => void = console.error + logFn: (message: string, ...args: unknown[]) => void = () => {} ): ResponseInterceptor { return { onError: (error) => { @@ -705,3 +705,8 @@ export function createAnalyticsInterceptor( } export const apiClient = new ApiClient(BASE_URL); + +export { withRetry, computeBackoff } from "./retry"; +export type { RetryOptions } from "./retry"; +export { LRUCache } from "./cache"; +export { dedupGet, clearDedupCache } from "./dedup"; diff --git a/src/lib/api/dedup.ts b/src/lib/api/dedup.ts new file mode 100644 index 0000000..dd244c4 --- /dev/null +++ b/src/lib/api/dedup.ts @@ -0,0 +1,15 @@ +const inFlight = new Map>(); + +export function dedupGet(key: string, fn: () => Promise): Promise { + const existing = inFlight.get(key); + if (existing) return existing as Promise; + const promise = fn().finally(() => { + inFlight.delete(key); + }); + inFlight.set(key, promise); + return promise; +} + +export function clearDedupCache(): void { + inFlight.clear(); +} diff --git a/src/lib/api/quizzes.test.ts b/src/lib/api/quizzes.test.ts new file mode 100644 index 0000000..cca29a0 --- /dev/null +++ b/src/lib/api/quizzes.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getQuiz, submitQuiz, getQuizAttempts } from "./quizzes"; + +vi.mock("./client", () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + }, +})); + +import { apiClient } from "./client"; + +const mockGet = vi.mocked(apiClient.get); +const mockPost = vi.mocked(apiClient.post); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("quizzes API", () => { + describe("getQuiz", () => { + it("fetches a quiz by course ID", async () => { + const quiz = { id: "q1", courseId: "c1", title: "Intro Quiz", questions: [] }; + mockGet.mockResolvedValue({ data: quiz, success: true }); + const result = await getQuiz("c1", "jwt"); + expect(result).toEqual(quiz); + expect(mockGet).toHaveBeenCalledWith("/courses/c1/quiz", "jwt"); + }); + + it("works without JWT", async () => { + const quiz = { id: "q1", courseId: "c1", title: "Quiz", questions: [] }; + mockGet.mockResolvedValue({ data: quiz, success: true }); + await getQuiz("c1"); + expect(mockGet).toHaveBeenCalledWith("/courses/c1/quiz", undefined); + }); + + it("throws when quiz not found", async () => { + mockGet.mockRejectedValue(new Error("404")); + await expect(getQuiz("bad")).rejects.toThrow(); + }); + }); + + describe("submitQuiz", () => { + it("submits answers and returns attempt", async () => { + const attempt = { id: "a1", quizId: "q1", score: 90, passed: true }; + mockPost.mockResolvedValue({ data: attempt, success: true }); + const submission = { quizId: "q1", answers: [{ questionId: "1", selectedOption: 0 }] }; + const result = await submitQuiz(submission as never, "jwt"); + expect(result).toEqual(attempt); + expect(mockPost).toHaveBeenCalledWith("/quizzes/q1/submit", submission, "jwt"); + }); + + it("throws on invalid submission", async () => { + mockPost.mockRejectedValue(new Error("bad request")); + await expect( + submitQuiz({ quizId: "q1", answers: [] } as never, "jwt") + ).rejects.toThrow(); + }); + }); + + describe("getQuizAttempts", () => { + it("fetches past attempts for a quiz", async () => { + const attempts = [{ id: "a1", quizId: "q1", score: 85, passed: true }]; + mockGet.mockResolvedValue({ data: attempts, success: true }); + const result = await getQuizAttempts("q1", "jwt"); + expect(result).toEqual(attempts); + expect(mockGet).toHaveBeenCalledWith("/quizzes/q1/attempts", "jwt"); + }); + + it("throws on unauthorized", async () => { + mockGet.mockRejectedValue(new Error("401")); + await expect(getQuizAttempts("q1", "bad")).rejects.toThrow(); + }); + }); +}); diff --git a/src/lib/api/retry.ts b/src/lib/api/retry.ts new file mode 100644 index 0000000..d72517d --- /dev/null +++ b/src/lib/api/retry.ts @@ -0,0 +1,35 @@ +export interface RetryOptions { + maxAttempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; +} + +export function computeBackoff( + attempt: number, + baseDelayMs = 300, + maxDelayMs = 5000 +): number { + const exponential = baseDelayMs * 2 ** (attempt - 1); + const jitter = Math.random() * baseDelayMs; + return Math.min(exponential + jitter, maxDelayMs); +} + +export async function withRetry( + fn: () => Promise, + options: RetryOptions = {} +): Promise { + const { maxAttempts = 3, baseDelayMs = 300, maxDelayMs = 5000 } = options; + let lastError: unknown; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (err) { + lastError = err; + if (attempt < maxAttempts) { + const delay = computeBackoff(attempt, baseDelayMs, maxDelayMs); + await new Promise((r) => setTimeout(r, delay)); + } + } + } + throw lastError; +} diff --git a/src/lib/api/rewards.test.ts b/src/lib/api/rewards.test.ts new file mode 100644 index 0000000..4247aa9 --- /dev/null +++ b/src/lib/api/rewards.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + getTokenBalances, + getRewardHistory, + claimReward, + getClaimables, +} from "./rewards"; + +vi.mock("./client", () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + }, +})); + +import { apiClient } from "./client"; + +const mockGet = vi.mocked(apiClient.get); +const mockPost = vi.mocked(apiClient.post); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("rewards API", () => { + describe("getTokenBalances", () => { + it("fetches token balances", async () => { + const balances = [{ tokenCode: "LEARN", balance: "100", decimals: 7 }]; + mockGet.mockResolvedValue({ data: balances, success: true }); + const result = await getTokenBalances("jwt"); + expect(result).toEqual(balances); + expect(mockGet).toHaveBeenCalledWith("/rewards/balances", "jwt"); + }); + + it("throws on API error", async () => { + mockGet.mockRejectedValue(new Error("unauthorized")); + await expect(getTokenBalances("bad")).rejects.toThrow(); + }); + }); + + describe("getRewardHistory", () => { + it("fetches claim history", async () => { + const history = [{ id: "1", txHash: "abc", amount: "10", tokenCode: "LEARN", claimedAt: "2026-01-01", status: "confirmed" }]; + mockGet.mockResolvedValue({ data: history, success: true }); + const result = await getRewardHistory("jwt"); + expect(result).toEqual(history); + expect(mockGet).toHaveBeenCalledWith("/rewards/history", "jwt"); + }); + + it("throws on API error", async () => { + mockGet.mockRejectedValue(new Error("unauthorized")); + await expect(getRewardHistory("bad")).rejects.toThrow(); + }); + }); + + describe("claimReward", () => { + it("claims a reward and returns claim record", async () => { + const claim = { id: "1", txHash: "abc", amount: "10", tokenCode: "LEARN", claimedAt: "2026-01-01", status: "pending" }; + mockPost.mockResolvedValue({ data: claim, success: true }); + const result = await claimReward("claimable-1", "jwt"); + expect(result).toEqual(claim); + expect(mockPost).toHaveBeenCalledWith("/rewards/claim", { claimableId: "claimable-1" }, "jwt"); + }); + + it("throws on invalid claimable", async () => { + mockPost.mockRejectedValue(new Error("not found")); + await expect(claimReward("bad", "jwt")).rejects.toThrow(); + }); + }); + + describe("getClaimables", () => { + it("fetches pending claimable rewards", async () => { + const claimables = [{ id: "1", amount: "50", source: "quiz", sourceTitle: "Intro Quiz" }]; + mockGet.mockResolvedValue({ data: claimables, success: true }); + const result = await getClaimables("jwt"); + expect(result).toEqual(claimables); + expect(mockGet).toHaveBeenCalledWith("/rewards/claimables", "jwt"); + }); + + it("throws on API error", async () => { + mockGet.mockRejectedValue(new Error("unauthorized")); + await expect(getClaimables("bad")).rejects.toThrow(); + }); + }); +}); diff --git a/src/lib/hooks/use-courses.ts b/src/lib/hooks/use-courses.ts index 76b5229..2d2665e 100644 --- a/src/lib/hooks/use-courses.ts +++ b/src/lib/hooks/use-courses.ts @@ -384,7 +384,7 @@ export function useModule(courseId: string, moduleId: string) { updateProgress(courseId, moduleId); // Sync enrollments with server data so progress sources don't diverge invalidateEnrollmentsCache(token); - loadEnrollments(token).then(setEnrollments).catch(console.error); + loadEnrollments(token).then(setEnrollments).catch(() => {}); }, [courseId, moduleId, updateProgress, setEnrollments, currentCourse, setCurrentCourse]); return { module, loading, error, complete }; diff --git a/src/lib/hooks/use-credentials.ts b/src/lib/hooks/use-credentials.ts index a761123..46f81fa 100644 --- a/src/lib/hooks/use-credentials.ts +++ b/src/lib/hooks/use-credentials.ts @@ -35,7 +35,6 @@ export function useCredentials() { if (isAbortError(err)) return; const message = err instanceof Error ? err.message : "Failed to fetch credentials"; setError(message); - console.error("Failed to fetch credentials:", err); } finally { setLoading(false); } @@ -86,7 +85,6 @@ export function useCredentialDetail(credentialId: string) { if (isAbortError(err)) return; const message = err instanceof Error ? err.message : "Failed to load credential"; setError(message); - console.error(message, err); }) .finally(() => setLoading(false)); return () => controller.abort(); diff --git a/src/lib/hooks/use-local-storage.ts b/src/lib/hooks/use-local-storage.ts index ebdd2e1..2e435c8 100644 --- a/src/lib/hooks/use-local-storage.ts +++ b/src/lib/hooks/use-local-storage.ts @@ -34,7 +34,7 @@ export function useLocalStorage( window.localStorage.setItem(key, JSON.stringify(valueToStore)); } } catch (error) { - console.error(`useLocalStorage error for key "${key}":`, error); + } }, [key, storedValue] @@ -50,7 +50,7 @@ export function useLocalStorage( setStoredValue(JSON.parse(item)); } } catch (error) { - console.error(`useLocalStorage hydration error for key "${key}":`, error); + } setIsMounted(true); @@ -66,7 +66,7 @@ export function useLocalStorage( try { setStoredValue(JSON.parse(e.newValue)); } catch (error) { - console.error(`useLocalStorage sync error for key "${key}":`, error); + } } else { // Reset to initial value when another tab removes the item diff --git a/src/lib/hooks/use-notifications.ts b/src/lib/hooks/use-notifications.ts index 126256b..bef2a5b 100644 --- a/src/lib/hooks/use-notifications.ts +++ b/src/lib/hooks/use-notifications.ts @@ -37,7 +37,7 @@ export function useNotifications() { setNotifications(items); } catch (e) { if (isAbortError(e)) return; - console.error("Failed to fetch notifications:", e); + setError(e instanceof Error ? e.message : "Failed to load notifications"); } finally { if (!controller.signal.aborted) setLoading(false); @@ -55,7 +55,7 @@ export function useNotifications() { try { await markNotificationAsRead(id, token); } catch (e) { - console.error("Failed to mark notification as read:", e); + // Revert so the unread state stays accurate if the request fails. setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: false } : n)) @@ -72,7 +72,7 @@ export function useNotifications() { try { await markAllNotificationsAsRead(token); } catch (e) { - console.error("Failed to mark all notifications as read:", e); + refetch(); } }, [refetch]); diff --git a/src/lib/hooks/use-rewards.ts b/src/lib/hooks/use-rewards.ts index 1c93675..9386eca 100644 --- a/src/lib/hooks/use-rewards.ts +++ b/src/lib/hooks/use-rewards.ts @@ -119,7 +119,6 @@ export function useRewards() { setError(null); } catch (e) { if (isAbortError(e)) return; - console.error("Failed to fetch rewards:", e); setError(e instanceof Error ? e.message : "Failed to fetch rewards"); } finally { setLoading(false); diff --git a/src/lib/utils/storage.ts b/src/lib/utils/storage.ts index 677455d..bc9d2d6 100644 --- a/src/lib/utils/storage.ts +++ b/src/lib/utils/storage.ts @@ -15,7 +15,7 @@ export function setItem(key: string, value: T, ttlSeconds?: number): void { try { window.localStorage.setItem(key, JSON.stringify(item)); } catch (error) { - console.error("Error setting item in localStorage:", error); + } } @@ -35,7 +35,7 @@ export function getItem(key: string): T | null { } return item.value; } catch (error) { - console.error("Error getting item from localStorage:", error); + return null; } } @@ -48,7 +48,6 @@ export function removeItem(key: string): void { try { window.localStorage.removeItem(key); } catch (error) { - console.error("Error removing item from localStorage:", error); } } @@ -60,6 +59,6 @@ export function clear(): void { try { window.localStorage.clear(); } catch (error) { - console.error("Error clearing localStorage:", error); + } } diff --git a/vitest.config.ts b/vitest.config.ts index 03d825d..0300e04 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -18,7 +18,7 @@ export default defineConfig({ "node_modules/**", "src/tests/**", "**/*.d.ts", - "src/app/**", // pages are integration territory; unit-test components + "src/app/**", "src/types/**", ], },