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
23 changes: 16 additions & 7 deletions src/app/credentials/[credentialId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,17 @@ export default function CredentialDetailPage({

const handleDownload = useCallback(() => {
if (!credential) return;
// #489 — escape all user-supplied values before interpolating into HTML
// to prevent XSS via malicious course titles or IDs.
const esc = (s: string | number | null | undefined): string =>
String(s ?? "")
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const html = `<!DOCTYPE html>
<html><head><title>Certificate - ${credential.courseTitle}</title>
<html><head><title>Certificate - ${esc(credential.courseTitle)}</title>
<style>
body{font-family:Georgia,serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f9fafb}
.cert{background:white;border:2px solid #111;padding:48px;max-width:640px;text-align:center}
Expand All @@ -77,12 +86,12 @@ export default function CredentialDetailPage({
</style></head><body>
<div class="cert">
<h1>Certificate of Completion</h1>
<h2>${credential.courseTitle}</h2>
<h2>${esc(credential.courseTitle)}</h2>
<div class="line"></div>
<p class="meta">Issued: ${formatDate(credential.issuedAt)}</p>
<p class="meta">Credential ID: ${credential.id}</p>
<p class="meta">Token: ${truncateAddress(credential.tokenId, 8)}</p>
${credential.metadata.score ? `<p class="meta">Score: ${credential.metadata.score}%</p>` : ""}
<p class="meta">Issued: ${esc(formatDate(credential.issuedAt))}</p>
<p class="meta">Credential ID: ${esc(credential.id)}</p>
<p class="meta">Token: ${esc(truncateAddress(credential.tokenId, 8))}</p>
${credential.metadata.score ? `<p class="meta">Score: ${esc(credential.metadata.score)}%</p>` : ""}
<div class="line"></div>
<p class="meta">Verified on Stellar blockchain</p>
</div>
Expand All @@ -91,7 +100,7 @@ export default function CredentialDetailPage({
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `certificate-${credential.courseTitle.toLowerCase().replace(/\s+/g, "-")}.html`;
a.download = `certificate-${credential.courseTitle.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.html`;
a.click();
URL.revokeObjectURL(url);
}, [credential]);
Expand Down
262 changes: 159 additions & 103 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { Component, type ReactNode } from "react";
import { useRequireAuth } from "@/lib/hooks/use-require-auth";
import { useCourses, useRecommendedCourses } from "@/lib/hooks/use-courses";
import { useRewards } from "@/lib/hooks/use-rewards";
Expand All @@ -20,17 +21,58 @@ import {
Award,
TrendingUp,
ArrowRight,
AlertTriangle,
} from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";

// #488 — lightweight per-section error boundary so one failing section
// (rewards, credentials, etc.) never takes down the whole dashboard.
class SectionErrorBoundary extends Component<
{ label: string; children: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };

static getDerivedStateFromError() {
return { hasError: true };
}

render() {
if (this.state.hasError) {
return (
<Card>
<CardContent className="py-6 text-center">
<AlertTriangle className="mx-auto h-5 w-5 text-amber-500 mb-2" />
<p className="text-sm text-gray-600">
{this.props.label} failed to load. Other sections are unaffected.
</p>
</CardContent>
</Card>
);
}
return this.props.children;
}
}

function SectionError({ label, message }: { label: string; message: string }) {
return (
<Card>
<CardContent className="py-6 text-center">
<AlertTriangle className="mx-auto h-5 w-5 text-amber-500 mb-2" />
<p className="text-sm text-gray-600">{label}: {message}</p>
</CardContent>
</Card>
);
}

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

if (!ready) return null;
Expand Down Expand Up @@ -104,116 +146,130 @@ export default function DashboardPage() {
/>
</div>

<section>
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Recommended for You
</h2>
{recommendedError ? (
<Card>
<CardContent className="py-6 text-center">
<p className="text-sm text-red-600">{recommendedError}</p>
</CardContent>
</Card>
) : recommended.length === 0 ? (
<Card>
<CardContent className="py-6 text-center text-sm text-gray-500">
Complete your profile to get personalized recommendations.
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{recommended.slice(0, 3).map((course) => (
<CourseCard key={course.id} course={course} />
))}
</div>
)}
</section>
<SectionErrorBoundary label="Recommendations">
<section>
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Recommended for You
</h2>
{recommendedError ? (
<SectionError label="Recommendations" message={recommendedError} />
) : recommended.length === 0 ? (
<Card>
<CardContent className="py-6 text-center text-sm text-gray-500">
Complete your profile to get personalized recommendations.
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{recommended.slice(0, 3).map((course) => (
<CourseCard key={course.id} course={course} />
))}
</div>
)}
</section>
</SectionErrorBoundary>
</TabsContent>

<TabsContent value="courses" className="focus-visible:outline-none focus-visible:ring-0">
<section className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">My Courses</h2>
<Link href="/courses">
<Button variant="ghost" size="sm" className="gap-1">
Browse Catalog <ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
{enrolledCourses.length === 0 ? (
<EmptyState
icon={BookOpen}
title="No Courses Yet"
description="You haven't enrolled in any courses yet."
action={
<Link href="/courses">
<Button size="sm">Explore Courses</Button>
</Link>
}
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{enrolledCourses.map(({ enrollment, course }) => (
<CourseProgressCard
key={enrollment.id}
courseId={enrollment.courseId}
courseTitle={course?.title || "Course"}
progress={
progress[enrollment.courseId]?.progressPercent ??
enrollment.progress
}
completedModules={enrollment.completedModules?.length}
moduleCount={course?.totalModules}
/>
))}
<SectionErrorBoundary label="Courses">
<section className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">My Courses</h2>
<Link href="/courses">
<Button variant="ghost" size="sm" className="gap-1">
Browse Catalog <ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
)}
</section>
{coursesError ? (
<SectionError label="Courses" message={coursesError} />
) : enrolledCourses.length === 0 ? (
<EmptyState
icon={BookOpen}
title="No Courses Yet"
description="You haven't enrolled in any courses yet."
action={
<Link href="/courses">
<Button size="sm">Explore Courses</Button>
</Link>
}
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{enrolledCourses.map(({ enrollment, course }) => (
<CourseProgressCard
key={enrollment.id}
courseId={enrollment.courseId}
courseTitle={course?.title || "Course"}
progress={
progress[enrollment.courseId]?.progressPercent ??
enrollment.progress
}
completedModules={enrollment.completedModules?.length}
moduleCount={course?.totalModules}
/>
))}
</div>
)}
</section>
</SectionErrorBoundary>
</TabsContent>

<TabsContent value="credentials" className="focus-visible:outline-none focus-visible:ring-0">
<section>
<h2 className="text-lg font-semibold text-gray-900 mb-4">
My Credentials
</h2>
{credentials.length === 0 ? (
<EmptyState
icon={Award}
title="No Credentials Yet"
description="You haven't earned any credentials yet. Complete courses to earn verifiable credentials on the Stellar network."
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{credentials.map((cred) => (
<CredentialCard key={cred.id} credential={cred} />
))}
</div>
)}
</section>
<SectionErrorBoundary label="Credentials">
<section>
<h2 className="text-lg font-semibold text-gray-900 mb-4">
My Credentials
</h2>
{credentialsError ? (
<SectionError label="Credentials" message={credentialsError} />
) : credentials.length === 0 ? (
<EmptyState
icon={Award}
title="No Credentials Yet"
description="You haven't earned any credentials yet. Complete courses to earn verifiable credentials on the Stellar network."
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{credentials.map((cred) => (
<CredentialCard key={cred.id} credential={cred} />
))}
</div>
)}
</section>
</SectionErrorBoundary>
</TabsContent>

<TabsContent value="rewards" className="focus-visible:outline-none focus-visible:ring-0">
<section className="mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Rewards Balance
</h2>
<Card>
<CardContent className="p-6">
<BalanceDisplay />
</CardContent>
</Card>
</section>

<section>
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Reward History
</h2>
<Card>
<CardContent className="p-6">
<RewardHistory claims={history} />
</CardContent>
</Card>
</section>
<SectionErrorBoundary label="Rewards">
{rewardsError ? (
<SectionError label="Rewards" message={rewardsError} />
) : (
<>
<section className="mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Rewards Balance
</h2>
<Card>
<CardContent className="p-6">
<BalanceDisplay />
</CardContent>
</Card>
</section>

<section>
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Reward History
</h2>
<Card>
<CardContent className="p-6">
<RewardHistory claims={history} />
</CardContent>
</Card>
</section>
</>
)}
</SectionErrorBoundary>
</TabsContent>
</Tabs>
</div>
Expand Down
11 changes: 7 additions & 4 deletions src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,16 +130,19 @@ export default function SettingsPage() {
<CardTitle className="text-lg">Profile Information</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Avatar Upload */}
<div className="flex justify-center mb-6">
{/* Avatar Upload — upload is not yet wired to a backend (#486).
Show a clear "coming soon" placeholder instead of a fake success. */}
<div className="flex flex-col items-center mb-6 gap-2">
<AvatarUpload
currentAvatarUrl={undefined}
name={displayName || walletAddress || "User"}
onUpload={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
addToast("Avatar uploaded successfully", "success");
addToast("Avatar upload is not yet available", "info");
}}
/>
<p className="text-xs text-gray-400 dark:text-gray-500">
Avatar upload coming soon
</p>
</div>

{/* Display Name */}
Expand Down
Loading