import { useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' import { de } from '../strings/de' import { listGerbils, listOriginBreeders } from '../api/gerbils' import { listColorVarieties } from '../api/lookups' import { andFilter, condition, orGroup } from '../api/gridify' import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types' import { useApi } from '../hooks/useApi' import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList' import { formatDate, genderLabel, statusLabel } from '../format/labels' import { GerbilFilterBar, ActiveFilterChips, BottomSheet, type FilterCategory, } from '../components/GerbilFilterBar' import { useGerbilName } from '../components/breederSuffix' import './gerbils.css' const PAGE_SIZE = 20 // SEARCH-2b: separator-insensitive search via Pam's normalized field. The term // is normalized the SAME way server-side stores it (lowercase + strip [\s._-]), // so 'clan kleine chaoten' matches 'clan-kleine-chaoten'. const NAME_SEARCH_FIELD = 'nameSearch' const normalizeSearch = (s: string): string => s.toLowerCase().replace(/[\s._-]/g, '') type SortKey = 'nameAsc' | 'nameDesc' | 'birthDesc' | 'birthAsc' // A trailing `id` makes every ordering a TOTAL order: rows that tie on name (or // dateOfBirth) keep a stable position across page fetches, so OFFSET pagination // never returns the same row on two pages (which caused duplicate React keys). const SORT_ORDER_BY: Record = { nameAsc: 'name,id', nameDesc: 'name desc,id', birthDesc: 'dateOfBirth desc,id', birthAsc: 'dateOfBirth,id', } const SORT_KEYS: SortKey[] = ['nameAsc', 'nameDesc', 'birthDesc', 'birthAsc'] // DESIGN: per-status chip/dot/badge colours (from the prototype's status palette). const STATUS_COLORS: Record = { Breeding: '#5e8c5a', Pet: '#5a7da8', ForSale: '#c2703d', Deceased: '#8a847b', GivenAway: '#b08e5c', } const GENDER_SYMBOL: Record = { unknown: '?', male: '♂', female: '♀' } // Multi-select filter state. Keys match Gridify field names so the query builds directly. interface GerbilFilters { [key: string]: string[] status: GerbilStatus[] gender: Gender[] colorVarietyId: string[] originBreeder: string[] } // BESTAND-FILTER: default to showing the own breeding stock (Status = Zucht). const DEFAULT_FILTERS: GerbilFilters = { status: ['Breeding'], gender: [], colorVarietyId: [], originBreeder: [], } const EMPTY_FILTERS: GerbilFilters = { status: [], gender: [], colorVarietyId: [], originBreeder: [] } export default function GerbilsPage() { const t = de.pages.gerbils const gerbilName = useGerbilName() const [search, setSearch] = useState('') const [filters, setFilters] = useState(DEFAULT_FILTERS) // BESTAND-FILTER: default to the own clan (Bestand); opt in to external ancestors. const [showExternal, setShowExternal] = useState(false) // BESTAND-FILTER: optionally hide animals without a date of birth (server-side). const [hideUndated, setHideUndated] = useState(false) const [sort, setSort] = useState('nameAsc') const [sortOpen, setSortOpen] = useState(false) // DESIGN: the header (search + filters) is sticky; the title row blends out once // the user scrolls. A 1px sentinel above the sticky header drives `condensed` // via IntersectionObserver — robust regardless of which element actually scrolls. const [condensed, setCondensed] = useState(false) const topSentinelRef = useRef(null) useEffect(() => { const el = topSentinelRef.current if (!el) return const io = new IntersectionObserver(([entry]) => setCondensed(!entry.isIntersecting)) io.observe(el) return () => io.disconnect() }, []) // Back-to-top: show a floating button once the user has scrolled a screenful, // and smooth-scroll the main area back to the top on tap. const [showBackToTop, setShowBackToTop] = useState(false) const scrollerOf = () => (topSentinelRef.current?.closest('.app-main') as HTMLElement | null) ?? null useEffect(() => { const scroller = scrollerOf() const read = () => (scroller ? scroller.scrollTop : window.scrollY) const target: HTMLElement | Window = scroller ?? window const onScroll = () => setShowBackToTop(read() > 600) target.addEventListener('scroll', onScroll, { passive: true }) onScroll() return () => target.removeEventListener('scroll', onScroll) }, []) const scrollToTop = () => (scrollerOf() ?? window).scrollTo({ top: 0, behavior: 'smooth' }) const colorVarieties = useApi(() => listColorVarieties(), []) const colorNameById = useMemo(() => { const map = new Map() for (const cv of colorVarieties.data ?? []) map.set(cv.id, cv.name) return map }, [colorVarieties.data]) const breeders = useApi(() => listOriginBreeders(), []) // DESIGN: filter categories for the Quick-Chips bar. `key` doubles as the Gridify field. const categories: FilterCategory[] = useMemo( () => [ { key: 'status', label: t.filters.status, options: GERBIL_STATUSES.map((s) => ({ value: s, label: statusLabel(s), color: STATUS_COLORS[s], })), }, { key: 'gender', label: t.filters.gender, options: GENDERS.map((g) => ({ value: g, label: genderLabel(g), symbol: GENDER_SYMBOL[g] })), }, { key: 'colorVarietyId', label: t.filters.colorVariety, searchable: true, options: (colorVarieties.data ?? []).map((cv) => ({ value: cv.id, label: cv.name })), }, { key: 'originBreeder', label: t.fields.origin, searchable: true, options: (breeders.data ?? []).map((b) => ({ value: b, label: b })), }, ], [t, colorVarieties.data, breeders.data], ) const filter = andFilter( search.trim() && condition({ field: NAME_SEARCH_FIELD, op: 'contains', value: normalizeSearch(search) }), // Multi-select: values within a category are OR'd, categories are AND'd. orGroup('status', filters.status), orGroup('gender', filters.gender), orGroup('colorVarietyId', filters.colorVarietyId), orGroup('originBreeder', filters.originBreeder), // Default view = own clan (Bestand). Unless "externe Ahnen einblenden" is on, // restrict to resident animals (server-side, so paging/counts stay correct). !showExternal && condition({ field: 'isResident', op: '==', value: true }), // Optionally drop animals without a date of birth. Gridify reads an empty // value as null, so `dateOfBirth!=` means "date of birth is set". hideUndated && condition({ field: 'dateOfBirth', op: '!=', value: '' }), ) const orderBy = SORT_ORDER_BY[sort] // Infinite scroll: page 1 loads now; further pages append as the sentinel scrolls // into view. Changing the query signature (filter/sort) reloads from page 1. const resetKey = `${filter}${orderBy}` const list = useInfiniteList( (page) => listGerbils({ filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }), resetKey, 'gerbils', ) const sentinelRef = useInfiniteSentinel(list) // Filter mutations. const toggleFilter = (key: string, value: string) => { setFilters((f) => { const arr = f[key as keyof GerbilFilters] as string[] const next = arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value] return { ...f, [key]: next } }) } const removeFilter = (key: string, value: string) => { setFilters((f) => ({ ...f, [key]: (f[key as keyof GerbilFilters] as string[]).filter((v) => v !== value), })) } const clearAllFilters = () => setFilters(EMPTY_FILTERS) const resetAll = () => { setSearch('') setFilters(DEFAULT_FILTERS) setShowExternal(false) setHideUndated(false) setSort('nameAsc') } const chipColor = (key: string, value: string): string | undefined => key === 'status' ? STATUS_COLORS[value as GerbilStatus] : undefined const { items, total } = list return (
{/* 1px sentinel above the sticky header — drives the title blend-out. */}
) }