Skip to main content

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

  1. Navigate to search page (/search)
  2. Enter participant name (minimum 2 characters)
  3. Select school from dropdown
  4. Submit form or press Enter
  5. 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.

Search Form

Form Component

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

Name Input

Simple text input with validation:
components/NameInput.tsx
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
hooks/useDropdown.ts
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>
  );
}

Form Validation

Client-Side Validation

The form includes built-in validation:
hooks/useForm.ts
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

Performance Optimizations

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