diff --git a/app/api/debug/profile/[username]/route.ts b/app/api/debug/profile/[username]/route.ts deleted file mode 100644 index 1d0cbc85a..000000000 --- a/app/api/debug/profile/[username]/route.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { createClient } from '@/lib/supabase/server'; -import { reservedUsernameService } from '@/lib/services/reserved-usernames'; -import { profileService } from '@/lib/services/profile'; -import { createClient as createBrowserClient } from '@/lib/supabase/client'; - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ username: string }> } -) { - try { - const { username } = await params; - const supabase = await createClient(); - - // Check if username is reserved - const isReserved = await reservedUsernameService.isReservedUsername(username); - const isFallbackReserved = reservedUsernameService.isFallbackReservedUsername(username); - - // Get profile data directly from database - const { data: profile, error: profileError } = await supabase - .from('profiles') - .select('*') - .eq('username', username) - .single(); - - // Get profile data using profileService - let profileServiceResult = null; - let profileServiceError = null; - try { - profileServiceResult = await profileService.getPublicProfileByUsername(username); - } catch (error) { - profileServiceError = error instanceof Error ? error.message : 'Unknown error'; - } - - // Test client-side Supabase client - let clientSideResult = null; - let clientSideError = null; - try { - const clientSupabase = createBrowserClient(); - const { data: clientData, error: clientError } = await clientSupabase - .from('profiles') - .select('*') - .eq('username', username) - .eq('is_public', true) - .single(); - - clientSideResult = clientData; - clientSideError = clientError?.message || null; - } catch (error) { - clientSideError = error instanceof Error ? error.message : 'Unknown error'; - } - - // Get reserved username data - const { data: reservedData, error: reservedError } = await supabase - .from('reserved_usernames') - .select('*') - .eq('username', username) - .single(); - - return NextResponse.json({ - username, - debug: { - isReserved, - isFallbackReserved, - profile: profile || null, - profileError: profileError?.message || null, - profileServiceResult: profileServiceResult || null, - profileServiceError: profileServiceError || null, - clientSideResult: clientSideResult || null, - clientSideError: clientSideError || null, - reservedData: reservedData || null, - reservedError: reservedError?.message || null, - timestamp: new Date().toISOString() - } - }); - } catch (error) { - return NextResponse.json({ - error: error instanceof Error ? error.message : 'Unknown error', - timestamp: new Date().toISOString() - }, { status: 500 }); - } -} \ No newline at end of file diff --git a/app/api/profile/[username]/route.ts b/app/api/profile/[username]/route.ts new file mode 100644 index 000000000..653ab65d1 --- /dev/null +++ b/app/api/profile/[username]/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ username: string }> } +) { + try { + const { username } = await params; + + if (!username) { + return NextResponse.json( + { error: 'Username is required' }, + { status: 400 } + ); + } + + // Use server-side Supabase client directly + const supabase = await createClient(); + const { data: profile, error } = await supabase + .from('profiles') + .select('*') + .eq('username', username) + .eq('is_public', true) + .single(); + + if (error) { + if (error.code === 'PGRST116') { + return NextResponse.json( + { error: 'Profile not found or not public' }, + { status: 404 } + ); + } + console.error('Error fetching public profile:', error); + return NextResponse.json( + { error: 'Failed to fetch profile' }, + { status: 500 } + ); + } + + return NextResponse.json({ profile }); + } catch (error) { + console.error('Error fetching public profile:', error); + return NextResponse.json( + { error: 'Failed to fetch profile' }, + { status: 500 } + ); + } +} diff --git a/app/api/tests/register/route.ts b/app/api/tests/register/route.ts new file mode 100644 index 000000000..b300f80ca --- /dev/null +++ b/app/api/tests/register/route.ts @@ -0,0 +1,123 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createClient as createServiceClient } from '@supabase/supabase-js' + +export async function POST(request: NextRequest) { + try { + const { testId, userId, userEmail, userMetadata } = await request.json() + + if (!testId || !userId) { + return NextResponse.json( + { error: 'Test ID and User ID are required' }, + { status: 400 } + ) + } + + // Use service role client to bypass RLS + const serviceSupabase = createServiceClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ) + + // Check if already registered + const { data: existingRegistration } = await serviceSupabase + .from('test_registrations') + .select('id') + .eq('test_id', testId) + .eq('user_id', userId) + .single() + + if (existingRegistration) { + return NextResponse.json( + { error: 'User is already registered for this test' }, + { status: 400 } + ) + } + + // Ensure profile exists to prevent trigger failure + try { + const { error: profileError } = await serviceSupabase + .from('profiles') + .select('id, first_name, last_name, email') + .eq('id', userId) + .single() + + if (profileError && profileError.code === 'PGRST116') { + // Profile doesn't exist, create a minimal one + const { error: createError } = await serviceSupabase + .from('profiles') + .insert({ + id: userId, + email: userEmail, + first_name: userMetadata?.first_name || userMetadata?.given_name || '', + last_name: userMetadata?.last_name || userMetadata?.family_name || '', + is_public: true, + email_notifications: true, + profile_completion_percentage: 0, + is_admin: false, + username_editable: true, + username_set: false, + profile_complete: false + }) + + if (createError) { + console.error('Error creating profile:', createError) + // Continue anyway, the trigger might still work + } + } + } catch (profileException) { + console.error('Exception during profile check/creation:', profileException) + // Continue with registration + } + + // Prepare registration data with proper fallbacks for the trigger + const fullName = userMetadata?.full_name || + userMetadata?.name || + (userMetadata?.first_name && userMetadata?.last_name ? + `${userMetadata.first_name} ${userMetadata.last_name}` : + null); + + // Register for the test + const { data, error } = await serviceSupabase + .from('test_registrations') + .insert([{ + test_id: testId, + user_id: userId, + status: 'registered', + attempt_count: 0, + registration_date: new Date().toISOString(), + full_name: fullName, + email: userEmail || null, + phone: null, + institution: null, + department: null, + year_of_study: null, + experience_level: null, + registration_data: { + registered_via: 'api_tests_register', + registration_timestamp: new Date().toISOString(), + user_metadata: userMetadata + } + }]) + .select() + + if (error) { + console.error('Registration error:', error) + return NextResponse.json( + { error: 'Failed to register for test', details: error.message }, + { status: 500 } + ) + } + + return NextResponse.json({ + success: true, + data: data[0] + }) + + } catch (error) { + console.error('Error in test registration API:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/app/protected/jobs/page.tsx b/app/protected/jobs/page.tsx new file mode 100644 index 000000000..15ce1ed3d --- /dev/null +++ b/app/protected/jobs/page.tsx @@ -0,0 +1,511 @@ +'use client' + +import { useMemo, useState, useCallback } from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { toast } from 'sonner' +import { useAuth } from '@/lib/hooks/useAuth' +import { useProfile } from '@/hooks/useProfile' +import type { Profile } from '@/types/profile' +import { apiFetch } from '@/lib/api-fetch' + +type Domain = 'Web Development' | 'Python' | 'Artificial Intelligence' | 'Machine Learning' | 'Java' +type Level = 'Beginner' | 'Intermediate' | 'Advanced' + +type Internship = { + id: string + title: string + description: string + type: 'Free' | 'Paid' + domains: Domain[] + levels: Level[] + priceInr?: number + benefits: string[] +} + +const INTERNSHIPS: Internship[] = [ + { + id: 'free-basic', + title: 'Codeunia Starter Internship', + description: 'Learn by doing real tasks with mentor check-ins. Remote friendly.', + type: 'Free', + domains: ['Web Development', 'Python', 'Java'], + levels: ['Beginner', 'Intermediate'], + benefits: [ + 'Mentor-curated task list and review checkpoints', + 'Certificate on successful completion', + 'Access to Codeunia community and weekly standups', + 'Resume and GitHub review at the end', + 'Shortlisted for partner hackathons and projects' + ] + }, + { + id: 'paid-pro', + title: 'Codeunia Pro Internship', + description: 'Work on production-grade projects with weekly reviews and certificate.', + type: 'Paid', + domains: ['Web Development', 'Artificial Intelligence', 'Machine Learning'], + levels: ['Intermediate', 'Advanced'], + priceInr: 4999, + benefits: [ + 'Guaranteed project with production code merges', + '1:1 mentor reviews every week', + 'Priority career guidance + mock interview', + 'Letter of Recommendation (based on performance)', + 'Premium certificate and LinkedIn showcase assets', + 'Early access to partner roles and referrals' + ] + } +] + +export default function JobsPage() { + const { user } = useAuth() + const { profile, isComplete, updateProfile, loading: profileLoading, refresh } = useProfile() + const [appliedIds, setAppliedIds] = useState([]) + const [domainFilter, setDomainFilter] = useState('All') + const [levelFilter, setLevelFilter] = useState('All') + const [applyOpen, setApplyOpen] = useState(false) + const [selected, setSelected] = useState(null) + const [selectedDomain, setSelectedDomain] = useState('') + const [selectedLevel, setSelectedLevel] = useState('') + const [coverNote, setCoverNote] = useState('') + const [savingProfile, setSavingProfile] = useState(false) + const [selectedDuration, setSelectedDuration] = useState('') + const [profileDraft, setProfileDraft] = useState({ + first_name: '', + last_name: '', + github_url: '', + linkedin_url: '' + }) + + const domains = useMemo<('All' | Domain)[]>(() => ['All', 'Web Development', 'Python', 'Artificial Intelligence', 'Machine Learning', 'Java'], []) + const levels = useMemo<('All' | Level)[]>(() => ['All', 'Beginner', 'Intermediate', 'Advanced'], []) + + const filtered = useMemo(() => { + return INTERNSHIPS.filter((i) => (domainFilter === 'All' || i.domains.includes(domainFilter)) && (levelFilter === 'All' || i.levels.includes(levelFilter))) + }, [domainFilter, levelFilter]) + + const openApply = (internship: Internship) => { + setSelected(internship) + setSelectedDomain('') + setSelectedLevel('') + setCoverNote('') + setSelectedDuration('') + setApplyOpen(true) + if (profile && !isComplete) { + setProfileDraft({ + first_name: profile.first_name || '', + last_name: profile.last_name || '', + github_url: profile.github_url || '', + linkedin_url: profile.linkedin_url || '' + }) + } + } + + // Load current user's applications + const loadApplied = useCallback(async () => { + try { + if (!user?.id) return + // Add cache-busting parameter to force fresh data + const res = await apiFetch(`/api/internships/my-applications?t=${Date.now()}`) + const data = await res.json() + if (res.ok && Array.isArray(data.appliedIds)) setAppliedIds(data.appliedIds) + } finally { } + }, [user?.id]) + + // initial and on user change + useMemo(() => { if (user?.id) loadApplied() }, [user?.id, loadApplied]) + + const handleProfileSave = useCallback(async () => { + if (!user?.id) return + try { + setSavingProfile(true) + const ok = await updateProfile(profileDraft as Profile) + if (ok) { + toast.success('Profile updated') + await refresh() + } else { + toast.error('Failed to update profile') + } + } finally { + setSavingProfile(false) + } + }, [profileDraft, updateProfile, refresh, user?.id]) + + const handleApply = useCallback(async () => { + if (!user?.id) { + toast.error('Please sign in to apply') + return + } + if (!selected) return + if (!selectedDomain || !selectedLevel) { + toast.error('Please select domain and level') + return + } + if (!selectedDuration) { + toast.error('Please select duration') + return + } + + try { + if (!user?.id) { + toast.error('Not authenticated. Please sign in again.') + return + } + if (selected.type === 'Paid') { + const basePrice = selectedDuration === 6 ? 999 : 699 + + // Check if user is a premium member for 50% discount + const isPremium = profile?.is_premium && profile?.premium_expires_at && + new Date(profile.premium_expires_at) > new Date() + + const price = isPremium ? Math.floor(basePrice / 2) : basePrice + + if (isPremium) { + toast.success(`🎉 Premium member discount applied! 50% off (₹${basePrice} → ₹${price})`) + } + + // Create Razorpay order + const orderRes = await apiFetch('/api/internships/create-order', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ internshipId: selected.id, amount: price * 100, currency: 'INR' }) + }) + const orderData = await orderRes.json() + if (!orderRes.ok) throw new Error(orderData.error || 'Payment init failed') + + // Load Razorpay and open checkout + const script = document.createElement('script') + script.src = 'https://checkout.razorpay.com/v1/checkout.js' + script.async = true + document.body.appendChild(script) + await new Promise((resolve, reject) => { + script.onload = resolve + script.onerror = reject + }) + + const options = { + key: orderData.key, + amount: price * 100, + currency: 'INR', + name: 'Codeunia', + description: `${selected.title}`, + order_id: orderData.orderId, + handler: async (response: { razorpay_payment_id: string; razorpay_order_id: string; razorpay_signature: string }) => { + // On successful payment, record application with payment details + try { + const basePrice = selectedDuration === 6 ? 999 : 699 + const isPremium = profile?.is_premium && profile?.premium_expires_at && + new Date(profile.premium_expires_at) > new Date() + const finalPrice = isPremium ? Math.floor(basePrice / 2) : basePrice + const discountAmount = basePrice - finalPrice + + const res = await apiFetch('/api/internships/apply', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + internshipId: selected.id, + domain: selectedDomain, + level: selectedLevel, + coverNote, + durationWeeks: selectedDuration, + // Payment information + orderId: orderData.orderId, + paymentId: response.razorpay_payment_id, + paymentSignature: response.razorpay_signature, + amountPaid: finalPrice * 100, // in paise + originalAmount: basePrice * 100, // in paise + discountApplied: discountAmount * 100, // in paise + paymentMethod: 'razorpay' + }) + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Failed to apply') + + toast.success(`🎉 Payment successful! Application submitted for ${selected.title}`) + setApplyOpen(false) + loadApplied() + } catch (error) { + console.error('Error submitting application after payment:', error) + toast.error('Payment successful but application submission failed. Please contact support.') + } + } + } + const razorpay = new (window as unknown as { Razorpay: new (options: object) => { open: () => void } }).Razorpay(options) + razorpay.open() + } else { + // Free internship application + const res = await apiFetch('/api/internships/apply', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + internshipId: selected.id, + domain: selectedDomain, + level: selectedLevel, + coverNote, + durationWeeks: selectedDuration || undefined, + // Payment tracking for free internships (all zero values) + amountPaid: 0, + originalAmount: 0, + discountApplied: 0, + paymentMethod: 'free' + }) + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Failed to apply') + toast.success(`🎉 Application submitted for ${selected.title}!`) + setApplyOpen(false) + loadApplied() + } + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Failed to apply') + } + }, [selected, selectedDomain, selectedLevel, coverNote, user?.id, selectedDuration, loadApplied, profile?.is_premium, profile?.premium_expires_at]) + + return ( +
+
+
+
+
+

Internships at Codeunia

+

Choose Free or Paid programs. Filter by domain and level.

+
+ +
+
+ + +
+
+ + +
+
+ +
+ {filtered.map((i) => ( + + +
+ {i.title} + {i.type} +
+
+ +

{i.description}

+ {i.type === 'Paid' ? ( +
+ Price: + {(() => { + const isPremium = profile?.is_premium && profile?.premium_expires_at && + new Date(profile.premium_expires_at) > new Date() + + if (isPremium) { + return ( +
+
+ ₹350 (4 weeks) / ₹500 (6 weeks) - Premium 50% off! 🎉 +
+
+ Regular: ₹699 (4 weeks) / ₹999 (6 weeks) +
+
+ ) + } else { + return ' ₹699 (4 weeks) / ₹999 (6 weeks)' + } + })()} +
+ ) : ( +
+ Duration: Choose 4 weeks or 6 weeks +
+ )} +
+
What you get
+
    + {i.benefits.map((b) => ( +
  • {b}
  • + ))} +
+
+
+ {i.domains.map((d) => ( + {d} + ))} +
+
+ {i.levels.map((l) => ( + {l} + ))} +
+
+ {appliedIds.includes(i.id) ? ( + + ) : i.type === 'Paid' ? ( + + ) : ( + + )} +
+
+
+ ))} +
+
+
+
+ + {/* Apply Dialog */} + + + + {selected ? `Apply: ${selected.title}` : 'Apply'} + +
+ Select domain, level, and duration. We will use your profile details for one-click apply. + {selected && ( +
+ {selected.type === 'Paid' ? ( +
Price: ₹699 (4 weeks) / ₹999 (6 weeks)
+ ) : ( +
Duration: Choose 4 weeks or 6 weeks
+ )} +
What you get
+
    + {selected.benefits.map((b) => ( +
  • {b}
  • + ))} +
+
+ )} +
+
+
+ +
+
+
+ + +
+
+ + +
+
+ + + {selected?.type === 'Paid' && profile?.is_premium && profile?.premium_expires_at && + new Date(profile.premium_expires_at) > new Date() && ( +

+ 🎉 Premium Member Discount: 50% off applied! +

+ )} +
+
+ +
+ + setCoverNote(e.target.value)} placeholder="One line about your interest" /> +
+ + {!user?.id && ( +

Please sign in to apply.

+ )} + + {user?.id && !profileLoading && !isComplete && ( +
+
Complete your profile
+
+
+ + setProfileDraft((s) => ({ ...s, first_name: e.target.value }))} /> +
+
+ + setProfileDraft((s) => ({ ...s, last_name: e.target.value }))} /> +
+
+ + setProfileDraft((s) => ({ ...s, github_url: e.target.value }))} /> +
+
+ + setProfileDraft((s) => ({ ...s, linkedin_url: e.target.value }))} /> +
+
+
+ +
+
+ )} + +
+ + +
+
+
+
+
+ ) +} diff --git a/app/protected/layout.tsx b/app/protected/layout.tsx index a19952ad8..7f1e335fb 100644 --- a/app/protected/layout.tsx +++ b/app/protected/layout.tsx @@ -70,6 +70,26 @@ const sidebarItems: SidebarGroupType[] = [ }, ], }, + { + title: "Tests & Assessments", + items: [ + { + title: "Test Dashboard", + url: "/protected/tests", + icon: Target, + }, + { + title: "Browse Tests", + url: "/tests", + icon: BookOpen, + }, + { + title: "Leaderboard", + url: "/leaderboard", + icon: Trophy, + }, + ], + }, { title: "Activities", items: [ @@ -88,6 +108,11 @@ const sidebarItems: SidebarGroupType[] = [ url: "/protected/projects", icon: Briefcase, }, + { + title: "Jobs", + url: "/protected/jobs", + icon: Briefcase, + }, { title: "Achievements", url: "/protected/achievements", diff --git a/app/protected/tests/page.tsx b/app/protected/tests/page.tsx new file mode 100644 index 000000000..85212d6f6 --- /dev/null +++ b/app/protected/tests/page.tsx @@ -0,0 +1,562 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { createClient } from "@/lib/supabase/client" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { + Trophy, + Clock, + CheckCircle, + XCircle, + Target, + Award, + Calendar, + Users, + TrendingUp, + BookOpen, + Star, + FileText, + BarChart3 +} from "lucide-react" +import Link from "next/link" +import { motion } from "framer-motion" +import { toast } from "sonner" +import { cn } from "@/lib/utils" + +interface TestRegistration { + id: string + test_id: string + status: string + registration_date: string + test: { + id: string + name: string + description: string + duration_minutes: number + passing_score: number + max_attempts: number + enable_leaderboard: boolean + } +} + +interface TestAttempt { + id: string + test_id: string + score: number + max_score: number + time_taken_minutes: number + passed: boolean + status: string + submitted_at: string + test: { + id: string + name: string + description: string + passing_score: number + enable_leaderboard: boolean + } +} + +interface TestStats { + totalRegistrations: number + totalAttempts: number + passedTests: number + averageScore: number + totalTimeSpent: number + currentRankings: number +} + +export default function TestDashboard() { + const [registrations, setRegistrations] = useState([]) + const [attempts, setAttempts] = useState([]) + const [stats, setStats] = useState({ + totalRegistrations: 0, + totalAttempts: 0, + passedTests: 0, + averageScore: 0, + totalTimeSpent: 0, + currentRankings: 0 + }) + const [loading, setLoading] = useState(true) + const supabase = createClient() + + const fetchUserTestData = useCallback(async () => { + try { + setLoading(true) + + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + toast.error('Please sign in to view your test dashboard') + return + } + + // Fetch test registrations + const { data: registrationsData, error: registrationsError } = await supabase + .from('test_registrations') + .select(` + id, + test_id, + status, + registration_date, + tests!inner( + id, + name, + description, + duration_minutes, + passing_score, + max_attempts, + enable_leaderboard + ) + `) + .eq('user_id', user.id) + .order('registration_date', { ascending: false }) + + if (registrationsError) throw registrationsError + + // Fetch test attempts + const { data: attemptsData, error: attemptsError } = await supabase + .from('test_attempts') + .select(` + id, + test_id, + score, + max_score, + time_taken_minutes, + passed, + status, + submitted_at, + tests!inner( + id, + name, + description, + passing_score, + enable_leaderboard + ) + `) + .eq('user_id', user.id) + .not('submitted_at', 'is', null) + .order('submitted_at', { ascending: false }) + + if (attemptsError) throw attemptsError + + // Process registrations data + const processedRegistrations = (registrationsData || []).map(reg => ({ + id: reg.id, + test_id: reg.test_id, + status: reg.status, + registration_date: reg.registration_date, + test: Array.isArray(reg.tests) ? reg.tests[0] : reg.tests + })) + + // Process attempts data + const processedAttempts = (attemptsData || []).map(attempt => ({ + id: attempt.id, + test_id: attempt.test_id, + score: attempt.score, + max_score: attempt.max_score, + time_taken_minutes: attempt.time_taken_minutes, + passed: attempt.passed, + status: attempt.status, + submitted_at: attempt.submitted_at, + test: Array.isArray(attempt.tests) ? attempt.tests[0] : attempt.tests + })) + + // Calculate statistics + const totalRegistrations = processedRegistrations.length + const totalAttempts = processedAttempts.length + const passedTests = processedAttempts.filter(attempt => attempt.passed).length + const averageScore = totalAttempts > 0 + ? Math.round(processedAttempts.reduce((sum, attempt) => sum + (attempt.score / attempt.max_score * 100), 0) / totalAttempts) + : 0 + const totalTimeSpent = processedAttempts.reduce((sum, attempt) => sum + attempt.time_taken_minutes, 0) + + setRegistrations(processedRegistrations) + setAttempts(processedAttempts) + setStats({ + totalRegistrations, + totalAttempts, + passedTests, + averageScore, + totalTimeSpent, + currentRankings: 0 // TODO: Calculate actual rankings + }) + + } catch (error) { + console.error('Error fetching test data:', error) + toast.error('Failed to load test dashboard') + } finally { + setLoading(false) + } + }, [supabase]) + + useEffect(() => { + fetchUserTestData() + }, [fetchUserTestData]) + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + } + + const formatTime = (minutes: number) => { + const hours = Math.floor(minutes / 60) + const mins = minutes % 60 + if (hours > 0) { + return `${hours}h ${mins}m` + } + return `${mins}m` + } + + const formatScore = (score: number, maxScore: number) => { + const percentage = Math.round((score / maxScore) * 100) + return `${score}/${maxScore} (${percentage}%)` + } + + const getStatusBadge = (status: string) => { + switch (status) { + case 'registered': + return Registered + case 'attempted': + return Attempted + case 'completed': + return Completed + default: + return {status} + } + } + + const getPassFailBadge = (passed: boolean) => { + return passed ? ( + + + Passed + + ) : ( + + + Failed + + ) + } + + if (loading) { + return ( +
+
+
+
+
+

Loading your test dashboard...

+
+
+
+
+ ) + } + + return ( +
+
+
+ {/* Header */} +
+

+ Test Dashboard +

+

+ Track your test registrations, attempts, and achievements +

+
+ + {/* Statistics Cards */} +
+ + +
+
+

Total Registrations

+

{stats.totalRegistrations}

+
+ +
+
+
+ + + +
+
+

Tests Attempted

+

{stats.totalAttempts}

+
+ +
+
+
+ + + +
+
+

Tests Passed

+

{stats.passedTests}

+
+ +
+
+
+ + + +
+
+

Average Score

+

{stats.averageScore}%

+
+ +
+
+
+
+ + {/* Main Content Tabs */} + + + Registrations + Test Results + Achievements + + + {/* Registrations Tab */} + + + + + + Test Registrations + + + Your test registration history and current status + + + + {registrations.length === 0 ? ( +
+ +

No test registrations yet

+ + + +
+ ) : ( +
+ {registrations.map((registration, index) => ( + +
+
+

{registration.test.name}

+ {getStatusBadge(registration.status)} +
+

+ {registration.test.description} +

+
+ + + {registration.test.duration_minutes}m + + + + {registration.test.passing_score}% pass + + + + {registration.test.max_attempts} attempts + + {registration.test.enable_leaderboard && ( + + + Leaderboard + + )} +
+
+
+

+ Registered: {formatDate(registration.registration_date)} +

+ + + +
+
+ ))} +
+ )} +
+
+
+ + {/* Test Results Tab */} + + + + + + Test Results + + + Your test attempts and performance history + + + + {attempts.length === 0 ? ( +
+ +

No test attempts yet

+ + + +
+ ) : ( +
+ {attempts.map((attempt, index) => ( + +
+
+

{attempt.test.name}

+ {getPassFailBadge(attempt.passed)} +
+

+ {attempt.test.description} +

+
+ + + Score: {formatScore(attempt.score, attempt.max_score)} + + + + Time: {formatTime(attempt.time_taken_minutes)} + + {attempt.test.enable_leaderboard && ( + + + + View Leaderboard + + + )} +
+
+
+

+ Completed: {formatDate(attempt.submitted_at)} +

+ + + +
+
+ ))} +
+ )} +
+
+
+ + {/* Achievements Tab */} + +
+ + + + + Performance Summary + + + +
+ Success Rate + + {stats.totalAttempts > 0 ? Math.round((stats.passedTests / stats.totalAttempts) * 100) : 0}% + +
+
+ Total Time Spent + {formatTime(stats.totalTimeSpent)} +
+
+ Tests Completed + {stats.totalAttempts} +
+
+
+ + + + + + Quick Actions + + + + + + + + + + + + +
+
+
+
+
+
+ ) +} diff --git a/app/tests/[id]/leaderboard/page.tsx b/app/tests/[id]/leaderboard/page.tsx index fb8e6067b..80ce397aa 100644 --- a/app/tests/[id]/leaderboard/page.tsx +++ b/app/tests/[id]/leaderboard/page.tsx @@ -86,24 +86,42 @@ export default function LeaderboardPage() { if (leaderboardError) throw leaderboardError - if (leaderboardError) throw leaderboardError + // Fetch user profiles for all participants + const userIds = leaderboardData?.map(entry => entry.user_id) || [] + const { data: profilesData, error: profilesError } = await supabase + .from('profiles') + .select('id, first_name, last_name, username, email') + .in('id', userIds) - // For now, we'll use user_id as display name since we can't easily fetch user details - // In a production app, you might want to store user display names in a separate table + // Create a map of user_id to profile data + const profilesMap = new Map() + if (!profilesError && profilesData) { + profilesData.forEach(profile => { + profilesMap.set(profile.id, profile) + }) + } - // Process leaderboard data - const processedLeaderboard = (leaderboardData || []).map((entry, index) => ({ - id: entry.id, - user_id: entry.user_id, - user_email: `User ${entry.user_id.slice(0, 8)}...`, // Show partial user ID - user_name: `User ${entry.user_id.slice(0, 8)}...`, // Show partial user ID - score: entry.score, - max_score: entry.max_score, - time_taken_minutes: entry.time_taken_minutes, - passed: entry.passed, - submitted_at: entry.submitted_at, - rank: index + 1 - })) + // Process leaderboard data with proper user names + const processedLeaderboard = (leaderboardData || []).map((entry, index) => { + const profile = profilesMap.get(entry.user_id) + const displayName = profile?.username || + (profile?.first_name && profile?.last_name ? + `${profile.first_name} ${profile.last_name}` : + `User ${entry.user_id.slice(0, 8)}...`) + + return { + id: entry.id, + user_id: entry.user_id, + user_email: profile?.email || `User ${entry.user_id.slice(0, 8)}...`, + user_name: displayName, + score: entry.score, + max_score: entry.max_score, + time_taken_minutes: entry.time_taken_minutes, + passed: entry.passed, + submitted_at: entry.submitted_at, + rank: index + 1 + } + }) setLeaderboard(processedLeaderboard) diff --git a/app/tests/[id]/page.tsx b/app/tests/[id]/page.tsx index 14ea71be4..43e25589a 100644 --- a/app/tests/[id]/page.tsx +++ b/app/tests/[id]/page.tsx @@ -155,17 +155,43 @@ export default function TestDetailPage() { return } - // Register for the test with additional data + // Check if already registered to prevent duplicates + const { data: existingRegistration } = await supabase + .from('test_registrations') + .select('id') + .eq('test_id', testId) + .eq('user_id', user.id) + .single(); + + if (existingRegistration) { + toast.error('You are already registered for this test'); + setIsRegistered(true); + return; + } + + // Register for the test with all required fields const { error } = await supabase .from('test_registrations') .insert([{ test_id: testId, user_id: user.id, status: 'registered', + attempt_count: 0, + registration_date: new Date().toISOString(), + full_name: registrationData.full_name, + email: registrationData.email, + phone: registrationData.phone || null, + institution: registrationData.institution || null, + department: registrationData.department || null, + year_of_study: registrationData.year_of_study || null, + experience_level: registrationData.experience_level || null, registration_data: registrationData // Store additional data as JSON }]) - if (error) throw error + if (error) { + console.error('Registration error:', error); + throw error; + } toast.success('Successfully registered for the test!') setIsRegistered(true) @@ -183,8 +209,9 @@ export default function TestDetailPage() { experience_level: '', agree_to_terms: false }) - } catch { - toast.error('Failed to register for test') + } catch (error) { + console.error('Registration failed:', error); + toast.error('Failed to register for test. Please try again.'); } } diff --git a/app/tests/[id]/results/page.tsx b/app/tests/[id]/results/page.tsx index f5386de3b..72cd46c36 100644 --- a/app/tests/[id]/results/page.tsx +++ b/app/tests/[id]/results/page.tsx @@ -15,9 +15,18 @@ import Footer from "@/components/footer" import type { Test, TestAttempt } from "@/types/test-management" import { CertificateGenerator } from '@/components/CertificateGenerator'; +interface TestAttemptWithProfile extends TestAttempt { + profiles?: { + first_name?: string; + last_name?: string; + username?: string; + email?: string; + }; +} + export default function TestResultsPage() { const [test, setTest] = useState(null) - const [attempt, setAttempt] = useState(null) + const [attempt, setAttempt] = useState(null) const [loading, setLoading] = useState(true) const params = useParams() const searchParams = useSearchParams() @@ -57,6 +66,20 @@ export default function TestResultsPage() { if (attemptError) throw attemptError setAttempt(attemptData) + + // Fetch user profile separately + if (attemptData?.user_id) { + const { data: profileData, error: profileError } = await supabase + .from('profiles') + .select('first_name, last_name, username, email') + .eq('id', attemptData.user_id) + .single() + + if (!profileError && profileData) { + // Add profile data to attempt object + setAttempt(prev => prev ? { ...prev, profiles: profileData } : prev) + } + } } catch (error) { toast.error('Failed to load results') console.error('Error fetching results:', error) @@ -368,11 +391,14 @@ export default function TestResultsPage() { t.id === testId); - console.log('Test details:', test); - - // Register for the test - const { error } = await getSupabaseClient() + // Double-check with database to prevent duplicate registrations + const { data: existingRegistration } = await getSupabaseClient() .from('test_registrations') - .insert([{ - test_id: testId, - user_id: user.id, - status: 'registered' - }]); - - if (error) { - console.error('Registration error:', error); - console.error('Error details:', { - message: error.message, - details: error.details, - hint: error.hint, - code: error.code - }); - throw error; + .select('id') + .eq('test_id', testId) + .eq('user_id', user.id) + .single(); + + if (existingRegistration) { + setUserRegistrations(prev => new Set([...prev, testId])); + toast.error('You are already registered for this test'); + return; + } + + // Register using the API endpoint + const response = await fetch('/api/tests/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + testId: testId, + userId: user.id, + userEmail: user.email, + userMetadata: user.user_metadata + }) + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || 'Registration failed'); } toast.success('Successfully registered for the test!'); // Update local state to reflect the new registration setUserRegistrations(prev => new Set([...prev, testId])); fetchTests(); // Refresh to update registration count - } catch { - toast.error('Failed to register for test'); + } catch (error) { + console.error('Registration failed:', error); + toast.error('Failed to register for test. Please try again.'); } }; @@ -332,25 +340,35 @@ export default function TestsPage() { const regStart = test.registration_start ? new Date(test.registration_start) : null; const regEnd = test.registration_end ? new Date(test.registration_end) : null; + console.log('🔍 Registration status check for test:', test.name); + console.log(' - Now:', now.toISOString()); + console.log(' - Reg start:', regStart?.toISOString()); + console.log(' - Reg end:', regEnd?.toISOString()); + console.log(' - User registered:', userRegistrations.has(test.id)); + // Check if user is registered for this test if (userRegistrations.has(test.id)) { + console.log(' - Status: registered'); return { status: 'registered', badge: Registered }; } // Check registration dates if (regStart && now < regStart) { + console.log(' - Status: pending'); return { status: 'pending', badge: Registration Pending, message: `Registration starts ${regStart.toLocaleDateString()} at ${regStart.toLocaleTimeString()}` }; } else if (regEnd && now > regEnd) { + console.log(' - Status: closed'); return { status: 'closed', badge: Registration Closed, message: `Registration ended ${regEnd.toLocaleDateString()} at ${regEnd.toLocaleTimeString()}` }; } else { + console.log(' - Status: open'); return { status: 'open', badge: Registration Open, diff --git a/app/projects/page.tsx b/app/zenith-hall/page.tsx similarity index 94% rename from app/projects/page.tsx rename to app/zenith-hall/page.tsx index 890ea47c6..575fcb13e 100644 --- a/app/projects/page.tsx +++ b/app/zenith-hall/page.tsx @@ -96,7 +96,7 @@ export default function ProjectsPage() {
- Projects Showcase + Hall of Excellence
@@ -104,10 +104,10 @@ export default function ProjectsPage() {

- Intern Projects Showcase + Zenith Hall

-

- Explore outstanding projects created by our talented interns during their internship at Codeunia. +

+ A tribute to Codeunia's finest minds who have risen above challenges with exceptional skill and innovation. Here, we showcase our best members whose remarkable projects and achievements reflect technical excellence, creative problem-solving, and the spirit of collaboration that defines our community.

@@ -121,7 +121,7 @@ export default function ProjectsPage() { setSearchTerm(e.target.value)} @@ -130,7 +130,7 @@ export default function ProjectsPage() { - Filter by Tags + Filter by Expertise Areas {allTags.map((tag) => ( + Admin Navigation Menu
{/* mobile header */}
diff --git a/components/ai/AIChat.tsx b/components/ai/AIChat.tsx index 29295261d..17cd25209 100644 --- a/components/ai/AIChat.tsx +++ b/components/ai/AIChat.tsx @@ -182,8 +182,8 @@ export default function AIChat() { } }, []); - // Hide the widget if we're on the dedicated AI page - if (currentPath === '/ai') { + // Hide the widget if we're on the dedicated AI page or during test taking + if (currentPath === '/ai' || currentPath.includes('/tests/') && currentPath.includes('/take')) { return null; } diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx index 830472ffb..93298e60c 100644 --- a/components/dashboard/DashboardContent.tsx +++ b/components/dashboard/DashboardContent.tsx @@ -44,6 +44,7 @@ export function DashboardContent({ userId, displayName }: DashboardContentProps)
+ {/* Contribution Graph Section */} }>
diff --git a/components/dashboard/TestStatsWidget.tsx b/components/dashboard/TestStatsWidget.tsx new file mode 100644 index 000000000..c72d9aec9 --- /dev/null +++ b/components/dashboard/TestStatsWidget.tsx @@ -0,0 +1,245 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import { createClient } from '@/lib/supabase/client' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Target, + Trophy, + CheckCircle, + Clock, + BookOpen +} from 'lucide-react' +import Link from 'next/link' +import { motion } from 'framer-motion' + +interface TestStats { + totalRegistrations: number + totalAttempts: number + passedTests: number + averageScore: number + totalTimeSpent: number + recentAttempts: Array<{ + id: string + test_name: string + score: number + max_score: number + passed: boolean + submitted_at: string + }> +} + +interface TestStatsWidgetProps { + userId: string +} + +export function TestStatsWidget({ userId }: TestStatsWidgetProps) { + const [stats, setStats] = useState({ + totalRegistrations: 0, + totalAttempts: 0, + passedTests: 0, + averageScore: 0, + totalTimeSpent: 0, + recentAttempts: [] + }) + const [loading, setLoading] = useState(true) + const supabase = createClient() + + useEffect(() => { + const fetchTestStats = async () => { + try { + setLoading(true) + + // Fetch test registrations count + const { count: registrationsCount } = await supabase + .from('test_registrations') + .select('*', { count: 'exact', head: true }) + .eq('user_id', userId) + + // Fetch test attempts with basic stats + const { data: attemptsData, error: attemptsError } = await supabase + .from('test_attempts') + .select(` + id, + score, + max_score, + time_taken_minutes, + passed, + submitted_at, + tests!inner( + name + ) + `) + .eq('user_id', userId) + .not('submitted_at', 'is', null) + .order('submitted_at', { ascending: false }) + .limit(5) + + if (attemptsError) throw attemptsError + + const totalAttempts = attemptsData?.length || 0 + const passedTests = attemptsData?.filter(attempt => attempt.passed).length || 0 + const averageScore = totalAttempts > 0 + ? Math.round(attemptsData.reduce((sum, attempt) => sum + (attempt.score / attempt.max_score * 100), 0) / totalAttempts) + : 0 + const totalTimeSpent = attemptsData?.reduce((sum, attempt) => sum + (attempt.time_taken_minutes || 0), 0) || 0 + + const recentAttempts = (attemptsData || []).map(attempt => ({ + id: attempt.id, + test_name: (attempt.tests as { name?: string })?.name || 'Unknown Test', + score: attempt.score, + max_score: attempt.max_score, + passed: attempt.passed, + submitted_at: attempt.submitted_at + })) + + setStats({ + totalRegistrations: registrationsCount || 0, + totalAttempts, + passedTests, + averageScore, + totalTimeSpent, + recentAttempts + }) + + } catch (error) { + console.error('Error fetching test stats:', error) + } finally { + setLoading(false) + } + } + + if (userId) { + fetchTestStats() + } + }, [userId, supabase]) + + + const formatScore = (score: number, maxScore: number) => { + const percentage = Math.round((score / maxScore) * 100) + return `${percentage}%` + } + + if (loading) { + return ( + + + + + Test Performance + + + +
+
+
+
+
+
+
+ ) + } + + return ( + + + + + Test Performance + + + + {/* Quick Stats */} +
+
+
+ {stats.totalAttempts} +
+
Tests Taken
+
+
+
+ {stats.averageScore}% +
+
Avg Score
+
+
+ + {/* Success Rate */} +
+
+ + Success Rate +
+ + {stats.totalAttempts > 0 ? Math.round((stats.passedTests / stats.totalAttempts) * 100) : 0}% + +
+ + {/* Recent Attempts */} + {stats.recentAttempts.length > 0 && ( +
+

Recent Tests

+
+ {stats.recentAttempts.slice(0, 3).map((attempt, index) => ( + +
+

{attempt.test_name}

+

+ {formatScore(attempt.score, attempt.max_score)} +

+
+
+ {attempt.passed ? ( + + ) : ( + + )} +
+
+ ))} +
+
+ )} + + {/* Action Buttons */} +
+ + + + + + +
+ + {/* No tests message */} + {stats.totalAttempts === 0 && ( +
+ +

No tests taken yet

+ + + +
+ )} +
+
+ ) +} diff --git a/components/header.tsx b/components/header.tsx index 5c78180ae..b67e712a5 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -58,7 +58,7 @@ export default function Header() { { href: "/", label: "Home" }, { href: "/about", label: "About" }, { href: "/opportunities", label: "Opportunities" }, - { href: "/projects", label: "Projects" }, + { href: "/zenith-hall", label: "Zenith Hall" }, { href: "/blog", label: "Blog" }, { href: "/join", label: "Join Codeunia" }, { href: "/contact", label: "Contact Us" }, diff --git a/components/users/PublicProfileView.tsx b/components/users/PublicProfileView.tsx index d26f3966f..887627427 100644 --- a/components/users/PublicProfileView.tsx +++ b/components/users/PublicProfileView.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { useState, useEffect } from 'react' +import React from 'react' import { useAuth } from '@/lib/hooks/useAuth' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' @@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button' import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { ContributionGraph } from '@/components/ui/contribution-graph' import { useContributionGraph } from '@/hooks/useContributionGraph' -import { Profile } from '@/types/profile' +import { usePublicProfileByUsername } from '@/hooks/useProfile' import { User, MapPin, @@ -33,9 +33,7 @@ interface PublicProfileViewProps { export function PublicProfileView({ username }: PublicProfileViewProps) { const { user } = useAuth() - const [profile, setProfile] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const { profile, loading, error } = usePublicProfileByUsername(username) const { data: activityData, @@ -44,33 +42,6 @@ export function PublicProfileView({ username }: PublicProfileViewProps) { refresh: refreshActivity } = useContributionGraph() - // Direct API call to fetch profile - useEffect(() => { - const fetchProfile = async () => { - try { - setLoading(true) - setError(null) - - const response = await fetch(`/api/debug/profile/${username}`) - const data = await response.json() - - if (data.debug.profile) { - setProfile(data.debug.profile) - } else { - setError('Profile not found') - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch profile') - } finally { - setLoading(false) - } - } - - if (username) { - fetchProfile() - } - }, [username]) - const isOwnProfile = user?.id === profile?.id if (loading) { @@ -183,11 +154,6 @@ export function PublicProfileView({ username }: PublicProfileViewProps) {
-
- - {getFullName()} - -
{hasCompleteProfessionalInfo && (
@@ -271,11 +237,6 @@ export function PublicProfileView({ username }: PublicProfileViewProps) {
-
- - {getFullName()} - -
{hasCompleteProfessionalInfo && (
diff --git a/components/users/StudentSidebar.tsx b/components/users/StudentSidebar.tsx index eed57a236..58fdead89 100644 --- a/components/users/StudentSidebar.tsx +++ b/components/users/StudentSidebar.tsx @@ -33,7 +33,7 @@ import { SidebarProvider, } from "@/components/ui/sidebar" import { Button } from "@/components/ui/button" -import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" +import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet" import CodeuniaLogo from "../codeunia-logo"; @@ -86,6 +86,7 @@ export function StudentSidebar({ avatar, name, email, sidebarItems, children }: + Student Navigation Menu
{/* mobile header */}
diff --git a/hooks/useProfile.ts b/hooks/useProfile.ts index e943654cb..af2fe66fe 100644 --- a/hooks/useProfile.ts +++ b/hooks/useProfile.ts @@ -130,10 +130,13 @@ export function usePublicProfileByUsername(username: string | null) { try { setLoading(true) setError(null) - console.log('usePublicProfileByUsername: Fetching profile for username:', username) - const profileData = await profileService.getPublicProfileByUsername(username) - console.log('usePublicProfileByUsername: Profile data received:', profileData) - setProfile(profileData) + const response = await fetch(`/api/profile/${username}`) + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to fetch profile') + } + setProfile(data.profile) } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to fetch profile' console.error('usePublicProfileByUsername: Error fetching profile:', err) diff --git a/lib/security/input-validation.ts b/lib/security/input-validation.ts index 36eca18c3..07f1251dc 100644 --- a/lib/security/input-validation.ts +++ b/lib/security/input-validation.ts @@ -1,9 +1,6 @@ -import DOMPurify from 'dompurify'; -import { JSDOM } from 'jsdom'; - -// Create a DOMPurify instance for server-side use -const window = new JSDOM('').window; -const purify = DOMPurify(window as any); +import DOMPurify from 'isomorphic-dompurify'; +// Works in both server and client without jsdom +const purify = DOMPurify; export interface ValidationResult { isValid: boolean; @@ -467,6 +464,10 @@ export function withInputValidation>( for (const [key, options] of Object.entries(schema)) { const value = body[key]; + if (typeof value !== 'string') { + errors.push(`${key}: must be a string`); + continue; + } const result = InputValidator.validateText(value, options); if (!result.isValid) { diff --git a/lib/seo/metadata.ts b/lib/seo/metadata.ts index b457fc735..b0019a163 100644 --- a/lib/seo/metadata.ts +++ b/lib/seo/metadata.ts @@ -55,6 +55,20 @@ export function generateMetadata(config: SEOConfig): Metadata { creator: author, publisher: defaultConfig.siteName, + // Favicon configuration + icons: { + icon: [ + { url: '/favicon.ico', type: 'image/x-icon' }, + { url: '/codeunia-favicon-light.svg', media: '(prefers-color-scheme: light)' }, + { url: '/codeunia-favicon-dark.svg', media: '(prefers-color-scheme: dark)' }, + { url: '/codeunia-favicon-light.svg' } + ], + apple: [ + { url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' } + ], + shortcut: '/favicon.ico' + }, + // Open Graph openGraph: { type, diff --git a/lib/services/profile.ts b/lib/services/profile.ts index 470e62954..aaeff03d5 100644 --- a/lib/services/profile.ts +++ b/lib/services/profile.ts @@ -132,8 +132,6 @@ export class ProfileService { // Get public profile by username (for viewing other users) async getPublicProfileByUsername(username: string): Promise { - console.log('profileService.getPublicProfileByUsername: Starting with username:', username) - const supabase = this.getSupabaseClient(); const { data, error } = await supabase .from('profiles') @@ -142,18 +140,14 @@ export class ProfileService { .eq('is_public', true) .single() - console.log('profileService.getPublicProfileByUsername: Supabase response:', { data, error }) - if (error) { if (error.code === 'PGRST116') { - console.log('profileService.getPublicProfileByUsername: Profile not found or not public') return null // Profile not found or not public } console.error('Error fetching public profile by username:', error) throw new Error(`Failed to fetch public profile: ${error.message}`) } - console.log('profileService.getPublicProfileByUsername: Returning profile:', data) return data } diff --git a/next.config.ts b/next.config.ts index 58fafb766..f2efcfa5d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -60,6 +60,13 @@ const nextConfig: NextConfig = { compress: true, poweredByHeader: false, + async redirects() { + return [ + { source: '/projects', destination: '/zenith-hall', permanent: true }, + { source: '/projects/:path*', destination: '/zenith-hall', permanent: true }, + ]; + }, + async headers() { const isDev = process.env.NODE_ENV === 'development' const isProd = process.env.NODE_ENV === 'production' diff --git a/package-lock.json b/package-lock.json index c3ac92490..f3ff6bd5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "html-to-image": "^1.11.13", "html2canvas": "^1.4.1", "ioredis": "^5.7.0", + "isomorphic-dompurify": "^2.26.0", "jspdf": "^3.0.1", "keen-slider": "^6.8.6", "lucide-react": "^0.511.0", @@ -131,6 +132,19 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -677,6 +691,116 @@ "dev": true, "license": "MIT" }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@dimforge/rapier3d-compat": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", @@ -7666,7 +7790,6 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, "license": "MIT" }, "node_modules/decimal.js-light": { @@ -10073,7 +10196,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -10644,7 +10766,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, "license": "MIT" }, "node_modules/is-promise": { @@ -10830,6 +10951,219 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/isomorphic-dompurify": { + "version": "2.26.0", + "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-2.26.0.tgz", + "integrity": "sha512-nZmoK4wKdzPs5USq4JHBiimjdKSVAOm2T1KyDoadtMPNXYHxiENd19ou4iU/V4juFM6LVgYQnpxCYmxqNP4Obw==", + "license": "MIT", + "dependencies": { + "dompurify": "^3.2.6", + "jsdom": "^26.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/isomorphic-dompurify/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/isomorphic-dompurify/node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/isomorphic-dompurify/node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/isomorphic-dompurify/node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/isomorphic-dompurify/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/isomorphic-dompurify/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/isomorphic-dompurify/node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/isomorphic-dompurify/node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/isomorphic-dompurify/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/isomorphic-dompurify/node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/isomorphic-dompurify/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/isomorphic-dompurify/node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/isomorphic-dompurify/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/isomorphic-dompurify/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/isomorphic-dompurify/node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/isows": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", @@ -12447,7 +12781,6 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, "license": "ISC" }, "node_modules/lucide-react": { @@ -13569,7 +13902,6 @@ "version": "2.2.21", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.21.tgz", "integrity": "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA==", - "dev": true, "license": "MIT" }, "node_modules/object-assign": { @@ -14520,7 +14852,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -15469,6 +15800,12 @@ "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", "license": "Unlicense" }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -15572,14 +15909,12 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -16596,7 +16931,6 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, "license": "MIT" }, "node_modules/tailwind-merge": { @@ -17088,6 +17422,24 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -18362,7 +18714,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, "license": "MIT" }, "node_modules/y18n": { diff --git a/package.json b/package.json index 835761c8f..648814606 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "html-to-image": "^1.11.13", "html2canvas": "^1.4.1", "ioredis": "^5.7.0", + "isomorphic-dompurify": "^2.26.0", "jspdf": "^3.0.1", "keen-slider": "^6.8.6", "lucide-react": "^0.511.0", diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 000000000..6ff8ab2e2 --- /dev/null +++ b/public/apple-touch-icon.png @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 000000000..6ff8ab2e2 --- /dev/null +++ b/public/favicon.ico @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file