Files
school_compare/nextjs-app/components/SchoolSearchModal.tsx
TudorandClaude Opus 5.5 0a4c051ee5
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 22s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m17s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 20s
feat(compare): limit the basket to five per phase, not five overall
A parent choosing a primary and a secondary school at once hit the old
cap of five total. The basket now holds up to five primary and five
secondary schools (ten in all), matching the compare page's phase tabs.

Schools that could land in either tab (all-through, special schools with
phase "Not applicable", unknown phase) count against both groups, so no
tab ever exceeds the five-slot chart palette and point styles.

- lib/compareLogic: compareGroups, fullGroupFor, fitToGroupLimits
- search modal disables only the full group and says which one
- rankings rows carry the phase of the tab they are ranked under
- shared ?urns= links are trimmed per group
- copy: compare metadata, homepage value prop, How it works card now
  name primary and secondary schools (also better for search intent)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:56:54 +01:00

167 lines
5.3 KiB
TypeScript

/**
* 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 { fetchSchools } from "@/lib/api";
import { track } from "@/lib/analytics";
import { MAX_PER_GROUP } from "@/lib/compareLogic";
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, fullGroupFor } = 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 data = await fetchSchools(
{ search: term, page_size: 10 },
{ cache: "no-store" },
);
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);
track('compare_school_added', {
urn: school.urn,
from: 'compare',
selection_count_after: selectedSchools.length + 1,
});
// Don't close modal, allow adding multiple schools
};
const isSchoolSelected = (urn: number) => {
return selectedSchools.some((s) => s.urn === urn);
};
// Limits apply per phase group, so a full primary list must not stop a
// parent adding secondary schools.
const primaryFull = fullGroupFor({ phase: "Primary" }) !== null;
const secondaryFull = fullGroupFor({ phase: "Secondary" }) !== null;
const limitWarning =
primaryFull && secondaryFull
? `You're comparing ${MAX_PER_GROUP} primary and ${MAX_PER_GROUP} secondary schools, the most at once. Remove a school to add another.`
: primaryFull
? `You're comparing ${MAX_PER_GROUP} primary schools, the most at once. You can still add secondary schools.`
: secondaryFull
? `You're comparing ${MAX_PER_GROUP} secondary schools, the most at once. You can still add primary schools.`
: null;
const handleClose = () => {
setSearchTerm("");
setResults([]);
setHasSearched(false);
onClose();
};
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Add School to Comparison">
<div className={styles.modalContent}>
{limitWarning && (
<div className={styles.warning}>{limitWarning}</div>
)}
{/* Search Input */}
<div className={styles.searchContainer}>
<input
type="text"
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="School name or postcode"
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);
const fullGroup = alreadySelected ? null : fullGroupFor(school);
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 || fullGroup !== null}
className={`${styles.resultButton} ${
alreadySelected ? "btn btn-active" : "btn btn-secondary"
}`}
>
{alreadySelected
? "✓ Comparing"
: fullGroup
? `${fullGroup === "primary" ? "Primary" : "Secondary"} full`
: "+ Compare"}
</button>
</div>
);
})}
</div>
{!hasSearched && (
<div className={styles.hint}>
Start typing to search for schools...
</div>
)}
</div>
</Modal>
);
}