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
18 changes: 18 additions & 0 deletions src/app/(auth)/connect/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { useRouter, useSearchParams } 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, Globe } from "lucide-react";
import { isFreighterInstalled, getFreighterNetwork } from "@/lib/stellar/wallet";
import { useEffect, useState } from "react";
import { Wallet, Shield, Loader2, AlertCircle } from "lucide-react";
import { isFreighterInstalled } from "@/lib/stellar/wallet";
import { useEffect, useState, Suspense } from "react";
Expand Down Expand Up @@ -35,6 +38,13 @@ export default function ConnectPage() {
const [freighterInstalled, setFreighterInstalled] = useState<boolean | null>(
null
);
// #500 — Track actual Freighter network to show correct badge
const [freighterNetwork, setFreighterNetwork] = useState<string | null>(null);

useEffect(() => {
isFreighterInstalled().then(setFreighterInstalled);
// #500 — Get actual network from Freighter
getFreighterNetwork().then(setFreighterNetwork).catch(() => setFreighterNetwork(null));
const connectingFromPage = useRef(false);

useEffect(() => {
Expand Down Expand Up @@ -213,6 +223,14 @@ export default function ConnectPage() {
)}
</Button>

{/* #500 — Show actual Freighter network */}
{freighterNetwork && (
<div className="flex items-center justify-center gap-2 text-xs text-gray-500">
<Globe className="h-3 w-3" />
<span>Network: <span className="font-semibold capitalize">{freighterNetwork}</span></span>
</div>
)}

<div className="flex items-center gap-2 rounded-lg bg-gray-50 p-3">
<Shield className="h-4 w-4 text-gray-400" />
<p className="text-xs text-gray-500">
Expand Down
19 changes: 19 additions & 0 deletions src/app/courses/[courseId]/modules/[moduleId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { isTextContent, isVideoContent, isInteractiveContent } from "@/types/course";

export default function ModulePage({
params,
Expand Down Expand Up @@ -109,6 +110,24 @@ export default function ModulePage({
<p className="text-gray-500 mb-6 dark:text-gray-400">{module.description}</p>

<div className="prose prose-gray max-w-none dark:prose-invert">
{/* #498 — Render module content with proper type handling */}
{isTextContent(module.content) && (
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(module.content) }} />
)}
{isVideoContent(module.content) && (
<div className="aspect-video rounded-lg overflow-hidden">
<iframe
src={module.content.url}
title={module.title}
className="w-full h-full"
allowFullScreen
/>
</div>
)}
{isInteractiveContent(module.content) && (
<div className="rounded-lg border border-gray-200 p-4">
<p className="text-sm text-gray-500">Interactive content</p>
</div>
{content.type === "video" ? (
<div className="aspect-video w-full bg-black rounded-lg overflow-hidden flex items-center justify-center mb-6">
{content.url ? (
Expand Down
71 changes: 70 additions & 1 deletion src/components/course/quiz-interface.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"use client";

import { useState } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { cn } from "@/lib/utils/cn";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CheckCircle, XCircle, ChevronRight, Trophy, Clock, AlertTriangle } from "lucide-react";
import type { Quiz, QuizAttempt, QuizQuestion } from "@/types/quiz";
import { EmptyState } from "@/components/shared/empty-state";
import { ResultsScreen } from "@/components/course/results-screen";
import {
Expand All @@ -27,6 +29,11 @@ export function QuizInterface({ quiz, courseId, onSubmit, onRetry, className }:
const [answers, setAnswers] = useState<Record<string, string>>({});
const [attempt, setAttempt] = useState<QuizAttempt | null>(null);
const [submitting, setSubmitting] = useState(false);
// #499 — Quiz time limit enforcement
const [timeRemaining, setTimeRemaining] = useState<number | null>(null);
const [timeExpired, setTimeExpired] = useState(false);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const submittedRef = useRef(false);
const [submitError, setSubmitError] = useState<string | null>(null);

const questions = quiz.questions;
Expand All @@ -36,6 +43,48 @@ export function QuizInterface({ quiz, courseId, onSubmit, onRetry, className }:
const allAnswered = answeredCount === questions.length;
const progressPercent = Math.round(((currentIndex + 1) / questions.length) * 100);

// #499 — Start timer when quiz has a time limit
useEffect(() => {
if (!quiz.timeLimitMinutes || attempt) return;

const totalSeconds = quiz.timeLimitMinutes * 60;
setTimeRemaining(totalSeconds);

timerRef.current = setInterval(() => {
setTimeRemaining((prev) => {
if (prev === null || prev <= 1) {
// Time expired - auto-submit
if (!submittedRef.current) {
submittedRef.current = true;
setTimeExpired(true);
}
return 0;
}
return prev - 1;
});
}, 1000);

return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [quiz.timeLimitMinutes, attempt]);

// #499 — Auto-submit when time expires
useEffect(() => {
if (timeExpired && !submittedRef.current && !attempt) {
submittedRef.current = true;
// Auto-submit with whatever answers we have
onSubmit(answers).then(setAttempt).catch(console.error);
}
}, [timeExpired, answers, onSubmit, attempt]);

// #499 — Format time as MM:SS
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};

if (!question) {
return (
<EmptyState
Expand Down Expand Up @@ -90,6 +139,26 @@ export function QuizInterface({ quiz, courseId, onSubmit, onRetry, className }:
// ── Question view ─────────────────────────────────────────────────────────
return (
<Card className={cn("max-w-2xl mx-auto", className)}>
<CardHeader>
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500">
Question {currentIndex + 1} of {quiz.questions.length}
</span>
<div className="flex items-center gap-2">
{/* #499 — Show time remaining when quiz has time limit */}
{timeRemaining !== null && !attempt && (
<span
className={cn(
"flex items-center gap-1 text-sm font-medium",
timeRemaining <= 60 ? "text-red-600 animate-pulse" : "text-gray-600"
)}
>
<Clock className="h-4 w-4" />
{formatTime(timeRemaining)}
</span>
)}
<span className="text-sm font-medium text-primary-600">
{quiz.title}
<CardHeader className="pb-3">
{/* Title row */}
<div className="flex items-center justify-between">
Expand Down
17 changes: 17 additions & 0 deletions src/lib/api/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,23 @@ export async function getCredential(

/**
* Verify a credential publicly (no auth required).
* #507 — Fix property name: API returns data wrapped in ApiResponse, so we
* need to extract .data from the response envelope.
*/
export async function verifyCredential(
credentialId: string
): Promise<{
valid: boolean;
metadata: CredentialMetadata;
verifiedAt: string;
}> {
const response = await apiClient.get<{
valid: boolean;
metadata: CredentialMetadata;
verifiedAt: string;
}>(`/credentials/${credentialId}/verify`);
// ApiResponse wraps data in { data, message?, success }, so extract .data
return response.data;
*
* Calls `GET /credentials/verify/:id` and returns a typed
* `VerifyCredentialResult`. A 404 for a nonexistent credential is folded
Expand Down
11 changes: 11 additions & 0 deletions src/lib/hooks/use-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ export function useVerifyCredential(credentialId: string) {
if (!credentialId) 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) =>
verifyCredential(credentialId, controller.signal)
.then(setVerification)
.catch((err) => {
Expand Down
16 changes: 15 additions & 1 deletion src/lib/stellar/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,20 @@ export function getRpcUrl(network: NetworkType): string {
return "https://soroban-rpc.testnet.stellar.gateway.fm";
}

/**
* #500 — Get the actual network from Freighter wallet.
* Returns "testnet" or "public" based on the connected network.
*/
export async function getFreighterNetwork(): Promise<string | null> {
try {
const network = await freighterApi.getNetwork();
if (network.error) return null;
// Freighter returns network details; map to simple string
return network.network === "mainnet" ? "public" : "testnet";
} catch {
return null;
}
}
export function getHorizonUrl(network: NetworkType): string {
return network === "public"
? "https://horizon.stellar.org"
Expand All @@ -233,4 +247,4 @@ export function watchWalletChanges(
const watcher = new WatchWalletChanges(intervalMs);
watcher.watch(onChange);
return () => watcher.stop();
}
}
32 changes: 32 additions & 0 deletions src/types/course.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export interface Course {
createdAt: string;
}

// #498 — Content payload union: text is markdown/HTML string, video is a URL,
// interactive is a config object. This replaces the previous `content: string`
// which forced casting in parseModuleContent.
export type ModuleContent =
| string // text/markdown content
| { type: "video"; url: string; duration?: number }
| { type: "interactive"; config: Record<string, unknown> };


export interface Module {
Expand All @@ -22,11 +29,36 @@ export interface Module {
description: string;
order: number;
contentType: "text" | "video" | "interactive";
content: ModuleContent;
content: string | ModuleContent;
estimatedMinutes: number;
isCompleted?: boolean;
}

// #498 — Type guard for Module.content
export function isTextContent(content: ModuleContent): content is string {
return typeof content === "string";
}

export function isVideoContent(
content: ModuleContent
): content is { type: "video"; url: string; duration?: number } {
return typeof content === "object" && content !== null && "type" in content && content.type === "video";
}

export function isInteractiveContent(
content: ModuleContent
): content is { type: "interactive"; config: Record<string, unknown> } {
return typeof content === "object" && content !== null && "type" in content && content.type === "interactive";
}

// #498 — Safe parser that returns string content or empty string
export function parseModuleContent(content: ModuleContent): string {
if (isTextContent(content)) return content;
if (isVideoContent(content)) return content.url;
return "";
}

export interface CourseEnrollment {
id: string;
courseId: string;
Expand Down
Loading