feat(ui): consolidate search/filter area into cleaner 2-row layout
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m4s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m4s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Before: 7 visual rows (search, radius row, phase row, advanced toggle,
teal location banner, results count, filter chips).
After: 2 rows in card + 1 unified results toolbar.
- FilterBar: merge radius, phase, and Advanced toggle into a single
.controlsRow below the search bar; removed orphaned stacked rows
- HomeView: remove separate teal location banner; merge location info
into results heading ("16 schools within 1.0 miles of SW196AR");
move List/Map toggle inline with sort in one results header row
- Remove "Near postcode (Xmi)" chip (now redundant with heading)
- Sort select hidden in map view (sorting is meaningless there)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -134,6 +134,19 @@
|
|||||||
.filterSelect {
|
.filterSelect {
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controlsRow {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlsRow .advancedToggle {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlSelect {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.radiusWrapper {
|
.radiusWrapper {
|
||||||
@@ -159,33 +172,44 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Phase filter (always visible) ──────────────────── */
|
/* ── Controls row (radius + phase + advanced toggle) ─── */
|
||||||
.phaseFilterRow {
|
.controlsRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.phaseSelect {
|
.controlsRow .advancedToggle {
|
||||||
padding: 0.5rem 0.875rem;
|
margin-left: auto;
|
||||||
font-size: 0.9rem;
|
}
|
||||||
|
|
||||||
|
.radiusControl {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlSelect {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card, white);
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
outline: none;
|
||||||
color: var(--text-primary, #1a1612);
|
|
||||||
min-width: 160px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.phaseSelect:focus {
|
.controlSelect:focus {
|
||||||
border-color: var(--accent-coral, #e07256);
|
border-color: var(--accent-coral, #e07256);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Advanced filters toggle ─────────────────────────── */
|
/* ── Advanced filters toggle ─────────────────────────── */
|
||||||
.advancedSection {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
|
||||||
padding-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.advancedToggle {
|
.advancedToggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -127,34 +127,34 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
|||||||
{isPending ? <div className={styles.spinner}></div> : 'Search'}
|
{isPending ? <div className={styles.spinner}></div> : 'Search'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{currentPostcode && (
|
|
||||||
<div className={styles.radiusWrapper}>
|
|
||||||
<label className={styles.radiusLabel}>Within:</label>
|
|
||||||
<select
|
|
||||||
value={currentRadius}
|
|
||||||
onChange={e => updateURL({ radius: e.target.value })}
|
|
||||||
className={styles.radiusSelect}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
<option value="0.5">0.5 miles</option>
|
|
||||||
<option value="1">1 mile</option>
|
|
||||||
<option value="3">3 miles</option>
|
|
||||||
<option value="5">5 miles</option>
|
|
||||||
<option value="10">10 miles</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{!isHero && (
|
{!isHero && (
|
||||||
<>
|
<>
|
||||||
{/* Phase filter — always visible on results page */}
|
<div className={styles.controlsRow}>
|
||||||
{phaseOptions.length > 0 && (
|
{currentPostcode && (
|
||||||
<div className={styles.phaseFilterRow}>
|
<div className={styles.radiusControl}>
|
||||||
|
<label className={styles.radiusLabel}>Within:</label>
|
||||||
|
<select
|
||||||
|
value={currentRadius}
|
||||||
|
onChange={e => updateURL({ radius: e.target.value })}
|
||||||
|
className={styles.controlSelect}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<option value="0.5">0.5 miles</option>
|
||||||
|
<option value="1">1 mile</option>
|
||||||
|
<option value="3">3 miles</option>
|
||||||
|
<option value="5">5 miles</option>
|
||||||
|
<option value="10">10 miles</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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.phaseSelect}
|
className={styles.controlSelect}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
<option value="">All Phases</option>
|
<option value="">All Phases</option>
|
||||||
@@ -162,18 +162,23 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
|||||||
<option key={p} value={p.toLowerCase()}>{p}</option>
|
<option key={p} value={p.toLowerCase()}>{p}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.advancedSection}>
|
<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 filters{hasActiveDropdownFilters ? ` (${activeDropdownFilters.length})` : ''}
|
<span className={filtersOpen ? styles.chevronUp : styles.chevronDown} />
|
||||||
<span className={filtersOpen ? styles.chevronUp : styles.chevronDown} />
|
</button>
|
||||||
</button>
|
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button onClick={handleClearFilters} className={`btn btn-tertiary ${styles.clearButton}`} type="button" disabled={isPending}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{filtersOpen && (
|
{filtersOpen && (
|
||||||
<div className={styles.filters}>
|
<div className={styles.filters}>
|
||||||
@@ -243,15 +248,8 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<button onClick={handleClearFilters} className={`btn btn-tertiary ${styles.clearButton}`} type="button" disabled={isPending}>
|
|
||||||
Clear Filters
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,33 +33,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.locationBannerWrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locationBanner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: var(--accent-teal-bg);
|
|
||||||
border: 1px solid rgba(45, 125, 125, 0.25);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--accent-teal, #2d7d7d);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locationIcon {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
color: var(--accent-teal, #2d7d7d);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* View Toggle */
|
/* View Toggle */
|
||||||
.viewToggle {
|
.viewToggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -308,16 +281,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.locationBannerWrapper {
|
.resultsHeader {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: flex-start;
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.locationBanner {
|
.resultsHeaderActions {
|
||||||
padding: 0.5rem 0.75rem;
|
width: 100%;
|
||||||
font-size: 0.8125rem;
|
justify-content: space-between;
|
||||||
border-radius: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewToggle {
|
.viewToggle {
|
||||||
@@ -509,6 +480,13 @@
|
|||||||
padding: 0 0 1rem;
|
padding: 0 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resultsHeaderActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.sortSelect {
|
.sortSelect {
|
||||||
padding: 0.375rem 0.75rem;
|
padding: 0.375rem 0.75rem;
|
||||||
border: 1px solid var(--border-color, #e0ddd8);
|
border: 1px solid var(--border-color, #e0ddd8);
|
||||||
|
|||||||
@@ -126,46 +126,6 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Location Info Banner with View Toggle */}
|
|
||||||
{isLocationSearch && initialSchools.location_info && (
|
|
||||||
<div className={styles.locationBannerWrapper}>
|
|
||||||
<div className={styles.locationBanner}>
|
|
||||||
<span>
|
|
||||||
Showing schools within {(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of{' '}
|
|
||||||
<strong>{initialSchools.location_info.postcode}</strong>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{initialSchools.schools.length > 0 && (
|
|
||||||
<div className={styles.viewToggle}>
|
|
||||||
<button
|
|
||||||
className={`${styles.viewToggleBtn} ${resultsView === 'list' ? styles.active : ''}`}
|
|
||||||
onClick={() => setResultsView('list')}
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
|
|
||||||
<line x1="8" y1="6" x2="21" y2="6"/>
|
|
||||||
<line x1="8" y1="12" x2="21" y2="12"/>
|
|
||||||
<line x1="8" y1="18" x2="21" y2="18"/>
|
|
||||||
<line x1="3" y1="6" x2="3.01" y2="6"/>
|
|
||||||
<line x1="3" y1="12" x2="3.01" y2="12"/>
|
|
||||||
<line x1="3" y1="18" x2="3.01" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
List
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`${styles.viewToggleBtn} ${resultsView === 'map' ? styles.active : ''}`}
|
|
||||||
onClick={() => setResultsView('map')}
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
|
|
||||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
|
|
||||||
<circle cx="12" cy="10" r="3"/>
|
|
||||||
</svg>
|
|
||||||
Map
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Results Section */}
|
{/* Results Section */}
|
||||||
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
|
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
|
||||||
{!hasSearch && initialSchools.schools.length > 0 && (
|
{!hasSearch && initialSchools.schools.length > 0 && (
|
||||||
@@ -177,21 +137,55 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasSearch && resultsView === 'list' && (
|
{hasSearch && (
|
||||||
<div className={styles.resultsHeader}>
|
<div className={styles.resultsHeader}>
|
||||||
<h2 aria-live="polite" aria-atomic="true">
|
<h2 aria-live="polite" aria-atomic="true">
|
||||||
{initialSchools.total.toLocaleString()} school
|
{isLocationSearch && initialSchools.location_info
|
||||||
{initialSchools.total !== 1 ? 's' : ''} found
|
? `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} within ${(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of ${initialSchools.location_info.postcode}`
|
||||||
|
: `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} found`
|
||||||
|
}
|
||||||
</h2>
|
</h2>
|
||||||
<select value={sortOrder} onChange={e => setSortOrder(e.target.value)} className={styles.sortSelect}>
|
<div className={styles.resultsHeaderActions}>
|
||||||
<option value="default">Sort: Relevance</option>
|
{isLocationSearch && initialSchools.schools.length > 0 && (
|
||||||
{!isSecondaryView && <option value="rwm_desc">Highest R, W & M %</option>}
|
<div className={styles.viewToggle}>
|
||||||
{!isSecondaryView && <option value="rwm_asc">Lowest R, W & M %</option>}
|
<button
|
||||||
{isSecondaryView && <option value="att8_desc">Highest Attainment 8</option>}
|
className={`${styles.viewToggleBtn} ${resultsView === 'list' ? styles.active : ''}`}
|
||||||
{isSecondaryView && <option value="att8_asc">Lowest Attainment 8</option>}
|
onClick={() => setResultsView('list')}
|
||||||
{isLocationSearch && <option value="distance">Nearest first</option>}
|
>
|
||||||
<option value="name_asc">Name A–Z</option>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
|
||||||
</select>
|
<line x1="8" y1="6" x2="21" y2="6"/>
|
||||||
|
<line x1="8" y1="12" x2="21" y2="12"/>
|
||||||
|
<line x1="8" y1="18" x2="21" y2="18"/>
|
||||||
|
<line x1="3" y1="6" x2="3.01" y2="6"/>
|
||||||
|
<line x1="3" y1="12" x2="3.01" y2="12"/>
|
||||||
|
<line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
List
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.viewToggleBtn} ${resultsView === 'map' ? styles.active : ''}`}
|
||||||
|
onClick={() => setResultsView('map')}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
|
||||||
|
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
|
||||||
|
<circle cx="12" cy="10" r="3"/>
|
||||||
|
</svg>
|
||||||
|
Map
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{resultsView === 'list' && (
|
||||||
|
<select value={sortOrder} onChange={e => setSortOrder(e.target.value)} className={styles.sortSelect}>
|
||||||
|
<option value="default">Sort: Relevance</option>
|
||||||
|
{!isSecondaryView && <option value="rwm_desc">Highest R, W & M %</option>}
|
||||||
|
{!isSecondaryView && <option value="rwm_asc">Lowest R, W & M %</option>}
|
||||||
|
{isSecondaryView && <option value="att8_desc">Highest Attainment 8</option>}
|
||||||
|
{isSecondaryView && <option value="att8_asc">Lowest Attainment 8</option>}
|
||||||
|
{isLocationSearch && <option value="distance">Nearest first</option>}
|
||||||
|
<option value="name_asc">Name A–Z</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -200,7 +194,6 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
{searchParams.get('search') && <span className={styles.filterChip}>Search: {searchParams.get('search')}<a href="/" className={styles.chipRemove} onClick={e => { e.preventDefault(); }}>×</a></span>}
|
{searchParams.get('search') && <span className={styles.filterChip}>Search: {searchParams.get('search')}<a href="/" className={styles.chipRemove} onClick={e => { e.preventDefault(); }}>×</a></span>}
|
||||||
{searchParams.get('local_authority') && <span className={styles.filterChip}>{searchParams.get('local_authority')}</span>}
|
{searchParams.get('local_authority') && <span className={styles.filterChip}>{searchParams.get('local_authority')}</span>}
|
||||||
{searchParams.get('school_type') && <span className={styles.filterChip}>{searchParams.get('school_type')}</span>}
|
{searchParams.get('school_type') && <span className={styles.filterChip}>{searchParams.get('school_type')}</span>}
|
||||||
{searchParams.get('postcode') && <span className={styles.filterChip}>Near {searchParams.get('postcode')} ({parseFloat(searchParams.get('radius') || '1')} mi)</span>}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user