Documentation Index Fetch the complete documentation index at: https://mintlify.com/ramistodev/tamborradata/llms.txt
Use this file to discover all available pages before exploring further.
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