diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index b29b314..2be07e7 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -1,173 +1,173 @@ -import { - Controller, - Get, - Patch, - Delete, - Body, - UseGuards, - Request, - HttpCode, - HttpStatus, - UseInterceptors, - ClassSerializerInterceptor, - Post, - Param, - UploadedFile, -} from '@nestjs/common'; -import { FileInterceptor } from '@nestjs/platform-express'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; -import { UserProfileResponseDto } from '../users/dto/user-profile-response.dto'; -import { VerifyWalletDto } from '../users/dto/verify-wallet.dto'; -import { UpdateProfileDto } from './dto/update-profile.dto'; -import { plainToInstance } from 'class-transformer'; -import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; -import { UserService } from './users.service'; -import { WalletService } from './wallet.service'; -import { DeleteAccountDto } from './dto/delete-account.dto'; -import { RolesGuard } from '../common/guards/roles.guard'; - -@ApiTags('Users') -@ApiBearerAuth('access-token') -@Controller('users') -@UseGuards(JwtAuthGuard) -@UseInterceptors(ClassSerializerInterceptor) -export class UsersController { - constructor( - private readonly userService: UserService, - private readonly walletService: WalletService, - ) { } - - @Get('me') - @ApiOperation({ summary: 'Get current user profile' }) - @ApiResponse({ status: 200, description: 'User profile retrieved successfully', type: UserProfileResponseDto }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) - async getMyProfile(@Request() req): Promise { - const user = await this.userService.getMyProfile(req.user.id as string); - return plainToInstance(UserProfileResponseDto, user); - } - - @Get('admin') - @UseGuards(JwtAuthGuard, RolesGuard) - @ApiOperation({ summary: 'Get admin user data (admin only)' }) - @ApiResponse({ status: 200, description: 'Admin data retrieved' }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) - @ApiResponse({ status: 403, description: 'Forbidden - admin access required' }) - async getAdminData(@Request() req) { - return { message: 'Admin data access' }; - } - - @Post('me/avatar') - @UseInterceptors(FileInterceptor('avatar')) - @ApiOperation({ summary: 'Upload user avatar' }) - @ApiResponse({ status: 200, description: 'Avatar uploaded successfully' }) - async uploadMyAvatar(@Request() req, @UploadedFile() file: Express.Multer.File) { - return this.userService.uploadAvatar(req.user.id as string, file); - } - - @Post('me/wallet/challenge') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Generate wallet verification challenge' }) - @ApiResponse({ status: 200, description: 'Challenge generated successfully' }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) - async generateWalletChallenge( - @Request() req, - ): Promise<{ challenge: string }> { - return this.walletService.generateChallenge(req.user.id as string); - } - - @Post('me/wallet/verify') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Verify and link wallet to user account' }) - @ApiResponse({ status: 200, description: 'Wallet linked successfully' }) - @ApiResponse({ status: 400, description: 'Bad request - invalid signature' }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) - async verifyAndLinkWallet( - @Request() req, - @Body() dto: VerifyWalletDto, - ): Promise<{ walletAddress: string }> { - return this.walletService.verifyAndLink( - req.user.id as string, - dto.walletAddress, - dto.signature, - ); - } - - @Get('me/wallet') - @ApiOperation({ summary: 'Get wallet connection status' }) - @ApiResponse({ status: 200, description: 'Wallet status retrieved' }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) - async getWalletStatus( - @Request() req, - ): Promise<{ linked: boolean; walletAddress?: string }> { - return this.walletService.getStatus(req.user.id as string); - } - - @Delete('me/wallet') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Unlink wallet from user account' }) - @ApiResponse({ status: 204, description: 'Wallet unlinked successfully' }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) - async unlinkWallet(@Request() req): Promise { - return this.walletService.unlink(req.user.id as string); - } - - @Patch('me') - @ApiOperation({ summary: 'Update user profile' }) - @ApiResponse({ status: 200, description: 'Profile updated successfully', type: UserProfileResponseDto }) - @ApiResponse({ status: 400, description: 'Bad request - validation error' }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) - async updateMyProfile( - @Request() req, - @Body() dto: UpdateProfileDto, - ): Promise { - await this.userService.updateProfile(req.user.id as string, dto); - const user = await this.userService.getMyProfile(req.user.id as string); - return plainToInstance(UserProfileResponseDto, user); - } - - @Delete('me') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Delete user account' }) - @ApiResponse({ status: 204, description: 'Account deleted successfully' }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) - async deleteProfile( - @Request() req, - @Body() dto: DeleteAccountDto, - ): Promise { - await this.userService.deleteProfile(req.user.id, dto.password); - } - - @Get('me/stats') - @ApiOperation({ summary: 'Get user statistics and achievements' }) - @ApiResponse({ status: 200, description: 'User stats retrieved successfully' }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) - async getMyStats(@Request() req): Promise<{ - courseCount: number; - completedCourseCount: number; - certificateCount: number; - xp: number; - streak: number; - longestStreak: number; - lastActiveAt: Date | null; - badgesCount: number; - rank: number; - }> { - return this.userService.getMyStats(req.user.id as string); - } - - @Get(':id/public') - @ApiOperation({ summary: 'Get public user profile by ID' }) - @ApiResponse({ status: 200, description: 'Public profile retrieved successfully' }) - @ApiResponse({ status: 404, description: 'User not found' }) - async getPublicProfile(@Param('id') id: string): Promise<{ - id: string; - username: string | null; - xp: number; - badgesCount: number; - coursesCompleted: number; - avatarUrl: string | null; - bio: string | null; - }> { - return this.userService.getPublicProfile(id); - } -} \ No newline at end of file +import { + Controller, + Get, + Patch, + Delete, + Body, + UseGuards, + Request, + HttpCode, + HttpStatus, + UseInterceptors, + ClassSerializerInterceptor, + Post, + Param, + UploadedFile, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { UserProfileResponseDto } from '../users/dto/user-profile-response.dto'; +import { VerifyWalletDto } from '../users/dto/verify-wallet.dto'; +import { UpdateProfileDto } from './dto/update-profile.dto'; +import { plainToInstance } from 'class-transformer'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { UserService } from './users.service'; +import { WalletService } from './wallet.service'; +import { DeleteAccountDto } from './dto/delete-account.dto'; +import { RolesGuard } from '../common/guards/roles.guard'; + +@ApiTags('Users') +@ApiBearerAuth('access-token') +@Controller('users') +@UseGuards(JwtAuthGuard) +@UseInterceptors(ClassSerializerInterceptor) +export class UsersController { + constructor( + private readonly userService: UserService, + private readonly walletService: WalletService, + ) { } + + @Get('me') + @ApiOperation({ summary: 'Get current user profile' }) + @ApiResponse({ status: 200, description: 'User profile retrieved successfully', type: UserProfileResponseDto }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getMyProfile(@Request() req): Promise { + const user = await this.userService.getMyProfile(req.user.id as string); + return plainToInstance(UserProfileResponseDto, user); + } + + @Get('admin') + @UseGuards(JwtAuthGuard, RolesGuard) + @ApiOperation({ summary: 'Get admin user data (admin only)' }) + @ApiResponse({ status: 200, description: 'Admin data retrieved' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden - admin access required' }) + async getAdminData(@Request() req) { + return { message: 'Admin data access' }; + } + + @Post('me/avatar') + @UseInterceptors(FileInterceptor('avatar')) + @ApiOperation({ summary: 'Upload user avatar' }) + @ApiResponse({ status: 200, description: 'Avatar uploaded successfully' }) + async uploadMyAvatar(@Request() req, @UploadedFile() file: Express.Multer.File) { + return this.userService.uploadAvatar(req.user.id as string, file); + } + + @Post('me/wallet/challenge') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Generate wallet verification challenge' }) + @ApiResponse({ status: 200, description: 'Challenge generated successfully' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async generateWalletChallenge( + @Request() req, + ): Promise<{ challenge: string }> { + return this.walletService.generateChallenge(req.user.id as string); + } + + @Post('me/wallet/verify') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Verify and link wallet to user account' }) + @ApiResponse({ status: 200, description: 'Wallet linked successfully' }) + @ApiResponse({ status: 400, description: 'Bad request - invalid signature' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async verifyAndLinkWallet( + @Request() req, + @Body() dto: VerifyWalletDto, + ): Promise<{ walletAddress: string }> { + return this.walletService.verifyAndLink( + req.user.id as string, + dto.walletAddress, + dto.signature, + ); + } + + @Get('me/wallet') + @ApiOperation({ summary: 'Get wallet connection status' }) + @ApiResponse({ status: 200, description: 'Wallet status retrieved' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getWalletStatus( + @Request() req, + ): Promise<{ linked: boolean; walletAddress?: string }> { + return this.walletService.getStatus(req.user.id as string); + } + + @Delete('me/wallet') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Unlink wallet from user account' }) + @ApiResponse({ status: 204, description: 'Wallet unlinked successfully' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async unlinkWallet(@Request() req): Promise { + return this.walletService.unlink(req.user.id as string); + } + + @Patch('me') + @ApiOperation({ summary: 'Update user profile' }) + @ApiResponse({ status: 200, description: 'Profile updated successfully', type: UserProfileResponseDto }) + @ApiResponse({ status: 400, description: 'Bad request - validation error' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async updateMyProfile( + @Request() req, + @Body() dto: UpdateProfileDto, + ): Promise { + await this.userService.updateProfile(req.user.id as string, dto); + const user = await this.userService.getMyProfile(req.user.id as string); + return plainToInstance(UserProfileResponseDto, user); + } + + @Delete('me') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete user account' }) + @ApiResponse({ status: 204, description: 'Account deleted successfully' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async deleteProfile( + @Request() req, + @Body() dto: DeleteAccountDto, + ): Promise { + await this.userService.deleteProfile(req.user.id, dto.password); + } + + @Get('me/stats') + @ApiOperation({ summary: 'Get user statistics and achievements' }) + @ApiResponse({ status: 200, description: 'User stats retrieved successfully' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getMyStats(@Request() req): Promise<{ + courseCount: number; + completedCourseCount: number; + certificateCount: number; + xp: number; + streak: number; + longestStreak: number; + lastActiveAt: Date | null; + badgesCount: number; + rank: number; + }> { + return this.userService.getMyStats(req.user.id as string); + } + + @Get(':id/public') + @ApiOperation({ summary: 'Get public user profile by ID' }) + @ApiResponse({ status: 200, description: 'Public profile retrieved successfully' }) + @ApiResponse({ status: 404, description: 'User not found' }) + async getPublicProfile(@Param('id') id: string): Promise<{ + id: string; + username: string | null; + xp: number; + badgesCount: number; + coursesCompleted: number; + avatarUrl: string | null; + bio: string | null; + }> { + return this.userService.getPublicProfile(id); + } +} diff --git a/frontend/app/certificates/page.tsx b/frontend/app/certificates/page.tsx index 58762d3..32fcdd6 100644 --- a/frontend/app/certificates/page.tsx +++ b/frontend/app/certificates/page.tsx @@ -5,38 +5,105 @@ import { Button } from "@/components/ui/button"; import { useAuth } from "@/contexts/auth-context"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; -import { ArrowLeft } from "lucide-react"; +import { ArrowLeft, ShieldCheck, Award, AlertTriangle, Loader2 } from "lucide-react"; import Link from "next/link"; -import { MyCertificatesPageContent } from "@/components/MyCertificatesPage/page"; +import { useCertificates } from "@/hooks/use-certificates"; +import { CertificateCard } from "@/components/certificates/certificate-card"; export default function MyCertificatesPage() { - const { isAuthenticated } = useAuth(); + const { isAuthenticated, isLoading: authLoading } = useAuth(); const router = useRouter(); + const { data: certificates, isLoading: certsLoading, isError, refetch } = useCertificates(); useEffect(() => { - if (!isAuthenticated) { + if (!authLoading && !isAuthenticated) { router.push("/"); } - }, [isAuthenticated, router]); + }, [isAuthenticated, authLoading, router]); + }, [isAuthenticated, authLoading, router]); - if (!isAuthenticated) return null; + if (authLoading || !isAuthenticated) { + return ( +
+ +
+ ); + } return (
-
+
-
-
- +
+
+

+ My Certificates +

+

+ Showcase your achievements. All certificates are cryptographically signed and verifiable on-chain. +

+
+
+ + Secured by ByteChain +
+ + {certsLoading ? ( +
+ {[1, 2].map((i) => ( +
+ ))} +
+ ) : isError ? ( +
+
+ +
+
+

Failed to load certificates

+

There was an error connecting to the verification server.

+
+ +
+ ) : !certificates || certificates.length === 0 ? ( +
+
+ +
+
+

No certificates yet

+

+ Complete courses and pass quizzes to earn your blockchain certifications. +

+
+ + + +
+ ) : ( +
+ {certificates.map((cert) => ( + + ))} +
+ )}
); diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index c7007fe..2660f6e 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; import { Header } from "@/components/header"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/contexts/auth-context"; import { useUser } from "@/contexts/user-context"; @@ -16,12 +16,17 @@ import { Mail, Shield, Settings, - User, + User as UserIcon, + Loader2, + ExternalLink, + Calendar, + MapPin, + Sparkles } from "lucide-react"; import Link from "next/link"; export default function ProfilePage() { - const { isAuthenticated } = useAuth(); + const { isAuthenticated, isLoading: authLoading } = useAuth(); const { user, stats, userStats, loadUserData, updateProfile } = useUser(); const router = useRouter(); @@ -30,107 +35,129 @@ export default function ProfilePage() { }, [loadUserData]); useEffect(() => { - if (!isAuthenticated) { + if (!authLoading && !isAuthenticated) { router.push("/"); } - }, [isAuthenticated, router]); + }, [isAuthenticated, authLoading, router]); - if (!isAuthenticated || !user) { + if (authLoading || !isAuthenticated || !user) { return ( -
-
-
-
-
-
-
-
+
+
); } return ( -
+
-
-
- - - -
- -
-

My Profile

-

View and manage your account details and progress.

-
- - - - Account Information - - - { - void updateProfile({ avatar: avatarUrl }); - }} - /> + + {/* Premium Profile Header Background */} +
+
+
+
+
-
-
- -
-

Username

-

{user.fullName}

+
+
+ + {/* Left Column: Avatar & Basic Info */} +
+ + +
+ { + void updateProfile({ avatar: avatarUrl }); + }} + /> +
+

{user.fullName}

+
+ + {user.role} +
+
-
-
- -
-

Email

-

{user.email}

+ +
+
+ + {user.email} +
+
+ + Joined {new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' })} +
-
-
- -
-

Role

-

{user.role}

+ +
+ + +
+ + + + {/* Quick Links / Badges Preview */} +
+
+

Academy Standing

+ +
+
+
+
+
+ Lvl 4 +
+

+ Complete 2 more modules to reach **Level 5** and unlock the Advanced Smart Contracts certification. +

+
+
+ + {/* Right Column: Stats & Tabs */} +
+
+ + + +
+ Member ID: #{user.id.substring(0, 8)}
- - - - - + -
- +
+
+
+ +
+
+
- -
); } + diff --git a/frontend/app/verify-certificate/page.tsx b/frontend/app/verify-certificate/page.tsx index 034a02d..1696ce5 100644 --- a/frontend/app/verify-certificate/page.tsx +++ b/frontend/app/verify-certificate/page.tsx @@ -1,166 +1,138 @@ "use client"; import { Header } from "@/components/header"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { useAuth } from "@/contexts/auth-context"; -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { ArrowLeft, FileCheck, CheckCircle2, XCircle } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { useSearchParams } from "next/navigation"; +import { useEffect, useState, Suspense } from "react"; +import { useCertificateVerification } from "@/hooks/use-certificates"; +import { + ShieldCheck, + Search, + ArrowRight, + Loader2, + Sparkles +} from "lucide-react"; import Link from "next/link"; -import { api } from "@/lib/api"; +import { VerificationResultView } from "@/components/certificates/verification-result"; -interface VerificationResult { - isValid: boolean; - message: string; - certificate?: { - recipientName: string; - recipientEmail: string; - courseOrProgram: string; - issuedAt: string; - expiresAt: string | null; - isValid: boolean; - }; -} +function VerifyCertificateContent() { + const searchParams = useSearchParams(); + const [inputValue, setInputValue] = useState(searchParams.get("hash") || ""); + const [activeHash, setActiveHash] = useState(searchParams.get("hash") || ""); + + const { data: result, isLoading: loading, isError, refetch } = useCertificateVerification(activeHash); -export default function VerifyCertificatePage() { - const { isAuthenticated } = useAuth(); - const router = useRouter(); - const [hash, setHash] = useState(""); - const [result, setResult] = useState(null); - const [loading, setLoading] = useState(false); + const handleVerify = (e?: React.FormEvent) => { + if (e) e.preventDefault(); + if (!inputValue.trim()) return; + setActiveHash(inputValue.trim()); + }; useEffect(() => { - if (!isAuthenticated) { - router.push("/"); + const autoHash = searchParams.get("hash"); + if (autoHash) { + setInputValue(autoHash); + setActiveHash(autoHash); } - }, [isAuthenticated, router]); - - const handleVerify = async (e: React.FormEvent) => { - e.preventDefault(); - if (!hash.trim()) return; - setLoading(true); - setResult(null); - try { - const data = await api.post("/certificates/verify", { - certificateHash: hash.trim(), - }); - setResult(data); - } catch (err) { - setResult({ - isValid: false, - message: err instanceof Error ? err.message : "Verification failed", - }); - } finally { - setLoading(false); - } - }; - - if (!isAuthenticated) return null; + }, [searchParams]); return ( -
-
-
-
- - - -
- -
-
-
- -
-

Verify Certificate

-
-

- Enter the certificate hash to verify its authenticity -

+
+
+
+ + Trustless Verification
+

+ Verify Authenticity +

+

+ Verify the integrity of certificates issued by ByteChain Academy. Enter a certificate hash to check its on-chain status. +

+
- - - Certificate Hash - - -
- setHash(e.target.value)} - className="font-mono text-sm" - disabled={loading} - /> -
+
- {result && ( - - -
- {result.isValid ? ( - - ) : ( - - )} -
-

- {result.isValid ? "Certificate Valid" : "Certificate Invalid"} -

-

{result.message}

- {result.certificate && ( -
-

- Recipient:{" "} - {result.certificate.recipientName} -

-

- Course:{" "} - {result.certificate.courseOrProgram} -

-

- Issued:{" "} - {new Date(result.certificate.issuedAt).toLocaleDateString()} -

-
- )} -
+ {loading ? ( +
+
+
+ +
+

Querying Blockchain...

+
+ ) : activeHash && result ? ( + + ) : null} + + {!activeHash && ( +
+ {[ + { title: "Immutable", desc: "Forged on-chain, impossible to alter or counterfeit.", icon: ShieldCheck }, + { title: "Public", desc: "Open to anyone with the hash. No account required.", icon: Search }, + { title: "Instant", desc: "Zero-latency verification against our decentralized registry.", icon: Sparkles } + ].map((feature, i) => ( +
+ +

{feature.title}

+

{feature.desc}

- - - )} + ))} +
+ )} + +
+ + Return to Dashboard + + +
+
+ ); +} + +export default function VerifyCertificatePage() { + return ( +
+
+
+ + +
+ }> + +
); diff --git a/frontend/components/certificates/certificate-card.tsx b/frontend/components/certificates/certificate-card.tsx new file mode 100644 index 0000000..0b1d437 --- /dev/null +++ b/frontend/components/certificates/certificate-card.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useState } from "react"; +import { + ShieldCheck, + Download, + Share2, + Check, + ExternalLink, + Loader2, + Calendar, + Award +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { toast } from "sonner"; +import { Certificate } from "@/hooks/use-certificates"; +import { api } from "@/lib/api"; + +interface CertificateCardProps { + certificate: Certificate; +} + +export function CertificateCard({ certificate }: CertificateCardProps) { + const [isDownloading, setIsDownloading] = useState(false); + const [copied, setCopied] = useState(false); + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }); + }; + + const handleDownload = async () => { + setIsDownloading(true); + try { + // Use the API base for downloading + const baseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; + const token = localStorage.getItem("auth_token"); + + const res = await fetch(`${baseUrl}/api/v1/certificates/${certificate.id}/download`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!res.ok) throw new Error("Failed to download certificate"); + + const blob = await res.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `Certificate-${certificate.courseName.replace(/\s+/g, "-")}.pdf`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + toast.success("Certificate downloaded successfully!"); + } catch (error) { + console.error("Download error:", error); + toast.error("Failed to download certificate. Please try again."); + } finally { + setIsDownloading(false); + } + }; + + const handleShare = () => { + const verificationUrl = `${window.location.origin}/verify-certificate?hash=${certificate.certificateHash}`; + navigator.clipboard.writeText(verificationUrl); + setCopied(true); + toast.success("Verification link copied to clipboard!"); + setTimeout(() => setCopied(false), 2000); + }; + + const isRevoked = certificate.status === "revoked"; + + return ( + +
+ +
+
+
+ +
+
+

+ {certificate.courseName} +

+
+
+ + {formatDate(certificate.issuedAt)} +
+
+ Hash: + {certificate.certificateHash.substring(0, 10)}... +
+
+
+
+ +
+ + + + + +
+
+ + {isRevoked && ( +
+ This certificate has been revoked +
+ )} +
+ + ); +} diff --git a/frontend/components/certificates/verification-result.tsx b/frontend/components/certificates/verification-result.tsx new file mode 100644 index 0000000..60b09f6 --- /dev/null +++ b/frontend/components/certificates/verification-result.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { Card, CardContent } from "@/components/ui/card"; +import { + ShieldCheck, + AlertTriangle, + User, + BookOpen, + Calendar, + ExternalLink, + Award, + CheckCircle2 +} from "lucide-react"; +import { VerificationResult } from "@/hooks/use-certificates"; + +interface VerificationResultViewProps { + result: VerificationResult; + hash: string; +} + +export function VerificationResultView({ result, hash }: VerificationResultViewProps) { + if (!result.valid) { + return ( + + +
+ +
+
+

Certificate Not Found

+

+ We couldn't find a valid certificate with the hash provided. This could mean the hash is incorrect or the certificate has not been issued yet. +

+
+
+ Hash: {hash} +
+
+
+ ); + } + + const isRevoked = result.revoked === true; + + return ( + + {/* Decorative Background Elements */} +
+
+ +
+
+ + {isRevoked ? 'Revoked Certificate' : 'Authentic Certificate'} +
+
+ {isRevoked ? 'Invalidated' : 'Verified ✓'} +
+
+ + +
+
+ +
+

+ Certificate of Completion +

+
+ +
+
+
+ + Recipient Name +
+

+ {result.recipientName || "Anonymous Learner"} +

+
+ +
+
+ + Course Completed +
+

+ {result.courseOrProgram || "Blockchain Specialization"} +

+
+ +
+
+ + Issue Date +
+

+ {result.issuedAt ? new Date(result.issuedAt).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric' + }) : "Unknown"} +

+
+ +
+
+ + Status +
+

+ {isRevoked ? 'Revoked' : 'Active & Valid'} +

+
+
+ +
+
+
+ Blockchain Verification Hash + +
+

+ {hash} +

+ +
+
+
+ + ); +} diff --git a/frontend/components/profile/my-certificates-content.tsx b/frontend/components/profile/my-certificates-content.tsx index 7231c01..325dea6 100644 --- a/frontend/components/profile/my-certificates-content.tsx +++ b/frontend/components/profile/my-certificates-content.tsx @@ -1,94 +1,64 @@ "use client"; -import { useEffect, useState } from "react"; -import { AlertTriangle } from "lucide-react"; -import { apiFetch } from "@/lib/api"; -import { - CertificateCard, - type Certificate, -} from "@/components/MyCertificatesPage/Certificatecard"; -import { CertificateCardSkeleton } from "@/components/MyCertificatesPage/Certificatecardskeleton"; -import { CertificatesEmptyState } from "@/components/MyCertificatesPage/Certificatesemptystate"; - -type FetchState = "loading" | "success" | "error"; - -function getAuthToken(): string { - if (typeof window === "undefined") return ""; - return localStorage.getItem("auth_token") ?? ""; -} +import { AlertTriangle, Loader2, Award } from "lucide-react"; +import { CertificateCard } from "@/components/certificates/certificate-card"; +import { useCertificates } from "@/hooks/use-certificates"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; export function MyCertificatesContent() { - const [fetchState, setFetchState] = useState("loading"); - const [certificates, setCertificates] = useState([]); - const token = typeof window !== "undefined" ? localStorage.getItem("auth_token") ?? "" : ""; - - const fetchCertificates = async () => { - setFetchState("loading"); - try { - // Try the primary endpoint - const data = await api.get('/certificates/my'); - - if (!Array.isArray(data)) { - throw new Error("Invalid response format"); - } - - const mapped: Certificate[] = data.map((item) => ({ - id: item.id || Math.random().toString(), - courseName: item.courseOrProgram || item.courseTitle || "Unknown Course", - issuedAt: item.issuedAt || new Date().toISOString(), - verificationCode: item.certificateHash || item.hash || "", - status: 'active', - })); + const { data: certificates, isLoading, isError, refetch } = useCertificates(); - mapped.sort( - (a, b) => - new Date(b.issuedAt).getTime() - new Date(a.issuedAt).getTime(), - ); - setCertificates(mapped); - setFetchState("success"); - } catch (err) { - console.error("Failed to fetch certificates:", err); - setFetchState("error"); - setCertificates([]); - } - }; - - useEffect(() => { - void fetchCertificates(); - }, []); - - if (fetchState === "loading") { + if (isLoading) { return (
- - + {[1, 2].map((i) => ( +
+ ))}
); } - if (fetchState === "error") { + if (isError) { return ( -
- -

Could not load your certificates.

- +
); } - if (certificates.length === 0) { - return ; + if (!certificates || certificates.length === 0) { + return ( +
+ +
+

No certificates earned yet

+

Complete courses to see them here.

+
+ + + +
+ ); } return (
{certificates.map((cert) => ( - + ))}
); diff --git a/frontend/components/profile/profile-tabs.tsx b/frontend/components/profile/profile-tabs.tsx index 85f2e69..21fe34d 100644 --- a/frontend/components/profile/profile-tabs.tsx +++ b/frontend/components/profile/profile-tabs.tsx @@ -1,9 +1,16 @@ "use client"; import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; import { MyCertificatesContent } from "@/components/profile/my-certificates-content"; +import { + Award, + Activity, + FileCheck, + ChevronRight, + ShieldCheck, + Calendar +} from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; type TabKey = "certificates" | "badges" | "activity"; @@ -15,54 +22,94 @@ interface ProfileTabsProps { export function ProfileTabs({ badgesCount, lastActiveAt }: ProfileTabsProps) { const [activeTab, setActiveTab] = useState("certificates"); + const tabs = [ + { id: "certificates", label: "Certificates", icon: FileCheck }, + { id: "badges", label: "Badges", icon: Award }, + { id: "activity", label: "Activity", icon: Activity }, + ] as const; + return ( - + -
- - - +
+ {tabs.map((tab) => { + const Icon = tab.icon; + const isActive = activeTab === tab.id; + return ( + + ); + })}
-
- {activeTab === "certificates" && } +
+ {activeTab === "certificates" && ( +
+ +
+ )} {activeTab === "badges" && ( -
-

Badges earned

-

You currently have {badgesCount} badges.

+
+
+ {/* Mock Badges for Visual Excellence */} + {[ + { name: "First Steps", color: "text-blue-400", bg: "bg-blue-400/10" }, + { name: "Quiz Master", color: "text-yellow-400", bg: "bg-yellow-400/10" }, + { name: "Code Ninja", color: "text-purple-400", bg: "bg-purple-400/10" }, + { name: "Explorer", color: "text-emerald-400", bg: "bg-emerald-400/10" }, + ].slice(0, Math.max(1, badgesCount)).map((badge, i) => ( +
+
+ +
+ {badge.name} +
+ ))} + + {badgesCount === 0 && ( +
+ +

No badges earned yet.

+
+ )} +
)} {activeTab === "activity" && ( -
-

Recent activity

-

- {lastActiveAt - ? `Last active on ${new Date(lastActiveAt).toLocaleString()}` - : "No recent activity available yet."} -

+
+ {lastActiveAt ? ( +
+
+ +
+
+

Last Session Active

+
+ + {new Date(lastActiveAt).toLocaleString()} +
+
+ +
+ ) : ( +
+ +

No recent activity detected.

+
+ )}
)}
diff --git a/frontend/components/profile/stats-summary.tsx b/frontend/components/profile/stats-summary.tsx index a0237d6..2b111be 100644 --- a/frontend/components/profile/stats-summary.tsx +++ b/frontend/components/profile/stats-summary.tsx @@ -1,7 +1,7 @@ "use client"; import { Card, CardContent } from "@/components/ui/card"; -import { Award, Flame, GraduationCap, Trophy, Zap } from "lucide-react"; +import { Award, Flame, GraduationCap, Trophy, Zap, Sparkles } from "lucide-react"; interface StatsSummaryProps { xp: number; @@ -20,59 +20,59 @@ export function StatsSummary({ }: StatsSummaryProps) { const cards = [ { - label: "XP", + label: "Experience", value: xp.toLocaleString(), + subValue: "XP Earned", icon: Zap, - valueClassName: "text-[#00ff88]", - iconClassName: "text-[#00ff88]", - borderClassName: "border-[#00ff88]/20", + color: "from-green-500 to-emerald-700", + iconColor: "text-green-400", }, { - label: "Streak", + label: "Daily Streak", value: String(streak), + subValue: "Days Active", icon: Flame, - valueClassName: "text-orange-400", - iconClassName: "text-orange-400", - borderClassName: "border-orange-400/20", + color: "from-orange-500 to-red-600", + iconColor: "text-orange-400", }, { - label: "Badges", - value: String(badgesCount), - icon: Award, - valueClassName: "text-white", - iconClassName: "text-[#00ff88]", - borderClassName: "border-white/10", - }, - { - label: "Certificates", + label: "Certifications", value: String(certificatesCount), + subValue: "Verifiable", icon: GraduationCap, - valueClassName: "text-white", - iconClassName: "text-[#00ff88]", - borderClassName: "border-white/10", + color: "from-blue-500 to-indigo-600", + iconColor: "text-blue-400", }, { - label: "Completed Courses", - value: String(completedCourses), - icon: Trophy, - valueClassName: "text-white", - iconClassName: "text-[#00ff88]", - borderClassName: "border-white/10", - }, + label: "Achievements", + value: String(badgesCount), + subValue: "Badges", + icon: Award, + color: "from-purple-500 to-pink-600", + iconColor: "text-purple-400", + } ]; return ( -
+
{cards.map((item) => { const Icon = item.icon; return ( - - -
-

{item.label}

- + +
+ +
+
+

{item.label}

+
+

{item.value}

+
+

{item.subValue}

+
+
+ +
-

{item.value}

); diff --git a/frontend/hooks/use-certificates.ts b/frontend/hooks/use-certificates.ts new file mode 100644 index 0000000..aa56ed0 --- /dev/null +++ b/frontend/hooks/use-certificates.ts @@ -0,0 +1,62 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; + +export interface Certificate { + id: string; + courseName: string; + issuedAt: string; + certificateHash: string; + status: "active" | "revoked"; +} + +export function useCertificates() { + return useQuery({ + queryKey: ["certificates", "my"], + queryFn: async () => { + const data = await api.get("/certificates/my"); + if (!Array.isArray(data)) throw new Error("Invalid response format"); + + return data.map((cert) => ({ + id: cert.id, + courseName: cert.courseOrProgram || "Unknown Course", + issuedAt: cert.issuedAt || cert.createdAt, + certificateHash: cert.certificateHash || "", + status: (cert.isValid === false ? "revoked" : "active") as "active" | "revoked", + })) as Certificate[]; + }, + retry: 1, + }); +} + +export interface VerificationResult { + valid: boolean; + recipientName?: string; + courseOrProgram?: string; + issuedAt?: string; + revoked?: boolean; +} + +export function useCertificateVerification(hash: string) { + return useQuery({ + queryKey: ["certificates", "verify", hash], + queryFn: async () => { + if (!hash) return null; + try { + const res = await api.get(`/certificates/verify/${hash}`); + // Map backend CertificateVerificationResultDto to our VerificationResult + return { + valid: res.isValid, + recipientName: res.certificate?.recipientName, + courseOrProgram: res.certificate?.courseOrProgram, + issuedAt: res.certificate?.issuedAt, + revoked: res.certificate?.status === 'revoked' + } as VerificationResult; + } catch (err) { + console.error("Verification error:", err); + return { valid: false } as VerificationResult; + } + }, + enabled: !!hash, + retry: false, + }); +}