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 { useRouter, useSearchParams, usePathname } from 'next/navigation';
import { isValidPostcode } from '@/lib/utils';
import type { Filters, ResultFilters } from '@/lib/types';
import styles from './FilterBar.module.css';
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { isValidPostcode } from "@/lib/utils";
import type { Filters, ResultFilters } from "@/lib/types";
import styles from "./FilterBar.module.css";
interface FilterBarProps {
filters: Filters;
@@ -19,22 +19,28 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
const [isPending, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null);
const currentSearch = searchParams.get('search') || '';
const currentPostcode = searchParams.get('postcode') || '';
const currentRadius = searchParams.get('radius') || '1';
const currentSearch = searchParams.get("search") || "";
const currentPostcode = searchParams.get("postcode") || "";
const currentRadius = searchParams.get("radius") || "1";
const initialOmniValue = currentPostcode || currentSearch;
const [omniValue, setOmniValue] = useState(initialOmniValue);
const currentLA = searchParams.get('local_authority') || '';
const currentType = searchParams.get('school_type') || '';
const currentPhase = searchParams.get('phase') || '';
const currentGender = searchParams.get('gender') || '';
const currentAdmissionsPolicy = searchParams.get('admissions_policy') || '';
const currentHasSixthForm = searchParams.get('has_sixth_form') || '';
const currentLA = searchParams.get("local_authority") || "";
const currentType = searchParams.get("school_type") || "";
const currentPhase = searchParams.get("phase") || "";
const currentGender = searchParams.get("gender") || "";
const currentAdmissionsPolicy = searchParams.get("admissions_policy") || "";
const currentHasSixthForm = searchParams.get("has_sixth_form") || "";
// 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 [filtersOpen, setFiltersOpen] = useState(hasActiveDropdownFilters);
@@ -45,47 +51,56 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.key === '/' || (e.key === 'k' && (e.ctrlKey || e.metaKey))) &&
document.activeElement?.tagName !== 'INPUT' &&
document.activeElement?.tagName !== 'TEXTAREA' &&
document.activeElement?.tagName !== 'SELECT') {
if (
(e.key === "/" || (e.key === "k" && (e.ctrlKey || e.metaKey))) &&
document.activeElement?.tagName !== "INPUT" &&
document.activeElement?.tagName !== "TEXTAREA" &&
document.activeElement?.tagName !== "SELECT"
) {
e.preventDefault();
inputRef.current?.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
document.addEventListener("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);
Object.entries(updates).forEach(([key, value]) => {
if (value && value !== '') {
if (value && value !== "") {
params.set(key, value);
} else {
params.delete(key);
}
});
params.delete('page');
params.delete("page");
startTransition(() => {
router.push(`${pathname}?${params.toString()}`);
});
}, [searchParams, pathname, router]);
},
[searchParams, pathname, router],
);
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!omniValue.trim()) {
updateURL({ search: '', postcode: '', radius: '' });
updateURL({ search: "", postcode: "", radius: "" });
return;
}
if (isValidPostcode(omniValue)) {
updateURL({ postcode: omniValue.trim().toUpperCase(), radius: currentRadius || '1', search: '' });
updateURL({
postcode: omniValue.trim().toUpperCase(),
radius: currentRadius || "1",
search: "",
});
} 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 = () => {
setOmniValue('');
setOmniValue("");
startTransition(() => {
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
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 phaseOptions = resultFilters?.phases ?? filters.phases ?? [];
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 (
<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}>
<div className={styles.omniBoxContainer}>
<input
@@ -120,11 +148,15 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
type="search"
value={omniValue}
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}
/>
<button type="submit" className={`btn btn-primary ${styles.searchButton}`} disabled={isPending}>
{isPending ? <div className={styles.spinner}></div> : 'Search'}
<button
type="submit"
className={`btn btn-primary ${styles.searchButton}`}
disabled={isPending}
>
{isPending ? <div className={styles.spinner}></div> : "Search"}
</button>
</div>
</form>
@@ -137,7 +169,7 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
<label className={styles.radiusLabel}>Within:</label>
<select
value={currentRadius}
onChange={e => updateURL({ radius: e.target.value })}
onChange={(e) => updateURL({ radius: e.target.value })}
className={styles.controlSelect}
disabled={isPending}
>
@@ -152,13 +184,15 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
{phaseOptions.length > 0 && (
<select
value={currentPhase}
onChange={(e) => handleFilterChange('phase', e.target.value)}
onChange={(e) => handleFilterChange("phase", e.target.value)}
className={styles.controlSelect}
disabled={isPending}
>
<option value="">All Phases</option>
{phaseOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>{p}</option>
<option key={p} value={p.toLowerCase()}>
{p}
</option>
))}
</select>
)}
@@ -166,14 +200,24 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
<button
type="button"
className={styles.advancedToggle}
onClick={() => setFiltersOpen(v => !v)}
onClick={() => setFiltersOpen((v) => !v)}
>
Advanced{hasActiveDropdownFilters ? ` (${activeDropdownFilters.length})` : ''}
<span className={filtersOpen ? styles.chevronUp : styles.chevronDown} />
Advanced
{hasActiveDropdownFilters
? ` (${activeDropdownFilters.length})`
: ""}
<span
className={filtersOpen ? styles.chevronUp : styles.chevronDown}
/>
</button>
{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
</button>
)}
@@ -183,25 +227,33 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
<div className={styles.filters}>
<select
value={currentLA}
onChange={(e) => handleFilterChange('local_authority', e.target.value)}
onChange={(e) =>
handleFilterChange("local_authority", e.target.value)
}
className={styles.filterSelect}
disabled={isPending}
>
<option value="">All Local Authorities</option>
{laOptions.map((la) => (
<option key={la} value={la}>{la}</option>
<option key={la} value={la}>
{la}
</option>
))}
</select>
<select
value={currentType}
onChange={(e) => handleFilterChange('school_type', e.target.value)}
onChange={(e) =>
handleFilterChange("school_type", e.target.value)
}
className={styles.filterSelect}
disabled={isPending}
>
<option value="">All School Types</option>
{typeOptions.map((type) => (
<option key={type} value={type}>{type}</option>
<option key={type} value={type}>
{type}
</option>
))}
</select>
@@ -210,20 +262,26 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
{genderOptions.length > 0 && (
<select
value={currentGender}
onChange={(e) => handleFilterChange('gender', e.target.value)}
onChange={(e) =>
handleFilterChange("gender", e.target.value)
}
className={styles.filterSelect}
disabled={isPending}
>
<option value="">Boys, Girls &amp; Mixed</option>
{genderOptions.map((g) => (
<option key={g} value={g.toLowerCase()}>{g}</option>
<option key={g} value={g.toLowerCase()}>
{g}
</option>
))}
</select>
)}
<select
value={currentHasSixthForm}
onChange={(e) => handleFilterChange('has_sixth_form', e.target.value)}
onChange={(e) =>
handleFilterChange("has_sixth_form", e.target.value)
}
className={styles.filterSelect}
disabled={isPending}
>
@@ -235,13 +293,17 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
{admissionsPolicyOptions.length > 0 && (
<select
value={currentAdmissionsPolicy}
onChange={(e) => handleFilterChange('admissions_policy', e.target.value)}
onChange={(e) =>
handleFilterChange("admissions_policy", e.target.value)
}
className={styles.filterSelect}
disabled={isPending}
>
<option value="">All admissions types</option>
{admissionsPolicyOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>{p}</option>
<option key={p} value={p.toLowerCase()}>
{p}
</option>
))}
</select>
)}
+22 -19
View File
@@ -3,15 +3,15 @@
* Modal for searching and adding schools to comparison
*/
'use client';
"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 type { School } from '@/lib/types';
import styles from './SchoolSearchModal.module.css';
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 type { School } from "@/lib/types";
import styles from "./SchoolSearchModal.module.css";
interface SchoolSearchModalProps {
isOpen: boolean;
@@ -20,7 +20,7 @@ interface SchoolSearchModalProps {
export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
const { addSchool, selectedSchools, canAddMore } = useComparison();
const [searchTerm, setSearchTerm] = useState('');
const [searchTerm, setSearchTerm] = useState("");
const [results, setResults] = useState<School[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
@@ -37,17 +37,20 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
setIsSearching(true);
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 || []);
setHasSearched(true);
} catch (error) {
console.error('Search failed:', error);
console.error("Search failed:", error);
setResults([]);
} finally {
setIsSearching(false);
}
}, 300),
[]
[],
);
const handleSearchChange = (value: string) => {
@@ -65,7 +68,7 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
};
const handleClose = () => {
setSearchTerm('');
setSearchTerm("");
setResults([]);
setHasSearched(false);
onClose();
@@ -88,7 +91,7 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
type="text"
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search by school name or location..."
placeholder="Search by school or postcode ..."
className={styles.searchInput}
autoFocus
/>
@@ -114,17 +117,17 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
{school.local_authority && (
<span>{school.local_authority}</span>
)}
{school.school_type && (
<span>{school.school_type}</span>
)}
{school.school_type && <span>{school.school_type}</span>}
</div>
</div>
<button
onClick={() => handleAddSchool(school)}
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>
</div>
);