From 852539cd2ea14fa651ebe2fea742760c5e4ca3b9 Mon Sep 17 00:00:00 2001 From: Kewonit <108450560+kewonit@users.noreply.github.com.> Date: Fri, 4 Jul 2025 17:25:00 +0530 Subject: [PATCH] feat: refactor signup page to improve user experience and integrate new signup logic - Updated the signup form to include full name and password confirmation fields. - Enhanced error handling and user feedback with messages for signup status. - Changed the redirect logic after signup to improve navigation. - Updated the SubmitButton component to reflect the new action and text. fix: update community partners member counts - Adjusted member counts for Reddit communities to reflect current statistics. feat: add authentication utilities for PocketBase - Created auth.ts to manage user authentication and retrieval of current user data. - Implemented functions to check authentication status and user validity. refactor: enhance PocketBase client for user authentication - Added ensureUserAuthenticated function to validate user sessions and refresh tokens. - Deprecated the old server-side authentication method in favor of the new user-based approach. fix: update middleware for authentication handling - Improved middleware to manage authentication cookies and refresh tokens effectively. - Ensured proper cleanup of invalid authentication cookies on errors. --- app/account/account-form.tsx | 221 ++--- app/account/page.tsx | 16 +- app/api/mht-cet/state-cutoffs/export/route.ts | 124 ++- app/api/mht-cet/state-cutoffs/route.ts | 514 ++++------ .../bits/cutoffs/components/authbutton.tsx | 98 +- app/login/actions.tsx | 239 ++++- app/login/page.tsx | 89 +- app/mht-cet-login-required/layout.tsx | 8 + app/mht-cet-login-required/page.tsx | 77 ++ .../components/authbutton.tsx | 137 ++- .../components/authbutton.tsx | 98 +- app/mht-cet/layout.tsx | 16 + app/mht-cet/state-cutoffs/page.tsx | 917 +++++++++++------- app/signup/page.tsx | 199 ++-- components/ui/community-partners.tsx | 38 +- lib/auth.ts | 100 ++ lib/pocketbaseClient.ts | 47 +- middleware.ts | 49 +- 18 files changed, 1746 insertions(+), 1241 deletions(-) create mode 100644 app/mht-cet-login-required/layout.tsx create mode 100644 app/mht-cet-login-required/page.tsx create mode 100644 app/mht-cet/layout.tsx create mode 100644 lib/auth.ts diff --git a/app/account/account-form.tsx b/app/account/account-form.tsx index 7940817..edac0d3 100644 --- a/app/account/account-form.tsx +++ b/app/account/account-form.tsx @@ -1,162 +1,109 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' -import { createClient } from '@/utils/supabase/client' -import { type User } from '@supabase/supabase-js' -import Avatar from './avatar' +import { useEffect, useState } from 'react' +import { type User } from '@/lib/auth' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' -import { ToastAction } from '@/components/ui/toast' -import { useToast } from '@/components/ui/use-toast' +import { signOut, updateProfile } from '@/app/login/actions' +import { SubmitButton } from '@/app/login/sumbit-button' -export default function AccountForm({ user }: { user: User | null }) { - const supabase = createClient() - const [loading, setLoading] = useState(true) - const [fullname, setFullname] = useState(null) - const [username, setUsername] = useState(null) - const [website, setWebsite] = useState(null) - const [avatar_url, setAvatarUrl] = useState(null) - const { toast } = useToast() - - const getProfile = useCallback(async () => { - try { - setLoading(true) - - const { data, error, status } = await supabase - .from('profiles') - .select(`full_name, username, website, avatar_url`) - .eq('id', user?.id) - .single() - - if (error && status !== 406) { - console.log(error) - throw error - } - - if (data) { - setFullname(data.full_name) - setUsername(data.username) - setWebsite(data.website) - setAvatarUrl(data.avatar_url) - } - } catch (error) { - toast({ - title: "Error", - description: "Error loading user data!", - variant: "destructive", - }) - } finally { - setLoading(false) - } - }, [user, supabase, toast]) +export default function AccountForm({ user, message }: { user: User | null; message?: string }) { + const [name, setName] = useState(user?.name || '') + const [email, setEmail] = useState(user?.email || '') useEffect(() => { - getProfile() - }, [user, getProfile]) - - async function updateProfile({ - username, - website, - avatar_url, - }: { - username: string | null - fullname: string | null - website: string | null - avatar_url: string | null - }) { - try { - setLoading(true) - - const { error } = await supabase.from('profiles').upsert({ - id: user?.id as string, - full_name: fullname, - username, - website, - avatar_url, - updated_at: new Date().toISOString(), - }) - if (error) throw error - toast({ - title: "Success", - description: "Profile updated successfully!", - action: Dismiss, - }) - } catch (error) { - toast({ - title: "Error", - description: "Error updating the data!", - variant: "destructive", - }) - } finally { - setLoading(false) + if (user) { + setName(user.name) + setEmail(user.email) } + }, [user]) + + if (!user) { + return
Loading...
} return (
+
- {/* - { - setAvatarUrl(url) - updateProfile({ fullname, username, website, avatar_url: url }) - }} - /> - */} - - +

Account Settings

-
- - setFullname(e.target.value)} - /> -
-
- + + {message && ( +
+

{message}

+
+ )} + +
+
+ setUsername(e.target.value)} + id="email" + type="email" + value={email} + disabled + className="mt-1" /> -
- {/* -
- +

+ Email cannot be changed. Contact support if you need to update your email. +

+
+ +
+ setWebsite(e.target.value)} + id="name" + name="name" + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + placeholder="Enter your full name" + className="mt-1" + required + minLength={2} /> -
- */} -
-
+
+ +
+ +
+ + {user.verified ? 'Verified' : 'Unverified'} + +
+
+ +
+
+ + Update Profile + +
+
+ +
-
-
- -
-
-
+
+ +
) } \ No newline at end of file diff --git a/app/account/page.tsx b/app/account/page.tsx index 7a36e31..4138dec 100644 --- a/app/account/page.tsx +++ b/app/account/page.tsx @@ -1,12 +1,14 @@ import AccountForm from './account-form' -import { createClient } from '@/utils/supabase/server' +import { getCurrentUser } from '@/lib/auth' +import { redirect } from 'next/navigation' -export default async function Account() { - const supabase = createClient() +export default async function Account({ searchParams }: { searchParams: Promise<{ message?: string }> }) { + const user = await getCurrentUser() + const params = await searchParams - const { - data: { user }, - } = await supabase.auth.getUser() + if (!user) { + redirect('/login') + } - return + return } \ No newline at end of file diff --git a/app/api/mht-cet/state-cutoffs/export/route.ts b/app/api/mht-cet/state-cutoffs/export/route.ts index 95d32ac..0092fb7 100644 --- a/app/api/mht-cet/state-cutoffs/export/route.ts +++ b/app/api/mht-cet/state-cutoffs/export/route.ts @@ -1,24 +1,130 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getPocketBase, ensureAuthenticatedServer } from '@/lib/pocketbaseClient'; +import { getPocketBase, ensureUserAuthenticated } from '@/lib/pocketbaseClient'; export async function GET(request: NextRequest) { try { + const url = new URL(request.url); + const searchParams = url.searchParams; + + // Parse query parameters for filtering + const search = searchParams.get('search') || ''; + const categories = searchParams.getAll('categories'); + const courses = searchParams.getAll('courses'); + const statuses = searchParams.getAll('statuses'); + const homeUniversities = searchParams.getAll('homeUniversities'); + const percentileInput = searchParams.get('percentileInput') || ''; + const pb = getPocketBase(); let allRecords; try { - // Ensure authentication before making the request - await ensureAuthenticatedServer(); + // Ensure user authentication before making the request + await ensureUserAuthenticated(); + + // Helper function to build filter query parts + const buildFilterParts = (courseChunk?: string[]) => { + const filterParts: string[] = []; + + if (search) { + filterParts.push(`(college_name ~ "${search}" || course_name ~ "${search}")`); + } + + if (categories && categories.length > 0) { + const categoryFilter = categories.map((cat: string) => `category = "${cat}"`).join(' || '); + filterParts.push(`(${categoryFilter})`); + } + + // Use courseChunk if provided, otherwise use all courses + const coursesToFilter = courseChunk || courses; + if (coursesToFilter && coursesToFilter.length > 0) { + const courseFilter = coursesToFilter.map((course: string) => `course_name = "${course}"`).join(' || '); + filterParts.push(`(${courseFilter})`); + } + + if (statuses && statuses.length > 0) { + const statusFilter = statuses.map((status: string) => `status = "${status}"`).join(' || '); + filterParts.push(`(${statusFilter})`); + } + + if (homeUniversities && homeUniversities.length > 0) { + const homeUniversityFilter = homeUniversities.map((uni: string) => `home_university = "${uni}"`).join(' || '); + filterParts.push(`(${homeUniversityFilter})`); + } + + // Percentile-based filtering + if (percentileInput && !isNaN(parseFloat(percentileInput))) { + const targetPercentile = parseFloat(percentileInput); + const minPercentile = 0; + const maxPercentile = Math.round(targetPercentile * 10000000000) / 10000000000; + filterParts.push(`(cutoff_score >= ${minPercentile} && cutoff_score <= ${maxPercentile})`); + } - // Get all records for export - allRecords = await pb.collection('2024_mht_cet_round_one_cutoffs').getFullList({ - sort: '-last_rank', - }); + return filterParts.length > 0 ? filterParts.join(' && ') : ''; + }; + + // Check if we need to split the query due to large course lists + const MAX_COURSES_PER_QUERY = 15; + const shouldSplitQuery = courses && courses.length > MAX_COURSES_PER_QUERY; + + if (shouldSplitQuery) { + // Split courses into chunks and execute multiple queries + const courseChunks = []; + for (let i = 0; i < courses.length; i += MAX_COURSES_PER_QUERY) { + courseChunks.push(courses.slice(i, i + MAX_COURSES_PER_QUERY)); + } + + console.log(`Export: Splitting query into ${courseChunks.length} chunks`); + + // Execute all chunk queries in parallel + const chunkPromises = courseChunks.map(async (courseChunk) => { + const chunkFilterQuery = buildFilterParts(courseChunk); + return pb.collection('2024_mht_cet_round_one_cutoffs').getFullList({ + filter: chunkFilterQuery, + sort: '-last_rank', + }); + }); + + // Wait for all chunk queries to complete and combine results + const chunkResults = await Promise.all(chunkPromises); + allRecords = chunkResults.flatMap(result => result); + + // Remove duplicates that might occur if a record matches multiple course categories + const uniqueRecords = new Map(); + allRecords.forEach(record => { + uniqueRecords.set(record.id, record); + }); + allRecords = Array.from(uniqueRecords.values()); + + console.log(`Export: Combined ${chunkResults.length} chunks into ${allRecords.length} unique records`); + } else { + // Execute single query for smaller course lists + const filterQuery = buildFilterParts(); + allRecords = await pb.collection('2024_mht_cet_round_one_cutoffs').getFullList({ + filter: filterQuery, + sort: '-last_rank', + }); + } } catch (error) { - console.error('Database export failed, using mock data:', error); + console.error('Database export failed or authentication error:', error); + + // Check if it's an authentication error + if (error instanceof Error && error.message.includes('authentication')) { + return NextResponse.json({ + success: false, + error: 'Authentication required', + message: 'Please log in to export cutoff data', + details: error.message + }, { + status: 401, + headers: { + 'Cache-Control': 'no-cache', + 'X-Content-Type-Options': 'nosniff' + } + }); + } - // Generate mock data for export + // Generate mock data for export as fallback allRecords = Array.from({ length: 500 }, (_, i) => ({ college_code: `COL${String(i + 1).padStart(3, '0')}`, college_name: `Mock Engineering College ${i + 1}`, diff --git a/app/api/mht-cet/state-cutoffs/route.ts b/app/api/mht-cet/state-cutoffs/route.ts index 0763117..7c6fd28 100644 --- a/app/api/mht-cet/state-cutoffs/route.ts +++ b/app/api/mht-cet/state-cutoffs/route.ts @@ -1,12 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getPocketBase, ensureAuthenticatedServer } from '@/lib/pocketbaseClient'; +import { getPocketBase, ensureUserAuthenticated } from '@/lib/pocketbaseClient'; export async function POST(request: NextRequest) { try { const body = await request.json(); console.log('API Route called with body:', body); - const page = parseInt(body.page || '1'); const perPage = parseInt(body.perPage || '50'); const search = body.search || ''; @@ -21,207 +20,76 @@ export async function POST(request: NextRequest) { const pb = getPocketBase(); - // Try to ensure authentication with better error handling + // Ensure user authentication - uses logged-in user's token try { - await ensureAuthenticatedServer(); + await ensureUserAuthenticated(); } catch (authError) { - console.error('Authentication failed:', authError); - - // Return mock data if authentication fails - let mockData = Array.from({ length: Math.min(perPage * 10, 500) }, (_, i) => { - // Include specific test case for 92.6268989 - if (i === 0) { - return { - id: 'mock_test_92_6268989', - college_code: 'TEST001', - college_name: 'Test College for 92.6268989', - course_code: 'TEST01', - course_name: 'Test Course for Precise Percentile', - category: 'GOPENS', - seat_allocation_section: 'STATE_LEVEL', - cutoff_score: '92.6268989', - last_rank: '1000', - total_admitted: 60, - status: 'Government', - home_university: 'Mumbai University', - created: new Date().toISOString(), - updated: new Date().toISOString() - }; - } - if (i === 1) { - return { - id: 'mock_test_91_6268989', - college_code: 'TEST002', - college_name: 'Test College for 91.6268989', - course_code: 'TEST02', - course_name: 'Test Course for Precise Percentile Lower Bound', - category: 'GOPENS', - seat_allocation_section: 'STATE_LEVEL', - cutoff_score: '91.6268989', - last_rank: '1500', - total_admitted: 45, - status: 'Government Autonomous', - home_university: 'Savitribai Phule Pune University', - created: new Date().toISOString(), - updated: new Date().toISOString() - }; + console.error('User authentication failed:', authError); + + // Return user-friendly authentication error + return NextResponse.json({ + success: false, + error: 'Authentication required', + message: 'Please log in to access cutoff data', + details: 'User authentication failed' + }, { + status: 401, + headers: { + 'Cache-Control': 'no-cache', + 'X-Content-Type-Options': 'nosniff' } - return { - id: `mock_${i}`, - college_code: `COL${String(i + 1).padStart(3, '0')}`, - college_name: `Mock Engineering College ${i + 1}`, - course_code: `CS${String(i + 1).padStart(2, '0')}`, - course_name: `Computer Science and Engineering ${i + 1}`, - category: ['GOPENS', 'GOBCS', 'GSTS', 'GVJS'][i % 4], - seat_allocation_section: ['STATE_LEVEL', 'HOME_TO_HOME', 'HOME_TO_OTHER'][i % 3], - cutoff_score: String((99.5 - (i * 0.02)).toFixed(7)), // More varied precise decimal values - last_rank: String(1000 + (i * 50)), - total_admitted: 60 + (i % 20), - status: ['Government', 'Government Autonomous', 'Un-Aided', 'University', 'Deemed University Autonomous'][i % 5], - home_university: ['Mumbai University', 'Savitribai Phule Pune University', 'Shivaji University', 'Dr. Babasaheb Ambedkar Marathwada University', 'Rashtrasant Tukadoji Maharaj Nagpur University'][i % 5], - created: new Date().toISOString(), - updated: new Date().toISOString() - }; }); + } + + // Helper function to build filter query parts + const buildFilterParts = (courseChunk?: string[]) => { + const filterParts: string[] = []; - // Apply search filter if provided if (search) { - mockData = mockData.filter(item => - item.college_name.toLowerCase().includes(search.toLowerCase()) || - item.course_name.toLowerCase().includes(search.toLowerCase()) - ); + filterParts.push(`(college_name ~ "${search}" || course_name ~ "${search}")`); } - // Apply category filter if provided - if (categories && categories.length > 0) { - mockData = mockData.filter((item: any) => categories.includes(item.category)); + if (categories && Array.isArray(categories) && categories.length > 0) { + const categoryFilter = categories.map((cat: string) => `category = "${cat}"`).join(' || '); + filterParts.push(`(${categoryFilter})`); } - // Apply seat allocation filter if provided - if (seatAllocations && seatAllocations.length > 0) { - mockData = mockData.filter((item: any) => seatAllocations.includes(item.seat_allocation_section)); + // Use courseChunk if provided, otherwise use all courses + const coursesToFilter = courseChunk || courses; + if (coursesToFilter && Array.isArray(coursesToFilter) && coursesToFilter.length > 0) { + const courseFilter = coursesToFilter.map((course: string) => `course_name = "${course}"`).join(' || '); + filterParts.push(`(${courseFilter})`); } - // Apply status filter if provided - if (statuses && statuses.length > 0) { - mockData = mockData.filter((item: any) => statuses.includes(item.status)); + if (seatAllocations && Array.isArray(seatAllocations) && seatAllocations.length > 0) { + const seatFilter = seatAllocations.map((seat: string) => `seat_allocation_section = "${seat}"`).join(' || '); + filterParts.push(`(${seatFilter})`); } - // Apply home university filter if provided - if (homeUniversities && homeUniversities.length > 0) { - mockData = mockData.filter((item: any) => homeUniversities.includes(item.home_university)); + if (statuses && Array.isArray(statuses) && statuses.length > 0) { + const statusFilter = statuses.map((status: string) => `status = "${status}"`).join(' || '); + filterParts.push(`(${statusFilter})`); } - // Apply courses filter if provided - if (courses && courses.length > 0) { - mockData = mockData.filter((item: any) => courses.includes(item.course_name)); + if (homeUniversities && Array.isArray(homeUniversities) && homeUniversities.length > 0) { + const homeUniversityFilter = homeUniversities.map((uni: string) => `home_university = "${uni}"`).join(' || '); + filterParts.push(`(${homeUniversityFilter})`); } - // Apply percentile filter if provided + // Percentile-based filtering (from target down to 0%) - filtering cutoff_score directly if (percentileInput && !isNaN(parseFloat(percentileInput))) { const targetPercentile = parseFloat(percentileInput); - const minPercentile = Math.max(0, Math.round((targetPercentile - 1) * 10000000000) / 10000000000); + // Use higher precision (10 decimal places) to avoid floating-point errors + const minPercentile = 0; // Changed: show from 0% to target percentile const maxPercentile = Math.round(targetPercentile * 10000000000) / 10000000000; - mockData = mockData.filter((item: any) => { - const score = parseFloat(item.cutoff_score); - return score >= minPercentile && score <= maxPercentile; - }); - - // Sort by cutoff_score descending for percentile searches - mockData.sort((a: any, b: any) => parseFloat(b.cutoff_score) - parseFloat(a.cutoff_score)); - } else { - // Apply other sorting when no percentile filter - if (sortBy === 'cutoff_score') { - mockData.sort((a: any, b: any) => { - const aScore = parseFloat(a.cutoff_score); - const bScore = parseFloat(b.cutoff_score); - return sortOrder === 'desc' ? bScore - aScore : aScore - bScore; - }); - } else if (sortBy === 'last_rank') { - mockData.sort((a: any, b: any) => { - const aRank = parseInt(a.last_rank); - const bRank = parseInt(b.last_rank); - return sortOrder === 'desc' ? bRank - aRank : aRank - bRank; - }); - } + // Filter cutoff_score directly since cutoff_score = percentile + // Show from 0% to target percentile (inclusive) + filterParts.push(`(cutoff_score >= ${minPercentile} && cutoff_score <= ${maxPercentile})`); } - // Handle pagination for mock data - const authMockItemsCount = mockData.length; - const startIndex = (page - 1) * perPage; - const endIndex = startIndex + perPage; - const paginatedMockData = mockData.slice(startIndex, endIndex); - - console.log('Auth mock data result:', { - filteredCount: authMockItemsCount, - page, - perPage, - paginatedCount: paginatedMockData.length, - totalPages: Math.ceil(authMockItemsCount / perPage) - }); - - return NextResponse.json({ - success: true, - data: paginatedMockData, - totalItems: authMockItemsCount, - totalPages: Math.ceil(authMockItemsCount / perPage), - page: page, - perPage: perPage, - note: 'Using mock data - PocketBase server unavailable' - }, { - headers: { - 'Cache-Control': 'public, max-age=300, stale-while-revalidate=600', - 'X-Content-Type-Options': 'nosniff' - } - }); - } - - // Build filter query - const filterParts: string[] = []; - - if (search) { - filterParts.push(`(college_name ~ "${search}" || course_name ~ "${search}")`); - } - - if (categories && Array.isArray(categories) && categories.length > 0) { - const categoryFilter = categories.map((cat: string) => `category = "${cat}"`).join(' || '); - filterParts.push(`(${categoryFilter})`); - } - - if (courses && Array.isArray(courses) && courses.length > 0) { - const courseFilter = courses.map((course: string) => `course_name = "${course}"`).join(' || '); - filterParts.push(`(${courseFilter})`); - } - - if (seatAllocations && Array.isArray(seatAllocations) && seatAllocations.length > 0) { - const seatFilter = seatAllocations.map((seat: string) => `seat_allocation_section = "${seat}"`).join(' || '); - filterParts.push(`(${seatFilter})`); - } - - if (statuses && Array.isArray(statuses) && statuses.length > 0) { - const statusFilter = statuses.map((status: string) => `status = "${status}"`).join(' || '); - filterParts.push(`(${statusFilter})`); - } - - if (homeUniversities && Array.isArray(homeUniversities) && homeUniversities.length > 0) { - const homeUniversityFilter = homeUniversities.map((uni: string) => `home_university = "${uni}"`).join(' || '); - filterParts.push(`(${homeUniversityFilter})`); - } - - // Percentile-based filtering (-1 range only) - filtering cutoff_score directly - if (percentileInput && !isNaN(parseFloat(percentileInput))) { - const targetPercentile = parseFloat(percentileInput); - // Use higher precision (10 decimal places) to avoid floating-point errors - const minPercentile = Math.max(0, Math.round((targetPercentile - 1) * 10000000000) / 10000000000); - const maxPercentile = Math.round(targetPercentile * 10000000000) / 10000000000; - - // Filter cutoff_score directly since cutoff_score = percentile - // Show from (target - 1) to target percentile (inclusive) - filterParts.push(`(cutoff_score >= ${minPercentile} && cutoff_score <= ${maxPercentile})`); - } - - const filterQuery = filterParts.length > 0 ? filterParts.join(' && ') : ''; + return filterParts.length > 0 ? filterParts.join(' && ') : ''; + }; // Build sort string - CRITICAL: Always sort by cutoff_score descending when percentile is provided // This ensures highest percentiles (closest to target) appear first @@ -233,23 +101,105 @@ export async function POST(request: NextRequest) { sortString = `${sortPrefix}${sortBy}`; } - console.log('Query details:', { - filterQuery, - sortString, - page, - perPage, - percentileInput + // Check if we need to split the query due to large course lists + const MAX_COURSES_PER_QUERY = 15; // Reduced from potentially 80+ to manageable chunks + const shouldSplitQuery = courses && Array.isArray(courses) && courses.length > MAX_COURSES_PER_QUERY; + + console.log('Query strategy:', { + totalCourses: courses?.length || 0, + shouldSplitQuery, + maxCoursesPerQuery: MAX_COURSES_PER_QUERY }); try { - const result = await pb.collection('2024_mht_cet_round_one_cutoffs_duplicate').getList( - page, - perPage, - { - filter: filterQuery, - sort: sortString, + let result; + + if (shouldSplitQuery) { + // Split courses into chunks and execute multiple queries + const courseChunks = []; + for (let i = 0; i < courses.length; i += MAX_COURSES_PER_QUERY) { + courseChunks.push(courses.slice(i, i + MAX_COURSES_PER_QUERY)); } - ); + + console.log(`Splitting query into ${courseChunks.length} chunks`); + + // Execute all chunk queries in parallel using user's authentication + const chunkPromises = courseChunks.map(async (courseChunk, index) => { + const chunkFilterQuery = buildFilterParts(courseChunk); + console.log(`Executing chunk ${index + 1}/${courseChunks.length} with ${courseChunk.length} courses`); + + // This query uses the authenticated user's credentials and respects collection permissions + return pb.collection('2024_mht_cet_round_one_cutoffs_duplicate').getList( + 1, // Always get page 1 for chunks + 200, // Get more items per chunk to have enough for final pagination + { + filter: chunkFilterQuery, + sort: sortString, + } + ); + }); + + // Wait for all chunk queries to complete + const chunkResults = await Promise.all(chunkPromises); + + // Combine all results + const allItems = chunkResults.flatMap(chunkResult => chunkResult.items); + const totalItems = allItems.length; + + // Sort the combined results according to the sort criteria + allItems.sort((a: any, b: any) => { + if (percentileInput && !isNaN(parseFloat(percentileInput))) { + // Sort by cutoff_score descending for percentile searches + return parseFloat(b.cutoff_score) - parseFloat(a.cutoff_score); + } else if (sortBy === 'cutoff_score') { + const aVal = parseFloat(a.cutoff_score); + const bVal = parseFloat(b.cutoff_score); + return sortOrder === 'desc' ? bVal - aVal : aVal - bVal; + } else if (sortBy === 'last_rank') { + const aVal = parseInt(a.last_rank); + const bVal = parseInt(b.last_rank); + return sortOrder === 'desc' ? bVal - aVal : aVal - bVal; + } + return 0; + }); + + // Apply pagination to the combined and sorted results + const startIndex = (page - 1) * perPage; + const endIndex = startIndex + perPage; + const paginatedItems = allItems.slice(startIndex, endIndex); + + // Create a result object similar to PocketBase's format + result = { + items: paginatedItems, + totalItems: totalItems, + totalPages: Math.ceil(totalItems / perPage), + page: page, + perPage: perPage + }; + + console.log('Combined query result:', { + chunksExecuted: chunkResults.length, + totalItemsFound: totalItems, + finalPaginatedItems: paginatedItems.length + }); + } else { + // Execute single query for smaller course lists + const filterQuery = buildFilterParts(); + console.log('Executing single query:', { + filterQuery: filterQuery.substring(0, 200) + (filterQuery.length > 200 ? '...' : ''), + filterLength: filterQuery.length + }); + + // This query uses the authenticated user's credentials and respects collection permissions + result = await pb.collection('2024_mht_cet_round_one_cutoffs_duplicate').getList( + page, + perPage, + { + filter: filterQuery, + sort: sortString, + } + ); + } console.log('Database result:', { totalItems: result.totalItems, @@ -275,172 +225,32 @@ export async function POST(request: NextRequest) { } catch (dbError) { console.error('Database query failed:', dbError); - // Return filtered mock data as fallback with proper sorting and pagination - const totalMockDataItems = 500; // Simulate total items - let mockData = Array.from({ length: totalMockDataItems }, (_, i) => { - // Include specific test cases for precision testing - if (i === 0) { - return { - id: 'mock_test_97_989925', - college_code: 'TEST001', - college_name: 'Test College for 97.989925', - course_code: 'TEST01', - course_name: 'Test Course for Precise Percentile', - category: 'GOPENS', - seat_allocation_section: 'STATE_LEVEL', - cutoff_score: '97.989925', - last_rank: '500', - total_admitted: 60, - status: 'Government', - home_university: 'Mumbai University', - created: new Date().toISOString(), - updated: new Date().toISOString() - }; - } - if (i === 1) { - return { - id: 'mock_test_92_6268989', - college_code: 'TEST002', - college_name: 'Test College for 92.6268989', - course_code: 'TEST02', - course_name: 'Test Course for Precise Percentile', - category: 'GOPENS', - seat_allocation_section: 'STATE_LEVEL', - cutoff_score: '92.6268989', - last_rank: '1000', - total_admitted: 60, - status: 'Government Autonomous', - home_university: 'Savitribai Phule Pune University', - created: new Date().toISOString(), - updated: new Date().toISOString() - }; - } - if (i === 2) { - return { - id: 'mock_test_91_6268989', - college_code: 'TEST003', - college_name: 'Test College for 91.6268989', - course_code: 'TEST03', - course_name: 'Test Course for Precise Percentile Lower Bound', - category: 'GOPENS', - seat_allocation_section: 'STATE_LEVEL', - cutoff_score: '91.6268989', - last_rank: '1500', - total_admitted: 45, - status: 'Un-Aided', - home_university: 'Shivaji University', - created: new Date().toISOString(), - updated: new Date().toISOString() - }; - } - return { - id: `mock_${i}`, - college_code: `COL${String(i + 1).padStart(3, '0')}`, - college_name: `Mock Engineering College ${i + 1}`, - course_code: `CS${String(i + 1).padStart(2, '0')}`, - course_name: `Computer Science and Engineering ${i + 1}`, - category: ['GOPENS', 'GOBCS', 'GSTS', 'GVJS'][i % 4], - seat_allocation_section: ['STATE_LEVEL', 'HOME_TO_HOME', 'HOME_TO_OTHER'][i % 3], - cutoff_score: String((99.5 - (i * 0.01)).toFixed(7)), // More realistic percentile values - last_rank: String(1000 + (i * 50)), - total_admitted: 60 + (i % 20), - status: ['Government', 'Government Autonomous', 'Un-Aided', 'University', 'Deemed University Autonomous'][i % 5], - home_university: ['Mumbai University', 'Savitribai Phule Pune University', 'Shivaji University', 'Dr. Babasaheb Ambedkar Marathwada University', 'Rashtrasant Tukadoji Maharaj Nagpur University'][i % 5], - created: new Date().toISOString(), - updated: new Date().toISOString() - }; - }); - - // Apply search filter to mock data if provided - if (search) { - mockData = mockData.filter((item: any) => - item.college_name.toLowerCase().includes(search.toLowerCase()) || - item.course_name.toLowerCase().includes(search.toLowerCase()) - ); - } - - // Apply category filter to mock data if provided - if (categories && categories.length > 0) { - mockData = mockData.filter((item: any) => categories.includes(item.category)); - } - - // Apply seat allocation filter to mock data if provided - if (seatAllocations && seatAllocations.length > 0) { - mockData = mockData.filter((item: any) => seatAllocations.includes(item.seat_allocation_section)); - } - - // Apply status filter to mock data if provided - if (statuses && statuses.length > 0) { - mockData = mockData.filter((item: any) => statuses.includes(item.status)); - } - - // Apply home university filter to mock data if provided - if (homeUniversities && homeUniversities.length > 0) { - mockData = mockData.filter((item: any) => homeUniversities.includes(item.home_university)); - } - - // Apply courses filter to mock data if provided - if (courses && courses.length > 0) { - mockData = mockData.filter((item: any) => courses.includes(item.course_name)); - } - - // Apply percentile filter to mock data if provided - if (percentileInput && !isNaN(parseFloat(percentileInput))) { - const targetPercentile = parseFloat(percentileInput); - // Use higher precision (10 decimal places) to avoid floating-point errors - const minPercentile = Math.max(0, Math.round((targetPercentile - 1) * 10000000000) / 10000000000); - const maxPercentile = Math.round(targetPercentile * 10000000000) / 10000000000; - - mockData = mockData.filter((item: any) => { - const score = parseFloat(item.cutoff_score); - return score >= minPercentile && score <= maxPercentile; + // Check if it's an authentication error + if (dbError instanceof Error && dbError.message.includes('authentication')) { + return NextResponse.json({ + success: false, + error: 'Authentication required', + message: 'Please log in to access cutoff data', + details: dbError.message + }, { + status: 401, + headers: { + 'Cache-Control': 'no-cache', + 'X-Content-Type-Options': 'nosniff' + } }); - - // Sort by cutoff_score descending for percentile searches - mockData.sort((a: any, b: any) => parseFloat(b.cutoff_score) - parseFloat(a.cutoff_score)); - } else { - // Apply other sorting when no percentile filter - if (sortBy === 'cutoff_score') { - mockData.sort((a: any, b: any) => { - const aScore = parseFloat(a.cutoff_score); - const bScore = parseFloat(b.cutoff_score); - return sortOrder === 'desc' ? bScore - aScore : aScore - bScore; - }); - } else if (sortBy === 'last_rank') { - mockData.sort((a: any, b: any) => { - const aRank = parseInt(a.last_rank); - const bRank = parseInt(b.last_rank); - return sortOrder === 'desc' ? bRank - aRank : aRank - bRank; - }); - } } - // Handle pagination for mock data - const filteredMockItemsCount = mockData.length; - const startIndex = (page - 1) * perPage; - const endIndex = startIndex + perPage; - const paginatedMockData = mockData.slice(startIndex, endIndex); - - console.log('Mock data result:', { - totalGenerated: totalMockDataItems, - filteredCount: filteredMockItemsCount, - page, - perPage, - paginatedCount: paginatedMockData.length, - totalPages: Math.ceil(filteredMockItemsCount / perPage) - }); - + // Return a generic database error return NextResponse.json({ - success: true, - data: paginatedMockData, - totalItems: filteredMockItemsCount, - totalPages: Math.ceil(filteredMockItemsCount / perPage), - page: page, - perPage: perPage, - note: 'Using mock data - Database unavailable' + success: false, + error: 'Database query failed', + message: 'Unable to fetch cutoff data at this time', + details: dbError instanceof Error ? dbError.message : 'Unknown database error' }, { + status: 500, headers: { - 'Cache-Control': 'public, max-age=300, stale-while-revalidate=600', + 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff' } }); diff --git a/app/engineering/colleges/bits/cutoffs/components/authbutton.tsx b/app/engineering/colleges/bits/cutoffs/components/authbutton.tsx index 5f257ab..8f7f18f 100644 --- a/app/engineering/colleges/bits/cutoffs/components/authbutton.tsx +++ b/app/engineering/colleges/bits/cutoffs/components/authbutton.tsx @@ -1,6 +1,6 @@ -import { createClient } from "@/utils/supabase/server"; +import { getCurrentUser } from "@/lib/auth"; +import { signOut } from "@/app/login/actions"; import Link from "next/link"; -import { redirect } from "next/navigation"; import { Button } from '@/components/ui/button' import { Drawer, @@ -15,73 +15,61 @@ import { import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' export default async function AuthButton() { - const supabase = createClient(); - - const { - data: { user }, - } = await supabase.auth.getUser(); - - const signOut = async () => { - "use server"; - - const supabase = createClient(); - await supabase.auth.signOut(); - return redirect("/"); - }; + const user = await getCurrentUser(); return user ? (
- - - - {user?.email?.split('@')[0]}! - - - -
- - Are you sure? - You can log back in at anytime! - ({user?.email?.split('@')[0]}) - - -
- -
- - - + + + + {user.name.charAt(0).toUpperCase()} + + + +
+ + Are you sure? + You can log back in at anytime! + ({user.name}) + + +
+ +
+ + +
-
+
- + - -
- - Are you sure? - You can log back in at anytime! - - -
- -
- - - + +
+ + Are you sure? + You can log back in at anytime! + + +
+ +
+ + +
-
+
diff --git a/app/login/actions.tsx b/app/login/actions.tsx index 2809ac5..e689f47 100644 --- a/app/login/actions.tsx +++ b/app/login/actions.tsx @@ -2,45 +2,242 @@ import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' - -import { createClient } from '@/utils/supabase/server' +import { getPocketBase } from '@/lib/pocketbaseClient' +import { cookies } from 'next/headers' +import { ClientResponseError } from 'pocketbase' export async function login(formData: FormData) { - const supabase = createClient() + const email = formData.get('email') as string + const password = formData.get('password') as string + const redirectTo = formData.get('redirect') as string || '/account' - // type-casting here for convenience - // in practice, you should validate your inputs - const data = { - email: formData.get('email') as string, - password: formData.get('password') as string, + // Validate input + if (!email || !password) { + const params = new URLSearchParams({ message: 'Please fill in all fields' }) + if (redirectTo !== '/account') params.set('redirect', redirectTo) + return redirect(`/login?${params.toString()}`) + } + + if (!email.includes('@')) { + const params = new URLSearchParams({ message: 'Please enter a valid email address' }) + if (redirectTo !== '/account') params.set('redirect', redirectTo) + return redirect(`/login?${params.toString()}`) + } + + if (password.length < 8) { + const params = new URLSearchParams({ message: 'Password must be at least 8 characters long' }) + if (redirectTo !== '/account') params.set('redirect', redirectTo) + return redirect(`/login?${params.toString()}`) } - const { error } = await supabase.auth.signInWithPassword(data) + const pb = getPocketBase() - if (error) { - redirect('/error') + try { + const authData = await pb.collection('users').authWithPassword(email, password) + + // Remove verification check - allow unverified users to login + // if (!authData.record.verified) { + // return redirect('/login?message=Please verify your email before logging in') + // } + + const cookieStore = await cookies() + const cookie = pb.authStore.exportToCookie({ + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' + }) + + cookieStore.set({ + name: 'pb_auth', + value: cookie, + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7 // 1 week + }) + + } catch (error) { + console.error('Login error:', error) + const params = new URLSearchParams() + if (redirectTo !== '/account') params.set('redirect', redirectTo) + + if (error instanceof ClientResponseError) { + if (error.status === 400) { + params.set('message', 'Invalid email or password') + return redirect(`/login?${params.toString()}`) + } + } + params.set('message', 'Login failed. Please try again.') + return redirect(`/login?${params.toString()}`) } revalidatePath('/', 'layout') - redirect('/account') + redirect(redirectTo) } export async function signup(formData: FormData) { - const supabase = createClient() + const email = formData.get('email') as string + const password = formData.get('password') as string + const passwordConfirm = formData.get('passwordConfirm') as string + const name = formData.get('name') as string + const redirectTo = formData.get('redirect') as string || '/account' + + // Validate input + if (!email || !password || !passwordConfirm || !name) { + const params = new URLSearchParams({ message: 'Please fill in all fields' }) + if (redirectTo !== '/account') params.set('redirect', redirectTo) + return redirect(`/signup?${params.toString()}`) + } + + if (!email.includes('@')) { + const params = new URLSearchParams({ message: 'Please enter a valid email address' }) + if (redirectTo !== '/account') params.set('redirect', redirectTo) + return redirect(`/signup?${params.toString()}`) + } + + if (password.length < 8) { + const params = new URLSearchParams({ message: 'Password must be at least 8 characters long' }) + if (redirectTo !== '/account') params.set('redirect', redirectTo) + return redirect(`/signup?${params.toString()}`) + } + + if (password !== passwordConfirm) { + const params = new URLSearchParams({ message: 'Passwords do not match' }) + if (redirectTo !== '/account') params.set('redirect', redirectTo) + return redirect(`/signup?${params.toString()}`) + } + + if (name.trim().length < 2) { + const params = new URLSearchParams({ message: 'Name must be at least 2 characters long' }) + if (redirectTo !== '/account') params.set('redirect', redirectTo) + return redirect(`/signup?${params.toString()}`) + } + + const pb = getPocketBase() - // type-casting here for convenience - // in practice, you should validate your inputs const data = { - email: formData.get('email') as string, - password: formData.get('password') as string, + email: email.trim().toLowerCase(), + emailVisibility: true, + password: password, + passwordConfirm: passwordConfirm, + name: name.trim(), + } + + try { + const record = await pb.collection('users').create(data) + await pb.collection('users').requestVerification(email) + } catch (error) { + console.error('Signup error:', error) + const params = new URLSearchParams() + if (redirectTo !== '/account') params.set('redirect', redirectTo) + + if (error instanceof ClientResponseError) { + const errorData = error.response?.data + if (errorData?.email?.message?.includes('unique')) { + params.set('message', 'An account with this email already exists. Please log in instead.') + return redirect(`/signup?${params.toString()}`) + } + if (errorData?.password?.message) { + params.set('message', 'Password must be at least 8 characters long') + return redirect(`/signup?${params.toString()}`) + } + if (errorData?.name?.message) { + params.set('message', 'Please enter a valid name') + return redirect(`/signup?${params.toString()}`) + } + } + params.set('message', 'Failed to create account. Please try again.') + return redirect(`/signup?${params.toString()}`) + } + + // Automatically log the user in after successful signup + try { + const authData = await pb.collection('users').authWithPassword(email, password) + + const cookieStore = await cookies() + const cookie = pb.authStore.exportToCookie({ + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' + }) + + cookieStore.set({ + name: 'pb_auth', + value: cookie, + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7 // 1 week + }) + + revalidatePath('/', 'layout') + redirect(redirectTo) + } catch (error) { + // If auto-login fails, redirect to login page with success message + revalidatePath('/', 'layout') + const params = new URLSearchParams({ message: 'Account created successfully! Please log in to access the platform.' }) + if (redirectTo !== '/account') params.set('redirect', redirectTo) + redirect(`/login?${params.toString()}`) + } +} + +export async function updateProfile(formData: FormData) { + const name = formData.get('name') as string + + if (!name || name.trim().length < 2) { + redirect('/account?message=Name must be at least 2 characters long') } - const { error } = await supabase.auth.signUp(data) + const cookieStore = await cookies() + const authCookie = cookieStore.get('pb_auth') - if (error) { - redirect('/error') + if (!authCookie?.value) { + redirect('/login') } + const pb = getPocketBase() + pb.authStore.loadFromCookie(authCookie.value) + + if (!pb.authStore.isValid || !pb.authStore.model) { + redirect('/login') + } + + try { + const data = { + name: name.trim(), + emailVisibility: true, + } + + await pb.collection('users').update(pb.authStore.model.id, data) + + revalidatePath('/account') + // Don't use redirect inside try-catch + } catch (error) { + console.error('Error updating profile:', error) + redirect('/account?message=Failed to update profile. Please try again.') + } + + // Success case - redirect outside try-catch + redirect('/account?message=Profile updated successfully!') +} + +export async function clearAuthCookie() { + const cookieStore = await cookies() + cookieStore.set({ + name: 'pb_auth', + value: '', + maxAge: 0 + }) +} + +export async function signOut() { + const cookieStore = await cookies() + cookieStore.set({ + name: 'pb_auth', + value: '', + maxAge: 0 + }) + revalidatePath('/', 'layout') - redirect('/account') + redirect('/') } \ No newline at end of file diff --git a/app/login/page.tsx b/app/login/page.tsx index 3b70d9f..0131aff 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,11 +1,11 @@ import Link from "next/link"; -import { createClient } from "@/utils/supabase/server"; -import { redirect } from "next/navigation"; import { SubmitButton } from "./sumbit-button"; import { Input } from "@/components/ui/input"; -import { login, signup } from './actions' +import { login } from './actions' -export default function Login(){ +export default async function Login({ searchParams }: { searchParams: Promise<{ message?: string; redirect?: string }> }) { + const params = await searchParams + const redirectTo = params?.redirect || '/account' return (
@@ -30,41 +30,66 @@ export default function Login(){ Back -
- - - - + {params?.message && ( +
+

{params.message}

+
+ )} + + +
+

Sign In

+

Welcome back! Please sign in to your account.

+
+ + + +
+ + +
+ +
+ + +
+ Sign In - - Sign Up - +
+

+ Don't have an account?{" "} + + Sign up here + +

+
); diff --git a/app/mht-cet-login-required/layout.tsx b/app/mht-cet-login-required/layout.tsx new file mode 100644 index 0000000..e3f3cc5 --- /dev/null +++ b/app/mht-cet-login-required/layout.tsx @@ -0,0 +1,8 @@ +export default function LoginRequiredLayout({ + children, +}: { + children: React.ReactNode +}) { + // No authentication required for this page + return <>{children} +} diff --git a/app/mht-cet-login-required/page.tsx b/app/mht-cet-login-required/page.tsx new file mode 100644 index 0000000..ce53f2d --- /dev/null +++ b/app/mht-cet-login-required/page.tsx @@ -0,0 +1,77 @@ +import Link from "next/link"; +import Image from "next/image"; +import { Button } from "@/components/ui/button"; + +export default function MHTCETLoginRequired({ searchParams }: { + searchParams: Promise<{ redirect?: string }> +}) { + return ( +
+
+
+ MHT-CET Logo +

+ MHT-CET Resources +

+

+ Access exclusive MHT-CET cutoffs +

+
+ +
+

+ Login Required +

+

+ Please sign in to access MHT-CET resources that includs state cutoffs +

+ +
+ + + + + + + +
+
+ +
+ + ← Back to Homepage + +
+ +
+
+
+ Page Screenshot +
+
+
+ ); +} diff --git a/app/mht-cet/all-india-cutoffs/components/authbutton.tsx b/app/mht-cet/all-india-cutoffs/components/authbutton.tsx index 9e5c399..4fd2279 100644 --- a/app/mht-cet/all-india-cutoffs/components/authbutton.tsx +++ b/app/mht-cet/all-india-cutoffs/components/authbutton.tsx @@ -1,6 +1,6 @@ -import { createClient } from "@/utils/supabase/server"; +import { getCurrentUser } from "@/lib/auth"; +import { signOut } from "@/app/login/actions"; import Link from "next/link"; -import { redirect } from "next/navigation"; import { Button } from '@/components/ui/button' import { Drawer, @@ -15,99 +15,74 @@ import { import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' export default async function AuthButton() { - const supabase = createClient(); - - const { data: { user } } = await supabase.auth.getUser(); - - let username = 'User'; - if (user) { - const { data: profileData, error: profileError } = await supabase - .from('profiles') - .select('username') - .eq('id', user.id) - .single() - - if (profileError) { - console.error('Error fetching profile:', profileError) - } else { - username = profileData?.username || 'User' - } - } - - const signOut = async () => { - "use server"; - - const supabase = createClient(); - await supabase.auth.signOut(); - return redirect("/"); - }; + const user = await getCurrentUser(); return user ? (
- - - - {username} - - - -
- - Are you sure? - {username}, you can log back in at anytime! - - -
- - - -
- -
-
- - - + + + + {user.name.charAt(0).toUpperCase()} + + + +
+ + Are you sure? + {user.name}, you can log back in at anytime! + + +
+ + + +
+ +
+
+ + +
-
+
- + - -
- - Are you sure? - You can log back in at anytime! - - -
- - - -
- -
-
- - - + +
+ + Are you sure? + You can log back in at anytime! + + +
+ + + +
+ +
+
+ + +
-
+
diff --git a/app/mht-cet/all-state-cutoffs/components/authbutton.tsx b/app/mht-cet/all-state-cutoffs/components/authbutton.tsx index 5f257ab..8f7f18f 100644 --- a/app/mht-cet/all-state-cutoffs/components/authbutton.tsx +++ b/app/mht-cet/all-state-cutoffs/components/authbutton.tsx @@ -1,6 +1,6 @@ -import { createClient } from "@/utils/supabase/server"; +import { getCurrentUser } from "@/lib/auth"; +import { signOut } from "@/app/login/actions"; import Link from "next/link"; -import { redirect } from "next/navigation"; import { Button } from '@/components/ui/button' import { Drawer, @@ -15,73 +15,61 @@ import { import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' export default async function AuthButton() { - const supabase = createClient(); - - const { - data: { user }, - } = await supabase.auth.getUser(); - - const signOut = async () => { - "use server"; - - const supabase = createClient(); - await supabase.auth.signOut(); - return redirect("/"); - }; + const user = await getCurrentUser(); return user ? (
- - - - {user?.email?.split('@')[0]}! - - - -
- - Are you sure? - You can log back in at anytime! - ({user?.email?.split('@')[0]}) - - -
- -
- - - + + + + {user.name.charAt(0).toUpperCase()} + + + +
+ + Are you sure? + You can log back in at anytime! + ({user.name}) + + +
+ +
+ + +
-
+
- + - -
- - Are you sure? - You can log back in at anytime! - - -
- -
- - - + +
+ + Are you sure? + You can log back in at anytime! + + +
+ +
+ + +
-
+
diff --git a/app/mht-cet/layout.tsx b/app/mht-cet/layout.tsx new file mode 100644 index 0000000..ed94f07 --- /dev/null +++ b/app/mht-cet/layout.tsx @@ -0,0 +1,16 @@ +import { getCurrentUser } from '@/lib/auth' +import { redirect } from 'next/navigation' + +export default async function MHTCETLayout({ + children, +}: { + children: React.ReactNode +}) { + const user = await getCurrentUser() + + if (!user) { + redirect('/mht-cet-login-required') + } + + return <>{children} +} diff --git a/app/mht-cet/state-cutoffs/page.tsx b/app/mht-cet/state-cutoffs/page.tsx index 8a21289..4dfe826 100644 --- a/app/mht-cet/state-cutoffs/page.tsx +++ b/app/mht-cet/state-cutoffs/page.tsx @@ -5,7 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; -import { Loader2, Search, Filter, ArrowUpDown, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; +import { Loader2, Search, Filter, ArrowUpDown, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Download } from 'lucide-react'; import { Select, SelectContent, @@ -331,10 +331,15 @@ const calculatePercentile = (rank: string | number): number => { const getPrecisePercentileRange = (targetPercentile: number): { min: number; max: number } => { // Use higher precision (10 decimal places) to avoid floating-point errors const max = Math.round(targetPercentile * 10000000000) / 10000000000; - const min = Math.max(0, Math.round((targetPercentile - 10) * 10000000000) / 10000000000); + const min = 0; // Changed: range from target percentile down to 0% return { min, max }; }; +// Helper function to calculate distance from target percentile +const calculatePercentileDistance = (currentPercentile: number, targetPercentile: number): number => { + return Math.round((targetPercentile - currentPercentile) * 100) / 100; +}; + // Debounce hook for performance optimization const useDebounce = (value: any, delay: number) => { const [debouncedValue, setDebouncedValue] = useState(value); @@ -406,291 +411,340 @@ export default function StateCutoffsPage() { // Define columns for the enhanced table with Abel font and swapped score/percentile const columns: ColumnDef[] = useMemo( - () => [ - { - accessorKey: "college_name", - header: ({ column }) => { - return ( - - ) - }, - cell: ({ row }) => ( - - - -
- {row.getValue("college_name")} -
-
- -

{row.getValue("college_name")}

-
-
-
- ), - size: 300, - }, - { - accessorKey: "course_name", - header: ({ column }) => { - return ( - - ) - }, - cell: ({ row }) => ( - - - -
- {row.getValue("course_name")} -
-
- -

{row.getValue("course_name")}

-
-
-
- ), - size: 250, - }, - { - accessorKey: "category", - header: ({ column }) => { - return ( - - ) + () => { + const baseColumns: ColumnDef[] = [ + { + accessorKey: "college_name", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => ( + + + +
+ {row.getValue("college_name")} +
+
+ +

{row.getValue("college_name")}

+
+
+
+ ), + size: 300, }, - cell: ({ row }) => ( -
- - {row.getValue("category")} - -
- ), - size: 120, - }, - { - accessorKey: "last_rank", - header: ({ column }) => { - return ( - - ) + { + accessorKey: "course_name", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => ( + + + +
+ {row.getValue("course_name")} +
+
+ +

{row.getValue("course_name")}

+
+
+
+ ), + size: 250, }, - cell: ({ row }) => { - const rank = row.getValue("last_rank") as string; - const numRank = parseInt(rank); - return ( -
- {isNaN(numRank) ? rank : numRank.toLocaleString()} + { + accessorKey: "category", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => ( +
+ + {row.getValue("category")} +
- ); + ), + size: 120, }, - size: 100, - }, - { - accessorKey: "cutoff_score", - header: ({ column }) => { - return ( - - ) + { + accessorKey: "last_rank", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const rank = row.getValue("last_rank") as string; + const numRank = parseInt(rank); + return ( +
+ {isNaN(numRank) ? rank : numRank.toLocaleString()} +
+ ); + }, + size: 100, }, - cell: ({ row }) => ( -
- {row.getValue("cutoff_score")} -
- ), - size: 100, - }, - { - accessorKey: "total_admitted", - header: ({ column }) => { - return ( - - ) + { + accessorKey: "cutoff_score", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const currentPercentile = parseFloat(row.getValue("cutoff_score") as string); + const showDistance = filters.percentileInput && !isNaN(parseFloat(filters.percentileInput)); + + if (showDistance) { + const targetPercentile = parseFloat(filters.percentileInput); + const distance = calculatePercentileDistance(currentPercentile, targetPercentile); + const isTarget = Math.abs(distance) < 0.01; + + return ( + + + +
+
+ {row.getValue("cutoff_score")} +
+ + {isTarget ? 'TARGET' : `${distance < 0 ? '' : '-'}${Math.abs(distance)}%`} + +
+
+ +

+ {isTarget ? 'This matches your target percentile exactly' : + distance < 0 ? `This is ${Math.abs(distance)}% below your target of ${targetPercentile}%` : + `This is ${Math.abs(distance)}% above your target of ${targetPercentile}%`} +

+
+
+
+ ); + } + + return ( +
+ {row.getValue("cutoff_score")} +
+ ); + }, + size: (filters.percentileInput && !isNaN(parseFloat(filters.percentileInput))) ? 140 : 100, + } + ]; + + // Add remaining columns + baseColumns.push( + { + accessorKey: "total_admitted", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const value = row.getValue("total_admitted") as number; + return ( + + + +
+ {value.toLocaleString()} +
+
+ +

Total students admitted: {value}

+
+
+
+ ); + }, + size: 100, }, - cell: ({ row }) => { - const value = row.getValue("total_admitted") as number; - return ( + { + accessorKey: "college_code", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => ( -
- {value.toLocaleString()} +
+ {row.getValue("college_code")}
- -

Total students admitted: {value}

+ +

College Code: {row.getValue("college_code")}

- ); - }, - size: 100, - }, - { - accessorKey: "college_code", - header: ({ column }) => { - return ( - - ) + ), + size: 110, }, - cell: ({ row }) => ( - - - -
- {row.getValue("college_code")} -
-
- -

College Code: {row.getValue("college_code")}

-
-
-
- ), - size: 110, - }, - { - accessorKey: "course_code", - header: ({ column }) => { - return ( - - ) - }, - cell: ({ row }) => ( - - - -
- {row.getValue("course_code")} -
-
- -

Course Code: {row.getValue("course_code")}

-
-
-
- ), - size: 110, - }, - { - accessorKey: "status", - header: ({ column }) => { - return ( - - ) + { + accessorKey: "course_code", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => ( + + + +
+ {row.getValue("course_code")} +
+
+ +

Course Code: {row.getValue("course_code")}

+
+
+
+ ), + size: 110, }, - cell: ({ row }) => ( - - - -
- {row.getValue("status")} -
-
- -

{row.getValue("status")}

-
-
-
- ), - size: 180, - }, - { - accessorKey: "home_university", - header: ({ column }) => { - return ( - - ) + { + accessorKey: "status", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => ( + + + +
+ {row.getValue("status")} +
+
+ +

{row.getValue("status")}

+
+
+
+ ), + size: 180, }, - cell: ({ row }) => ( - - - -
- {row.getValue("home_university")} -
-
- -

{row.getValue("home_university")}

-
-
-
- ), - size: 250, - }, - ], - [] + { + accessorKey: "home_university", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => ( + + + +
+ {row.getValue("home_university")} +
+
+ +

{row.getValue("home_university")}

+
+
+
+ ), + size: 250, + } + ); + + return baseColumns; + }, + [filters.percentileInput] ); // Create table instance @@ -989,6 +1043,68 @@ export default function StateCutoffsPage() { }, []); // Throttled pagination function to prevent rapid clicking + // State for export functionality + const [isExporting, setIsExporting] = useState(false); + + // Export function + const exportToCSV = async () => { + setIsExporting(true); + try { + // Build query parameters for export + const params = new URLSearchParams(); + + if (filters.search) params.append('search', filters.search); + if (filters.categories.length > 0) { + filters.categories.forEach(cat => params.append('categories', cat)); + } + if (filters.courses.length > 0) { + filters.courses.forEach(course => params.append('courses', course)); + } + if (filters.statuses.length > 0) { + filters.statuses.forEach(status => params.append('statuses', status)); + } + if (filters.homeUniversities.length > 0) { + filters.homeUniversities.forEach(uni => params.append('homeUniversities', uni)); + } + if (filters.percentileInput) { + params.append('percentileInput', filters.percentileInput); + } + + // Fetch the CSV export + const response = await fetch(`/api/mht-cet/state-cutoffs/export?${params.toString()}`); + + if (!response.ok) { + throw new Error('Export failed'); + } + + // Get the blob and create download + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = `mht_cet_state_cutoffs_2024_filtered.csv`; + + document.body.appendChild(a); + a.click(); + + // Clean up + setTimeout(() => { + if (a.parentNode) { + document.body.removeChild(a); + } + window.URL.revokeObjectURL(url); + }, 100); + + toast.success('Export completed successfully!'); + } catch (error) { + console.error('Export error:', error); + toast.error('Failed to export data. Please try again.'); + } finally { + setIsExporting(false); + } + }; + const handlePageChange = useCallback((newPage: number) => { const now = Date.now(); const timeSinceLastClick = now - lastPaginationClickRef.current; @@ -1014,6 +1130,25 @@ export default function StateCutoffsPage() { Round 1 cutoffs for engineering colleges

+
+ {/* Stats */} + {totalItems > 0 && ( +
+

Total Records

+

{totalItems.toLocaleString()}

+
+ )} + {/* Export Button */} + +
{/* Filters */} @@ -1036,8 +1171,14 @@ export default function StateCutoffsPage() { setPendingFilters(prev => ({ ...prev, search: e.target.value }))} + onChange={(e) => { + const value = e.target.value; + // Basic sanitization - remove any non-printable characters and limit length + const sanitized = value.replace(/[^\x20-\x7E]/g, '').slice(0, 100); + setPendingFilters(prev => ({ ...prev, search: sanitized })); + }} className="pl-10 font-abel text-sm md:text-base h-12" + maxLength={100} />
@@ -1045,32 +1186,62 @@ export default function StateCutoffsPage() { {/* Percentile Input */}
- { - const value = e.target.value; - // Allow numbers and decimal point - if (value === '' || /^\d*\.?\d*$/.test(value)) { - const numValue = parseFloat(value); - if (value === '' || (numValue >= 0 && numValue <= 100)) { +
+ { + const value = e.target.value; + + // Allow empty string + if (value === '') { setPendingFilters(prev => ({ ...prev, percentileInput: value })); + return; + } + + // More flexible regex for decimal input + // Allows: 0-100, with optional decimal point and up to 7 decimal places + const percentileRegex = /^(100(\.0{1,7})?|[0-9]{1,2}(\.\d{0,7})?)$/; + + // First check if it matches the pattern or is a partial valid input + const partialRegex = /^(100(\.0{0,7})?|[0-9]{1,2}(\.\d{0,7})?|\.)$/; + + if (partialRegex.test(value)) { + // If it's a complete valid number, check range + if (percentileRegex.test(value)) { + const numValue = parseFloat(value); + if (numValue >= 0 && numValue <= 100) { + setPendingFilters(prev => ({ ...prev, percentileInput: value })); + } + } else { + // Allow partial input (like "95." while typing) + setPendingFilters(prev => ({ ...prev, percentileInput: value })); + } } - } - }} - type="text" - className="font-abel text-sm md:text-base h-12" - /> + }} + type="text" + className="font-abel text-sm md:text-base h-12 pr-12" + maxLength={11} + /> +
+ % +
+
{pendingFilters.percentileInput && ( -

- {(() => { - const target = parseFloat(pendingFilters.percentileInput); - const range = getPrecisePercentileRange(target); - return `Will show percentiles from ${range.min}% to ${range.max}%`; - })()} -

+
+
+

+ {(() => { + const target = parseFloat(pendingFilters.percentileInput); + if (isNaN(target)) return "Please enter a valid percentile number"; + const range = getPrecisePercentileRange(target); + return `Will show percentiles from ${range.min}% to ${range.max}% (all options at or below your target)`; + })()} +

+
)}
@@ -1490,7 +1661,7 @@ export default function StateCutoffsPage() { universities
- {filters.percentileInput ? '-10%' : 'All'} + {filters.percentileInput ? '0% to target' : 'All'} range
@@ -1539,24 +1710,10 @@ export default function StateCutoffsPage() { ) : loading ? ( -
-
- -
-
-
-

Loading Cutoff Data

-

- Fetching the latest MHT-CET state cutoffs... -

-
- This may take a few seconds -
-
-
-
-
-
+
+
+
+ Loading cutoffs...
) : ( @@ -1728,91 +1885,101 @@ export default function StateCutoffsPage() { - {/* Informational Cards */} -
+ + {/* Informational Cards - Improved Design & UX */} +
{/* How to Use This Tool */} - + +
- -
- + +
+
How to Use This Tool
- -
-
-
1
-
-

Enter Your Percentile

-

Type your MHT-CET percentile to see colleges in a -10% range.

-
-
-
-
2
-
-

Filter Your Preferences

-

Select categories, courses, and seat types to narrow down results.

-
-
-
-
3
-
-

Analyze & Strategize

-

Results are sorted by highest cutoff. Use this to plan your CAP round choices.

-
-
-
-
-

- Pro Tip: The -10% range shows realistic options, helping you find colleges where you have a strong chance of admission. -

+ +
    +
  1. + 1 + + Enter Your Percentile + Type your MHT-CET percentile to see colleges in a -10% range. + +
  2. +
  3. + 2 + + Filter Your Preferences + Select categories, courses, and seat types to narrow down results. + +
  4. +
  5. + 3 + + Analyze & Strategize + Results are sorted by highest cutoff. Use this to plan your CAP round choices. + +
  6. +
+
+ + + + + Pro Tip: Enter your percentile to see all colleges where you have a chance of admission (from 0% to your target percentile). Results show the distance from your target. +
{/* Seat Allocation Types */} - + +
- -
- + +
+
Seat Allocation Types
- -
- + +
+
-

State Level

-

Open to all Maharashtra candidates.

+ State Level + Open to all Maharashtra candidates.
-
- +
+
-

Home University

-

For students within the same university region.

+ Home University + For students within the same university region.
-
- +
+
-

Other University

-

For students from different university regions.

+ Other University + For students from different university regions.
+
+ Tip: Hover on seat types in the table for more info. +
{/* Category and Code Legends */} - + +
- -
- + +
+
Category & Code Legends
@@ -1820,9 +1987,9 @@ export default function StateCutoffsPage() { - +
- + Category Code Format
@@ -1835,9 +2002,9 @@ export default function StateCutoffsPage() {
- +
- + Special Category Codes
@@ -1852,14 +2019,16 @@ export default function StateCutoffsPage() {
- +
- + Video Explanation
- +
+