Complete Next.js migration with SSR and Docker deployment
- Migrate from vanilla JavaScript SPA to Next.js 16 with App Router - Add server-side rendering for all pages (Home, Compare, Rankings) - Create individual school pages with dynamic routing (/school/[urn]) - Implement Chart.js and Leaflet map integrations - Add comprehensive SEO with sitemap, robots.txt, and JSON-LD - Set up Docker multi-service architecture (PostgreSQL, FastAPI, Next.js) - Update CI/CD pipeline to build both backend and frontend images - Fix Dockerfile to include devDependencies for TypeScript compilation - Add Jest testing configuration - Implement performance optimizations (code splitting, caching) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
142
nextjs-app/components/SchoolSearchModal.tsx
Normal file
142
nextjs-app/components/SchoolSearchModal.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* SchoolSearchModal Component
|
||||
* Modal for searching and adding schools to comparison
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import { debounce } from '@/lib/utils';
|
||||
import type { School } from '@/lib/types';
|
||||
import styles from './SchoolSearchModal.module.css';
|
||||
|
||||
interface SchoolSearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
|
||||
const { addSchool, selectedSchools, canAddMore } = useComparison();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [results, setResults] = useState<School[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
|
||||
// Debounced search function
|
||||
const performSearch = useMemo(
|
||||
() =>
|
||||
debounce(async (term: string) => {
|
||||
if (!term.trim()) {
|
||||
setResults([]);
|
||||
setHasSearched(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const response = await fetch(`/api/schools?search=${encodeURIComponent(term)}&page_size=10`);
|
||||
const data = await response.json();
|
||||
setResults(data.schools || []);
|
||||
setHasSearched(true);
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
setResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 300),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
performSearch(value);
|
||||
};
|
||||
|
||||
const handleAddSchool = (school: School) => {
|
||||
addSchool(school);
|
||||
// Don't close modal, allow adding multiple schools
|
||||
};
|
||||
|
||||
const isSchoolSelected = (urn: number) => {
|
||||
return selectedSchools.some((s) => s.urn === urn);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSearchTerm('');
|
||||
setResults([]);
|
||||
setHasSearched(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose}>
|
||||
<div className={styles.modalContent}>
|
||||
<h2 className={styles.title}>Add School to Comparison</h2>
|
||||
|
||||
{!canAddMore && (
|
||||
<div className={styles.warning}>
|
||||
Maximum 5 schools can be compared. Remove a school to add another.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Input */}
|
||||
<div className={styles.searchContainer}>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder="Search by school name or location..."
|
||||
className={styles.searchInput}
|
||||
autoFocus
|
||||
/>
|
||||
{isSearching && <div className={styles.searchSpinner} />}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className={styles.results}>
|
||||
{hasSearched && results.length === 0 && (
|
||||
<div className={styles.noResults}>
|
||||
No schools found matching "{searchTerm}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.map((school) => {
|
||||
const alreadySelected = isSchoolSelected(school.urn);
|
||||
|
||||
return (
|
||||
<div key={school.urn} className={styles.resultItem}>
|
||||
<div className={styles.schoolInfo}>
|
||||
<div className={styles.schoolName}>{school.school_name}</div>
|
||||
<div className={styles.schoolMeta}>
|
||||
{school.local_authority && (
|
||||
<span>📍 {school.local_authority}</span>
|
||||
)}
|
||||
{school.school_type && (
|
||||
<span>🏫 {school.school_type}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleAddSchool(school)}
|
||||
disabled={alreadySelected || !canAddMore}
|
||||
className={styles.addButton}
|
||||
>
|
||||
{alreadySelected ? '✓ Added' : '+ Add'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!hasSearched && (
|
||||
<div className={styles.hint}>
|
||||
Start typing to search for schools...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user