Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@ import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
reactStrictMode: true,
allowedDevOrigins: ['http://192.168.1.159', 'http://localhost:3000'],
allowedDevOrigins: ['http://192.168.1.159', 'http://localhost:3000'],
images: {
domains: ['lh3.googleusercontent.com']
remotePatterns: [
{
protocol: "https",
hostname: 'lh3.googleusercontent.com',
pathname: '/**',
}
]
}
};

Expand Down
5,934 changes: 3,593 additions & 2,341 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@
"firebase-admin": "^13.4.0",
"framer-motion": "^12.23.6",
"lucide-react": "^0.523.0",
"next": "15.3.6",
"next": "^16.1.1",
"nodemailer": "^7.0.5",
"rc-slider": "^11.1.8",
"react": "^19.1.0",
"react-dom": "^19.0.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-hook-form": "^7.60.0",
"react-hot-toast": "^2.5.2",
"uuid": "^11.1.0"
Expand Down
31 changes: 28 additions & 3 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useState, useRef } from "react";
import { useEffect, useState, useRef, useMemo } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { onAuthStateChanged, User } from "firebase/auth";
import { auth, db } from "@/lib/config/firebaseConfig";
Expand All @@ -21,8 +21,9 @@ import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined';
import InternshipCards from "@/lib/components/InternshipCards";
import Brushstroke from "@/lib/components/Brushstroke";
import { toggleBookmarkInFirestore } from "@/lib/modules/toggleBookmark";
import { InternshipCards as Internship } from "@/lib/types/internshipCards";
import { InternshipCards as Internship, Grade } from "@/lib/types/internshipCards";
import { useInternshipsWithFallback } from "@/lib/hooks/useRecommendedInternships";
import { UserPreferences } from "@/lib/types/userPreferences";

// Define user data type
interface UserData {
Expand Down Expand Up @@ -316,7 +317,31 @@ function HomeContent() {

const router = useRouter();

const { internshipsToShow } = useInternshipsWithFallback(bookmarked);
// Derive implicit preferences from the user's bookmarked internships.
// This gives the scorer real signal without requiring a manual prefs setup.
const impliedPrefs = useMemo((): UserPreferences | undefined => {
const saved = internships.filter((i) => bookmarked[i.id]);
if (!saved.length) return undefined;

const subjects = new Set<string>();
const grades = new Set<Grade>();

saved.forEach((i) => {
i.overview?.subject?.forEach((s) => { if (s) subjects.add(s); });
(i.eligibility?.eligibility?.grades ?? []).forEach((g) => { if (g && g !== "not provided") grades.add(g); });
});

return {
subjects: Array.from(subjects),
preferredGrades: Array.from(grades),
tags: [],
preferredLocation: { virtual: false, states: [], cities: [] },
minDurationWeeks: null,
stipendRequired: false,
};
}, [internships, bookmarked]);

const { internshipsToShow } = useInternshipsWithFallback(bookmarked, impliedPrefs);

const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
Expand Down
7 changes: 0 additions & 7 deletions src/app/pages/[uid]/[id]/page.tsx

This file was deleted.

14 changes: 0 additions & 14 deletions src/app/pages/[uid]/page.tsx

This file was deleted.

66 changes: 51 additions & 15 deletions src/app/pages/internships/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import React, { useEffect, useState, useMemo, Suspense } from "react";
import React, { useEffect, useState, useMemo, useRef, useCallback, Suspense } from "react";
import Slider from 'rc-slider';
import 'rc-slider/assets/index.css';
import { useRouter, useSearchParams } from "next/navigation";
Expand Down Expand Up @@ -121,6 +121,13 @@ function InternshipsContent() {
const [searchTerm, setSearchTerm] = useState(initialSearch);
const router = useRouter();

// Infinite scroll state
const INITIAL_COUNT = 12;
const LOAD_MORE_COUNT = 8;
const [visibleCount, setVisibleCount] = useState(INITIAL_COUNT);
// Ref so the scroll handler always sees the latest total without re-subscribing
const totalRef = useRef(0);

const filterData = [
{
label: "Due in",
Expand Down Expand Up @@ -194,18 +201,6 @@ function InternshipsContent() {
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);

// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
if (!target.closest('.filter-dropdown') && !target.closest('.filter-button') && !target.closest('.sort-dropdown-container')) {
setOpenDropdown(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);

// Add keyboard navigation support
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
Expand All @@ -229,6 +224,28 @@ function InternshipsContent() {
return () => window.removeEventListener('resize', handleResize);
}, []);

// Infinite scroll: scroll listener using a ref for the total so we never re-subscribe
const loadMore = useCallback(() => {
setVisibleCount((prev) => {
if (prev >= totalRef.current) return prev;
return Math.min(prev + LOAD_MORE_COUNT, totalRef.current);
});
}, []);

useEffect(() => {
const handleScroll = () => {
const distanceFromBottom =
document.documentElement.scrollHeight - window.scrollY - window.innerHeight;
// Pre-load when 600px away from bottom
if (distanceFromBottom < 600) {
loadMore();
}
};

window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, [loadMore]);

function isTimestamp(value: unknown): value is { toDate: () => Date } {
return (
value !== null &&
Expand Down Expand Up @@ -565,6 +582,11 @@ function InternshipsContent() {
}
};

// Reset visible count whenever filters/search/sort change
useEffect(() => {
setVisibleCount(INITIAL_COUNT);
}, [activeFilters, searchTerm, showBookmarkedOnly, sortBy]);

const filteredAndSortedInternships = useMemo(() => {
const filtered = internships.filter((internship) => {
// Search filter
Expand Down Expand Up @@ -784,6 +806,11 @@ function InternshipsContent() {
return sortInternships(filtered, sortBy, searchTerm);
}, [internships, activeFilters, searchTerm, showBookmarkedOnly, sortBy, bookmarked, lastSearchTime]);

// Slice for infinite scroll — cap at visibleCount but never more than available
totalRef.current = filteredAndSortedInternships.length;
const visibleInternships = filteredAndSortedInternships.slice(0, visibleCount);
const hasMore = visibleCount < filteredAndSortedInternships.length;

const totalActiveFilters = Object.values(activeFilters).reduce((acc, arr) => acc + arr.length, 0);
const hasBookmarkedInternships = Object.values(bookmarked).some(Boolean);

Expand Down Expand Up @@ -1214,7 +1241,7 @@ function InternshipsContent() {
{option === "Custom Range" && showCustomCostInput && activeFilters[filter.label]?.includes("Custom Range") && (
<div className="ml-0 mt-2 space-y-2 w-[180px]" onClick={(e) => e.stopPropagation()}>
<div className="flex flex-col gap-2 items-start w-full">
<div className="flex items-center justify-start w-full mb-1 pr-1ok">
<div className="flex items-center justify-start w-full mb-1 pr-1">
<div className="flex flex-col items-start w-[80px]">
<label htmlFor="minCostInput" className="text-xs text-gray-600 mb-1 ml-1">Min</label>
<input
Expand Down Expand Up @@ -1373,11 +1400,20 @@ function InternshipsContent() {

<div className="animate-in fade-in-0 duration-500">
<InternshipCards
internships={filteredAndSortedInternships}
internships={visibleInternships}
bookmarked={bookmarked}
toggleBookmark={toggleBookmark}
/>
</div>

{/* Subtle loading indicator while more cards are coming */}
{hasMore && (
<div className="flex justify-center items-center gap-2 py-8 opacity-40">
<div className="w-2 h-2 bg-blue-400 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
<div className="w-2 h-2 bg-purple-400 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
<div className="w-2 h-2 bg-pink-400 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
</div>
)}
</div>
);
}
Expand Down
43 changes: 23 additions & 20 deletions src/lib/components/InternshipCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ export default function InternshipCards({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_isLayoutCalculated, setIsLayoutCalculated] = useState<boolean>(false);
const [isInitialRender, setIsInitialRender] = useState<boolean>(true);
// Ref to guard eligibility save from firing on modal open
const eligibilityModalPrevRef = useRef<string | null>(null);
const isInitialRenderRef = useRef<boolean>(true);
const [expandedSubjects, setExpandedSubjects] = useState<{ [key: string]: boolean }>({});
const [modalInfo, setModalInfo] = useState<{ internshipId: string; info: { label: string; value: string }[] } | null>(null);

Expand Down Expand Up @@ -127,12 +130,8 @@ export default function InternshipCards({
// Helper function to check if internship was added in the last week
// Helper function to check if internship was added in the last week
const isNewInternship = (dateAdded: string | null | undefined): boolean => {
console.log('=== isNewInternship called ===');
console.log('dateAdded input:', dateAdded);
console.log('dateAdded type:', typeof dateAdded);

if (!dateAdded) {
console.log('No dateAdded provided');
// console.log('No dateAdded provided');
return false;
}

Expand All @@ -141,12 +140,6 @@ export default function InternshipCards({
const addedDate = new Date(dateAdded);
const now = new Date();
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);

console.log('Parsed addedDate:', addedDate);
console.log('Current date (now):', now);
console.log('One week ago:', oneWeekAgo);
console.log('Is addedDate >= oneWeekAgo?:', addedDate >= oneWeekAgo);

return addedDate >= oneWeekAgo;
} catch (error) {
console.error('Error parsing date:', error);
Expand Down Expand Up @@ -533,10 +526,19 @@ export default function InternshipCards({
};

// UseEffect to save data to Firebase whenever userEligibilityData changes
// Guard: only save when the data actually changes, not when the modal first opens
useEffect(() => {
if (eligibilityModal) {
saveEligibilityData(eligibilityModal.internshipId);
if (!eligibilityModal) {
eligibilityModalPrevRef.current = null;
return;
}
const internshipId = eligibilityModal.internshipId;
// If modal just opened (new internshipId), record it but don't save yet
if (eligibilityModalPrevRef.current !== internshipId) {
eligibilityModalPrevRef.current = internshipId;
return;
}
saveEligibilityData(internshipId);
}, [userEligibilityData, eligibilityModal]);

// Function to save eligibility data to database
Expand Down Expand Up @@ -610,7 +612,7 @@ export default function InternshipCards({
try {
// Example: const savedData = await loadFromDatabase('users', 'current_user_id');
// For now, we'll use empty state
console.log('Loading saved eligibility data...');
// console.log('Loading saved eligibility data...');
} catch (error) {
console.error('Error loading saved data:', error);
}
Expand Down Expand Up @@ -680,10 +682,11 @@ export default function InternshipCards({
setCardPositions(positions);
setContainerHeight(Math.max(...columnHeights) - gap); // Remove last gap
setIsLayoutCalculated(true);
if (isInitialRender) {
if (isInitialRenderRef.current) {
isInitialRenderRef.current = false;
setIsInitialRender(false);
}
}, [internships, itemWidth, gap, isInitialRender]);
}, [internships, itemWidth, gap]);

// Initial layout calculation after cards are rendered
useEffect(() => {
Expand Down Expand Up @@ -762,7 +765,7 @@ export default function InternshipCards({
const firstDeadlineDateString = internship.dates?.deadlines?.[0]?.date ?? null;
// Debug: log raw date string to console
if (firstDeadlineDateString) {
console.log('Raw deadline date:', firstDeadlineDateString);
// console.log('Raw deadline date:', firstDeadlineDateString);
}
const firstDeadlineDate = firstDeadlineDateString &&
isValidValue(firstDeadlineDateString)
Expand All @@ -776,9 +779,9 @@ export default function InternshipCards({
const position = cardPositions[internshipId];
// Check if internship is new (added in last week)
const isNew = isNewInternship(internship.metadata?.date_added);
console.log('🔍 Internship:', internship.overview?.title);
console.log(' metadata:', internship.metadata);
console.log(' isNew result:', isNew);
// console.log('🔍 Internship:', internship.overview?.title);
// console.log(' metadata:', internship.metadata);
// console.log(' isNew result:', isNew);

// Eligibility: grades and age display
const gradesArray = internship.eligibility?.eligibility?.grades || [];
Expand Down
Loading