shortening placeholder text
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 50s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s

This commit is contained in:
Tudor Sitaru
2026-04-07 16:17:56 +01:00
parent a562f408d2
commit ce46db7dbe
3 changed files with 148 additions and 83 deletions
Vendored
BIN
View File
Binary file not shown.
+116 -54
View File
@@ -1,10 +1,10 @@
'use client'; "use client";
import { useState, useCallback, useTransition, useRef, useEffect } from 'react'; import { useState, useCallback, useTransition, useRef, useEffect } from "react";
import { useRouter, useSearchParams, usePathname } from 'next/navigation'; import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { isValidPostcode } from '@/lib/utils'; import { isValidPostcode } from "@/lib/utils";
import type { Filters, ResultFilters } from '@/lib/types'; import type { Filters, ResultFilters } from "@/lib/types";
import styles from './FilterBar.module.css'; import styles from "./FilterBar.module.css";
interface FilterBarProps { interface FilterBarProps {
filters: Filters; filters: Filters;
@@ -19,22 +19,28 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const currentSearch = searchParams.get('search') || ''; const currentSearch = searchParams.get("search") || "";
const currentPostcode = searchParams.get('postcode') || ''; const currentPostcode = searchParams.get("postcode") || "";
const currentRadius = searchParams.get('radius') || '1'; const currentRadius = searchParams.get("radius") || "1";
const initialOmniValue = currentPostcode || currentSearch; const initialOmniValue = currentPostcode || currentSearch;
const [omniValue, setOmniValue] = useState(initialOmniValue); const [omniValue, setOmniValue] = useState(initialOmniValue);
const currentLA = searchParams.get('local_authority') || ''; const currentLA = searchParams.get("local_authority") || "";
const currentType = searchParams.get('school_type') || ''; const currentType = searchParams.get("school_type") || "";
const currentPhase = searchParams.get('phase') || ''; const currentPhase = searchParams.get("phase") || "";
const currentGender = searchParams.get('gender') || ''; const currentGender = searchParams.get("gender") || "";
const currentAdmissionsPolicy = searchParams.get('admissions_policy') || ''; const currentAdmissionsPolicy = searchParams.get("admissions_policy") || "";
const currentHasSixthForm = searchParams.get('has_sixth_form') || ''; const currentHasSixthForm = searchParams.get("has_sixth_form") || "";
// Count active dropdown filters (not search/postcode, not phase since it's always visible) // Count active dropdown filters (not search/postcode, not phase since it's always visible)
const activeDropdownFilters = [currentLA, currentType, currentGender, currentAdmissionsPolicy, currentHasSixthForm].filter(Boolean); const activeDropdownFilters = [
currentLA,
currentType,
currentGender,
currentAdmissionsPolicy,
currentHasSixthForm,
].filter(Boolean);
const hasActiveDropdownFilters = activeDropdownFilters.length > 0; const hasActiveDropdownFilters = activeDropdownFilters.length > 0;
const [filtersOpen, setFiltersOpen] = useState(hasActiveDropdownFilters); const [filtersOpen, setFiltersOpen] = useState(hasActiveDropdownFilters);
@@ -45,47 +51,56 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.key === '/' || (e.key === 'k' && (e.ctrlKey || e.metaKey))) && if (
document.activeElement?.tagName !== 'INPUT' && (e.key === "/" || (e.key === "k" && (e.ctrlKey || e.metaKey))) &&
document.activeElement?.tagName !== 'TEXTAREA' && document.activeElement?.tagName !== "INPUT" &&
document.activeElement?.tagName !== 'SELECT') { document.activeElement?.tagName !== "TEXTAREA" &&
document.activeElement?.tagName !== "SELECT"
) {
e.preventDefault(); e.preventDefault();
inputRef.current?.focus(); inputRef.current?.focus();
} }
}; };
document.addEventListener('keydown', handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, []); }, []);
const updateURL = useCallback((updates: Record<string, string>) => { const updateURL = useCallback(
(updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => { Object.entries(updates).forEach(([key, value]) => {
if (value && value !== '') { if (value && value !== "") {
params.set(key, value); params.set(key, value);
} else { } else {
params.delete(key); params.delete(key);
} }
}); });
params.delete('page'); params.delete("page");
startTransition(() => { startTransition(() => {
router.push(`${pathname}?${params.toString()}`); router.push(`${pathname}?${params.toString()}`);
}); });
}, [searchParams, pathname, router]); },
[searchParams, pathname, router],
);
const handleSearchSubmit = (e: React.FormEvent) => { const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!omniValue.trim()) { if (!omniValue.trim()) {
updateURL({ search: '', postcode: '', radius: '' }); updateURL({ search: "", postcode: "", radius: "" });
return; return;
} }
if (isValidPostcode(omniValue)) { if (isValidPostcode(omniValue)) {
updateURL({ postcode: omniValue.trim().toUpperCase(), radius: currentRadius || '1', search: '' }); updateURL({
postcode: omniValue.trim().toUpperCase(),
radius: currentRadius || "1",
search: "",
});
} else { } else {
updateURL({ search: omniValue.trim(), postcode: '', radius: '' }); updateURL({ search: omniValue.trim(), postcode: "", radius: "" });
} }
}; };
@@ -94,25 +109,38 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
}; };
const handleClearFilters = () => { const handleClearFilters = () => {
setOmniValue(''); setOmniValue("");
startTransition(() => { startTransition(() => {
router.push(pathname); router.push(pathname);
}); });
}; };
const hasActiveFilters = currentSearch || currentLA || currentType || currentPhase || currentPostcode || currentGender || currentAdmissionsPolicy || currentHasSixthForm; const hasActiveFilters =
currentSearch ||
currentLA ||
currentType ||
currentPhase ||
currentPostcode ||
currentGender ||
currentAdmissionsPolicy ||
currentHasSixthForm;
// Use result-scoped filter values when available, fall back to global // Use result-scoped filter values when available, fall back to global
const laOptions = resultFilters?.local_authorities ?? filters.local_authorities; const laOptions =
resultFilters?.local_authorities ?? filters.local_authorities;
const typeOptions = resultFilters?.school_types ?? filters.school_types; const typeOptions = resultFilters?.school_types ?? filters.school_types;
const phaseOptions = resultFilters?.phases ?? filters.phases ?? []; const phaseOptions = resultFilters?.phases ?? filters.phases ?? [];
const genderOptions = resultFilters?.genders ?? filters.genders ?? []; const genderOptions = resultFilters?.genders ?? filters.genders ?? [];
const admissionsPolicyOptions = resultFilters?.admissions_policies ?? filters.admissions_policies ?? []; const admissionsPolicyOptions =
resultFilters?.admissions_policies ?? filters.admissions_policies ?? [];
const isSecondaryMode = currentPhase === 'secondary' || genderOptions.length > 0; const isSecondaryMode =
currentPhase === "secondary" || genderOptions.length > 0;
return ( return (
<div className={`${styles.filterBar} ${isPending ? styles.isLoading : ''} ${isHero ? styles.heroMode : ''}`}> <div
className={`${styles.filterBar} ${isPending ? styles.isLoading : ""} ${isHero ? styles.heroMode : ""}`}
>
<form onSubmit={handleSearchSubmit} className={styles.searchSection}> <form onSubmit={handleSearchSubmit} className={styles.searchSection}>
<div className={styles.omniBoxContainer}> <div className={styles.omniBoxContainer}>
<input <input
@@ -120,11 +148,15 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
type="search" type="search"
value={omniValue} value={omniValue}
onChange={(e) => setOmniValue(e.target.value)} onChange={(e) => setOmniValue(e.target.value)}
placeholder="Search by school name or postcode (e.g., SW1A 1AA)..." placeholder="Search by school or postcode ..."
className={styles.omniInput} className={styles.omniInput}
/> />
<button type="submit" className={`btn btn-primary ${styles.searchButton}`} disabled={isPending}> <button
{isPending ? <div className={styles.spinner}></div> : 'Search'} type="submit"
className={`btn btn-primary ${styles.searchButton}`}
disabled={isPending}
>
{isPending ? <div className={styles.spinner}></div> : "Search"}
</button> </button>
</div> </div>
</form> </form>
@@ -137,7 +169,7 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
<label className={styles.radiusLabel}>Within:</label> <label className={styles.radiusLabel}>Within:</label>
<select <select
value={currentRadius} value={currentRadius}
onChange={e => updateURL({ radius: e.target.value })} onChange={(e) => updateURL({ radius: e.target.value })}
className={styles.controlSelect} className={styles.controlSelect}
disabled={isPending} disabled={isPending}
> >
@@ -152,13 +184,15 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
{phaseOptions.length > 0 && ( {phaseOptions.length > 0 && (
<select <select
value={currentPhase} value={currentPhase}
onChange={(e) => handleFilterChange('phase', e.target.value)} onChange={(e) => handleFilterChange("phase", e.target.value)}
className={styles.controlSelect} className={styles.controlSelect}
disabled={isPending} disabled={isPending}
> >
<option value="">All Phases</option> <option value="">All Phases</option>
{phaseOptions.map((p) => ( {phaseOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>{p}</option> <option key={p} value={p.toLowerCase()}>
{p}
</option>
))} ))}
</select> </select>
)} )}
@@ -166,14 +200,24 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
<button <button
type="button" type="button"
className={styles.advancedToggle} className={styles.advancedToggle}
onClick={() => setFiltersOpen(v => !v)} onClick={() => setFiltersOpen((v) => !v)}
> >
Advanced{hasActiveDropdownFilters ? ` (${activeDropdownFilters.length})` : ''} Advanced
<span className={filtersOpen ? styles.chevronUp : styles.chevronDown} /> {hasActiveDropdownFilters
? ` (${activeDropdownFilters.length})`
: ""}
<span
className={filtersOpen ? styles.chevronUp : styles.chevronDown}
/>
</button> </button>
{hasActiveFilters && ( {hasActiveFilters && (
<button onClick={handleClearFilters} className={`btn btn-tertiary ${styles.clearButton}`} type="button" disabled={isPending}> <button
onClick={handleClearFilters}
className={`btn btn-tertiary ${styles.clearButton}`}
type="button"
disabled={isPending}
>
Clear Clear
</button> </button>
)} )}
@@ -183,25 +227,33 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
<div className={styles.filters}> <div className={styles.filters}>
<select <select
value={currentLA} value={currentLA}
onChange={(e) => handleFilterChange('local_authority', e.target.value)} onChange={(e) =>
handleFilterChange("local_authority", e.target.value)
}
className={styles.filterSelect} className={styles.filterSelect}
disabled={isPending} disabled={isPending}
> >
<option value="">All Local Authorities</option> <option value="">All Local Authorities</option>
{laOptions.map((la) => ( {laOptions.map((la) => (
<option key={la} value={la}>{la}</option> <option key={la} value={la}>
{la}
</option>
))} ))}
</select> </select>
<select <select
value={currentType} value={currentType}
onChange={(e) => handleFilterChange('school_type', e.target.value)} onChange={(e) =>
handleFilterChange("school_type", e.target.value)
}
className={styles.filterSelect} className={styles.filterSelect}
disabled={isPending} disabled={isPending}
> >
<option value="">All School Types</option> <option value="">All School Types</option>
{typeOptions.map((type) => ( {typeOptions.map((type) => (
<option key={type} value={type}>{type}</option> <option key={type} value={type}>
{type}
</option>
))} ))}
</select> </select>
@@ -210,20 +262,26 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
{genderOptions.length > 0 && ( {genderOptions.length > 0 && (
<select <select
value={currentGender} value={currentGender}
onChange={(e) => handleFilterChange('gender', e.target.value)} onChange={(e) =>
handleFilterChange("gender", e.target.value)
}
className={styles.filterSelect} className={styles.filterSelect}
disabled={isPending} disabled={isPending}
> >
<option value="">Boys, Girls &amp; Mixed</option> <option value="">Boys, Girls &amp; Mixed</option>
{genderOptions.map((g) => ( {genderOptions.map((g) => (
<option key={g} value={g.toLowerCase()}>{g}</option> <option key={g} value={g.toLowerCase()}>
{g}
</option>
))} ))}
</select> </select>
)} )}
<select <select
value={currentHasSixthForm} value={currentHasSixthForm}
onChange={(e) => handleFilterChange('has_sixth_form', e.target.value)} onChange={(e) =>
handleFilterChange("has_sixth_form", e.target.value)
}
className={styles.filterSelect} className={styles.filterSelect}
disabled={isPending} disabled={isPending}
> >
@@ -235,13 +293,17 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
{admissionsPolicyOptions.length > 0 && ( {admissionsPolicyOptions.length > 0 && (
<select <select
value={currentAdmissionsPolicy} value={currentAdmissionsPolicy}
onChange={(e) => handleFilterChange('admissions_policy', e.target.value)} onChange={(e) =>
handleFilterChange("admissions_policy", e.target.value)
}
className={styles.filterSelect} className={styles.filterSelect}
disabled={isPending} disabled={isPending}
> >
<option value="">All admissions types</option> <option value="">All admissions types</option>
{admissionsPolicyOptions.map((p) => ( {admissionsPolicyOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>{p}</option> <option key={p} value={p.toLowerCase()}>
{p}
</option>
))} ))}
</select> </select>
)} )}
+22 -19
View File
@@ -3,15 +3,15 @@
* Modal for searching and adding schools to comparison * Modal for searching and adding schools to comparison
*/ */
'use client'; "use client";
import { useState, useMemo } from 'react'; import { useState, useMemo } from "react";
import { Modal } from './Modal'; import { Modal } from "./Modal";
import { useComparison } from '@/hooks/useComparison'; import { useComparison } from "@/hooks/useComparison";
import { debounce } from '@/lib/utils'; import { debounce } from "@/lib/utils";
import { fetchSchools } from '@/lib/api'; import { fetchSchools } from "@/lib/api";
import type { School } from '@/lib/types'; import type { School } from "@/lib/types";
import styles from './SchoolSearchModal.module.css'; import styles from "./SchoolSearchModal.module.css";
interface SchoolSearchModalProps { interface SchoolSearchModalProps {
isOpen: boolean; isOpen: boolean;
@@ -20,7 +20,7 @@ interface SchoolSearchModalProps {
export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) { export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
const { addSchool, selectedSchools, canAddMore } = useComparison(); const { addSchool, selectedSchools, canAddMore } = useComparison();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState("");
const [results, setResults] = useState<School[]>([]); const [results, setResults] = useState<School[]>([]);
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [hasSearched, setHasSearched] = useState(false); const [hasSearched, setHasSearched] = useState(false);
@@ -37,17 +37,20 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
setIsSearching(true); setIsSearching(true);
try { try {
const data = await fetchSchools({ search: term, page_size: 10 }, { cache: 'no-store' }); const data = await fetchSchools(
{ search: term, page_size: 10 },
{ cache: "no-store" },
);
setResults(data.schools || []); setResults(data.schools || []);
setHasSearched(true); setHasSearched(true);
} catch (error) { } catch (error) {
console.error('Search failed:', error); console.error("Search failed:", error);
setResults([]); setResults([]);
} finally { } finally {
setIsSearching(false); setIsSearching(false);
} }
}, 300), }, 300),
[] [],
); );
const handleSearchChange = (value: string) => { const handleSearchChange = (value: string) => {
@@ -65,7 +68,7 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
}; };
const handleClose = () => { const handleClose = () => {
setSearchTerm(''); setSearchTerm("");
setResults([]); setResults([]);
setHasSearched(false); setHasSearched(false);
onClose(); onClose();
@@ -88,7 +91,7 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
type="text" type="text"
value={searchTerm} value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)} onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search by school name or location..." placeholder="Search by school or postcode ..."
className={styles.searchInput} className={styles.searchInput}
autoFocus autoFocus
/> />
@@ -114,17 +117,17 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
{school.local_authority && ( {school.local_authority && (
<span>{school.local_authority}</span> <span>{school.local_authority}</span>
)} )}
{school.school_type && ( {school.school_type && <span>{school.school_type}</span>}
<span>{school.school_type}</span>
)}
</div> </div>
</div> </div>
<button <button
onClick={() => handleAddSchool(school)} onClick={() => handleAddSchool(school)}
disabled={alreadySelected || !canAddMore} disabled={alreadySelected || !canAddMore}
className={alreadySelected ? 'btn btn-active' : 'btn btn-secondary'} className={
alreadySelected ? "btn btn-active" : "btn btn-secondary"
}
> >
{alreadySelected ? '✓ Comparing' : '+ Compare'} {alreadySelected ? "✓ Comparing" : "+ Compare"}
</button> </button>
</div> </div>
); );