The participant search feature allows users to find participants by name and school. It includes form validation, dropdown filters, and real-time result updates powered by React Query.
Overview
Key features of the search system:
Form Validation Client-side validation with error messages
School Dropdown Searchable dropdown with all participating schools
Real-time Results Instant search results with React Query caching
Loading States Skeleton screens and loading indicators
Search Flow
User Journey
Navigate to search page (/search)
Enter participant name (minimum 2 characters)
Select school from dropdown
Submit form or press Enter
View results below the form
URL Parameters
Search parameters are stored in the URL for shareable links:
https://tamborradata.com/search?name=Juan&company=Colegio+A
URL parameters are automatically loaded when the page loads, allowing users to share search results.
app/(frontend)/search/components/SearchCard/components/CardForm/CardForm.tsx
'use client' ;
import { AnimatePresence , motion } from 'framer-motion' ;
import { NameInput } from './components/NameInput' ;
import { SelectCompanies } from './components/SelectCompanies/SelectCompanies' ;
import { useForm } from './hooks/useForm' ;
import { useSearchParams } from 'next/navigation' ;
export function CardForm ({ onSubmit }) {
const { formSubmit , alert } = useForm ({ onSubmit });
const searchParams = useSearchParams ();
const initialName = searchParams . get ( 'name' ) || '' ;
const initialCompany = searchParams . get ( 'company' ) || '' ;
return (
< motion.form
initial = { { opacity: 0 } }
animate = { { opacity: 1 } }
transition = { { opacity: { duration: 0.4 , ease: 'linear' , delay: 0.7 } } }
aria-label = "Formulario de búsqueda"
className = "h-full flex flex-col justify-between gap-5"
onSubmit = { formSubmit }
>
< h2 className = "text-xl md:text-2xl font-bold text-center" >
Buscar Participante
</ h2 >
< div className = "flex flex-col gap-5" >
< NameInput defaultValue = { initialName } />
< SelectCompanies defaultValue = { initialCompany } />
</ div >
{ /* Alert message */ }
< AnimatePresence mode = "wait" >
{ alert && (
< motion.p
initial = { { opacity: 0 , height: 0 } }
animate = { { opacity: 1 , height: 'auto' } }
exit = { { opacity: 0 , height: 0 } }
transition = { { duration: 0.3 } }
className = "text-sm text-(--color-error) font-semibold text-center"
>
{ alert }
</ motion.p >
) }
</ AnimatePresence >
< button
type = "submit"
className = "w-full bg-(--color-bg-secondary) font-semibold py-2 rounded-md hover:scale-102 transition-all"
>
Buscar
</ button >
</ motion.form >
);
}
File location : app/(frontend)/search/components/SearchCard/components/CardForm/CardForm.tsx:9
Simple text input with validation:
export function NameInput ({ defaultValue } : { defaultValue ?: string }) {
return (
< div className = "flex flex-col gap-2" >
< label htmlFor = "name" className = "font-semibold" >
Nombre del participante
</ label >
< input
id = "name"
name = "name"
type = "text"
required
minLength = { 2 }
defaultValue = { defaultValue }
placeholder = "Introduce un nombre"
className = "px-3 py-2 rounded-md bg-(--color-bg-secondary) outline-none"
aria-label = "Nombre del participante"
/>
</ div >
);
}
Minimum length : Names must be at least 2 characters to prevent excessive database queries.
School Dropdown
Custom Select Component
The school selector is a custom dropdown built from scratch:
app/(frontend)/search/components/SearchCard/components/CardForm/components/SelectCompanies/SelectCompanies.tsx
'use client' ;
import { scrollToForm } from '../../utils/scrollToForm' ;
import { useDropdown } from './hooks/useDropdown' ;
import { ChevronDown } from '@/app/(frontend)/icons/icons' ;
import { SelectOptions } from './SelectOptions' ;
export function SelectCompanies ({ defaultValue } : { defaultValue ?: string }) {
const { isOpen , setIsOpen , dropdownRef , selectedCompany } = useDropdown ( defaultValue );
return (
< div id = "company-select" className = "flex flex-col w-full gap-2" ref = { dropdownRef } >
< label className = "font-semibold" > Selecciona una compañía </ label >
< button
onClick = { () => {
setIsOpen ( ! isOpen );
scrollToForm ( dropdownRef . current );
} }
type = "button"
aria-label = "Selector de compañías"
aria-expanded = { isOpen }
aria-haspopup = "listbox"
aria-controls = "company-listbox"
className = "relative w-full rounded-md bg-(--color-bg-secondary) flex items-center justify-between"
>
< input
required
readOnly
type = "text"
name = "company"
autoComplete = "off"
value = { selectedCompany || '' }
placeholder = "Selecciona una compañía"
className = "w-full px-3 py-2 bg-transparent outline-none cursor-pointer caret-transparent"
/>
< div
className = "absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-400"
style = { { transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)' } }
>
< ChevronDown />
</ div >
{ isOpen && (
< div
id = "company-listbox"
role = "listbox"
className = "absolute -bottom-2 left-0 z-50 translate-y-full bg-(--color-bg-secondary) border rounded-lg overflow-hidden"
>
< SelectOptions />
</ div >
) }
</ button >
</ div >
);
}
File location : app/(frontend)/search/components/SearchCard/components/CardForm/components/SelectCompanies/SelectCompanies.tsx:7
Dropdown Hook
import { useState , useRef , useEffect } from 'react' ;
export function useDropdown ( defaultValue ?: string ) {
const [ isOpen , setIsOpen ] = useState ( false );
const [ selectedCompany , setSelectedCompany ] = useState ( defaultValue || '' );
const dropdownRef = useRef < HTMLDivElement >( null );
// Close dropdown when clicking outside
useEffect (() => {
function handleClickOutside ( event : MouseEvent ) {
if ( dropdownRef . current && ! dropdownRef . current . contains ( event . target as Node )) {
setIsOpen ( false );
}
}
document . addEventListener ( 'mousedown' , handleClickOutside );
return () => document . removeEventListener ( 'mousedown' , handleClickOutside );
}, []);
return {
isOpen ,
setIsOpen ,
dropdownRef ,
selectedCompany ,
setSelectedCompany
};
}
Click-outside detection : The dropdown automatically closes when users click outside the component.
Data Fetching
React Query Hook
Search uses React Query for data fetching and caching:
app/(frontend)/hooks/query/useParticipantsQuery.ts
import { useQuery } from '@tanstack/react-query' ;
import { fetchParticipants } from '@/app/(frontend)/services/fetchParticipants' ;
import { queryKeys } from '../../lib/queryKeys' ;
export function useParticipantsQuery < T >( name : string , company : string ) {
return useQuery ({
queryKey: queryKeys . participants ( name , company ),
queryFn : () => fetchParticipants < T >( name , company ),
enabled: Boolean ( name ) && Boolean ( company ), // Only query when both params exist
staleTime: Infinity , // Always fresh on new params
gcTime: Infinity , // Don't persist old results
retry: false , // Don't retry on error
refetchOnWindowFocus: false
});
}
File location : app/(frontend)/hooks/query/useParticipantsQuery.ts:5
API Endpoint
The search queries the /api/participants endpoint:
GET / api / participants ? name = Juan & company = Colegio + A
Response format :
{
"participants" : [
{
"id" : "123" ,
"name" : "Juan" ,
"surname" : "García" ,
"company" : "Colegio A" ,
"year" : 2024
}
]
}
Search parameters are case-insensitive and support partial matching on names.
Search Results
Results Component
app/(frontend)/search/components/SearchCard/components/SearchResults/components/Results.tsx
import { SearchNotFound } from './SearchNotFound' ;
import { ResultsPlaceholder } from './ResultsPlaceholder' ;
import { ParticipantResultsList } from './ParticipantResultsList' ;
import { ResultsLoading } from './ResultsLoading' ;
import { useParticipants } from '@/app/(frontend)/search/hooks/useParticipants' ;
export function Results ({ params } : { params : { name : string ; company : string } | null }) {
const { participants , isLoading , isFetching , isError } = useParticipants ({ params });
if ( isLoading || isFetching ) return < ResultsLoading /> ;
if ( isError ) return < SearchNotFound /> ;
if ( participants && participants . length > 0 )
return < ParticipantResultsList participants = { participants } /> ;
return < ResultsPlaceholder /> ;
}
File location : app/(frontend)/search/components/SearchCard/components/SearchResults/components/Results.tsx:7
Loading State
Skeleton screen shown while fetching:
components/ResultsLoading.tsx
export function ResultsLoading () {
return (
< div className = "flex flex-col gap-3 animate-pulse" >
{ Array . from ({ length: 3 }). map (( _ , i ) => (
< div key = { i } className = "p-4 bg-gray-200 rounded-lg" >
< div className = "h-4 bg-gray-300 rounded w-3/4 mb-2" />
< div className = "h-4 bg-gray-300 rounded w-1/2" />
</ div >
)) }
</ div >
);
}
Empty State
Shown when no results are found:
components/SearchNotFound.tsx
export function SearchNotFound () {
return (
< div className = "text-center py-8" >
< p className = "text-lg font-semibold mb-2" > No se encontraron resultados </ p >
< p className = "text-sm text-(--color-text-secondary)" >
Intenta con otro nombre o colegio
</ p >
</ div >
);
}
Client-Side Validation
The form includes built-in validation:
import { useState } from 'react' ;
export function useForm ({ onSubmit }) {
const [ alert , setAlert ] = useState ( '' );
function formSubmit ( e : React . FormEvent < HTMLFormElement >) {
e . preventDefault ();
const formData = new FormData ( e . currentTarget );
const name = formData . get ( 'name' ) as string ;
const company = formData . get ( 'company' ) as string ;
// Validation
if ( ! name || name . length < 2 ) {
setAlert ( 'El nombre debe tener al menos 2 caracteres' );
return ;
}
if ( ! company ) {
setAlert ( 'Debes seleccionar un colegio' );
return ;
}
// Clear alert and submit
setAlert ( '' );
onSubmit ?.({ name , company });
}
return { formSubmit , alert };
}
Error Messages
Errors are displayed with Framer Motion animations:
< AnimatePresence mode = "wait" >
{ alert && (
< motion.p
initial = { { opacity: 0 , height: 0 } }
animate = { { opacity: 1 , height: 'auto' } }
exit = { { opacity: 0 , height: 0 } }
className = "text-sm text-(--color-error) font-semibold text-center"
>
{ alert }
</ motion.p >
) }
</ AnimatePresence >
Accessibility
ARIA Attributes
The search form is fully accessible:
< form aria-label = "Formulario de búsqueda" >
< label htmlFor = "name" > Nombre del participante </ label >
< input
id = "name"
name = "name"
aria-label = "Nombre del participante"
required
/>
< button
aria-label = "Selector de compañías"
aria-expanded = { isOpen }
aria-haspopup = "listbox"
aria-controls = "company-listbox"
>
Selecciona una compañía
</ button >
< div id = "company-listbox" role = "listbox" >
{ /* Options */ }
</ div >
</ form >
Keyboard Navigation
Tab : Navigate between fields
Enter : Submit form
Escape : Close dropdown
Arrow keys : Navigate dropdown options
Query Deduplication
React Query automatically deduplicates identical queries:
// Multiple components can call useParticipantsQuery
// Only 1 HTTP request is made
const query1 = useParticipantsQuery ( 'Juan' , 'Colegio A' );
const query2 = useParticipantsQuery ( 'Juan' , 'Colegio A' );
// → Only 1 request to /api/participants
Infinite Cache
Search results are cached indefinitely:
staleTime : Infinity ,
gcTime : Infinity ,
Users can navigate away and return to see cached results instantly without re-fetching.
Debouncing
The query only runs when both name AND company are provided:
enabled : Boolean ( name ) && Boolean ( company )
React Query Data fetching and caching patterns
Data Visualization Chart and table components