L'estructura ja està muntada: el projecte arrenca, el linter no protesta i json-server respon. Però no hi ha res a mirar. Aquesta lliçó aixeca CicloUrbano sencer per dins: el sistema de disseny, el marc de l'aplicació i les vuit pantalles, totes navegables i amb aspecte definitiu.

I ho fa amb una restricció deliberada: amb dades estàtiques, sense tocar la xarxa. La raó no és pedagògica, és de mètode. Una interfície que ja es pot recórrer es valida en vint minuts amb qui l'utilitzarà, i els canvis que en surtin d'aquesta revisió —que sempre en surten— costen aquí una fracció del que costarien amb hooks de consulta, mutacions i invalidacions enganxats a sobre. Separar «com es veu» de «d'on vénen les dades» és el que fa que 11-03 sigui una lliçó de connexió i no una de reconstrucció.

Hi ha una segona idea que governa tot el que ve a continuació: els estats buit, de càrrega i d'error es dissenyen ara, no quan apareguin. La majoria de les interfícies lletges d'aquest món són interfícies que només es van dissenyar per al cas feliç. Aquí, cada pantalla s'escriu amb els seus quatre estats des del primer moment, encara que de moment tres d'ells s'activin amb una variable a mà.

Contingut

  1. Què es construeix aquí i què es deixa per a 11-03
  2. El sistema de disseny: index.css
  3. Tema clar i fosc amb data-tema
  4. Els components base i el seu contracte de props
  5. Disseny: el marc de l'aplicació
  6. Navegació amb NavLink i molles de pa
  7. Accessibilitat estructural: punts de referència i salt al contingut
  8. Les dades d'exemple mentre no hi ha xarxa
  9. Els quatre estats de cada pantalla
  10. Pantalla 1: el catàleg
  11. Pantalla 2: la fitxa d'una bicicleta
  12. Pantalles 3 i 4: estacions i detall amb pestanyes
  13. Pantalla 5: les meves reserves
  14. Pantalla 6: nova reserva
  15. Pantalles 7 i 8: accés i taller
  16. Les pantalles d'error
  17. Disseny adaptable amb CSS Modules
  18. La llista de comprovació d'accessibilitat per pantalla
  19. Recorregut de revisió de la interfície acabada

  1. Què es construeix aquí i què es deixa per a 11-03

Es construeix ara Es deixa per a 11-03
Variables de disseny, reinici i tema
Components base (Boto, Camp, Panell, Etiqueta, Modal)
Disseny, Capcalera, MollesDePa, PeuDePagina
Les vuit pantalles amb la seva composició completa
Estats buit, de càrrega i d'error maquetats Que s'activin amb dades reals
Formularis amb el seu marcatge accessible La validació connectada i l'enviament
Dades importades de src/dades/domini.js fetch, TanStack Query, Redux
Interacció local (useState per a pestanyes i modals) El filtre a la URL, la sessió real

Una conseqüència pràctica: en aquesta lliçó RutaProtegida deixa passar tothom. S'escriu el component, es col·loca a l'arbre de rutes i retorna <Outlet /> sense comprovar res, amb un comentari que ho assenyala. Així es pot navegar a /reservas i a /taller per revisar-ne l'aspecte, i a 11-03 només cal omplir la condició.

  1. El sistema de disseny: index.css

Tot allò visual del projecte surt d'aquí. Un fitxer, unes quantes variables, i cap decisió de color presa dues vegades.

/* src/index.css */

/* ---------- 1. Reinici mínim ---------- */
*,
*::before,
*::after {
  box-sizing: border-box;
}

* {
  margin: 0;
}

html {
  -webkit-text-size-adjust: 100%;
}

body {
  min-height: 100vh;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

img,
picture,
svg {
  display: block;
  max-width: 100%;
}

input,
button,
textarea,
select {
  font: inherit;
  color: inherit;
}

h1, h2, h3, h4 {
  line-height: 1.2;
  text-wrap: balance;
}

/* ---------- 2. Variables de disseny ---------- */
:root {
  /* Color */
  --color-marca: #12805c;
  --color-marca-fosca: #0d6247;
  --color-llogada: #b45309;
  --color-manteniment: #9b1c1c;
  --color-fons: #f5f7fa;
  --color-superficie: #ffffff;
  --color-text: #1f2933;
  --color-text-suau: #52606d;
  --color-vora: #d9e2ec;
  --color-focus: #2563eb;

  /* Forma */
  --radi: 8px;
  --ombra: 0 1px 3px rgba(15, 23, 42, 0.08), 0 1px 2px rgba(15, 23, 42, 0.04);
  --ombra-elevada: 0 10px 25px rgba(15, 23, 42, 0.15);

  /* Espaiat: una escala, no nombres solts */
  --espai: 1rem;
  --espai-xs: 0.25rem;
  --espai-s: 0.5rem;
  --espai-l: 1.5rem;
  --espai-xl: 2.5rem;

  /* Tipografia */
  --lletra: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
  --text-s: 0.875rem;
  --text-m: 1rem;
  --text-l: 1.25rem;
  --text-xl: 1.75rem;

  /* Altres */
  --ample-maxim: 72rem;
  --transicio: 150ms ease;
}

/* ---------- 3. Base ---------- */
body {
  font-family: var(--lletra);
  background-color: var(--color-fons);
  color: var(--color-text);
}

/* Focus visible SEMPRE. Mai outline: none sense substitut */
:focus-visible {
  outline: 3px solid var(--color-focus);
  outline-offset: 2px;
  border-radius: 2px;
}

/* Respecte per qui demana menys moviment */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

Tres decisions que convé raonar, perquè s'apliquen a cada component a partir d'ara:

  • L'escala d'espaiat té cinc valors i cap més. En el moment que s'admet un padding: 13px perquè «quedava millor», el sistema deixa d'existir i cada pantalla acaba amb el seu propi ritme visual. Si un valor no encaixa, s'ajusta l'escala, no el component.
  • :focus-visible al reinici, amb estil propi. L'anell del navegador es perd tan bon punt canvies els colors d'un botó; definir-lo una vegada aquí garanteix que cap part de l'aplicació pot quedar-se sense indicador de focus, que és la fallada d'accessibilitat més freqüent i més greu en aplicacions d'aquest tipus (03-06).
  • prefers-reduced-motion des del primer dia. Costa sis línies, i evita que les transicions provoquin molèsties reals a qui les ha desactivat al seu sistema.

  1. Tema clar i fosc amb data-tema

El tema s'implementa amb un atribut a l'element arrel i una redefinició de variables. Ni una sola regla de component canvia.

/* src/index.css — continuació */
:root[data-tema='fosc'] {
  --color-fons: #131a22;
  --color-superficie: #1b242f;
  --color-text: #e4ecf3;
  --color-text-suau: #9aa5b1;
  --color-vora: #2c3846;
  --color-marca: #1fa87a;         /* més lluminós: sobre fons fosc necessita més contrast */
  --color-llogada: #d97706;
  --color-manteniment: #ef4444;
  --ombra: 0 1px 3px rgba(0, 0, 0, 0.4);
  --ombra-elevada: 0 10px 25px rgba(0, 0, 0, 0.5);
}

El proveïdor, que en aquesta lliçó només desa el valor a memòria i a localStorage:

// src/contextos/ProveidorTema.jsx
import { createContext, useContext, useEffect, useMemo, useCallback } from 'react';
import { useMagatzemLocal } from '../hooks/useMagatzemLocal.js';

const ContextTema = createContext(null);

export function ProveidorTema({ children }) {
  const [tema, setTema] = useMagatzemLocal('ciclourbano:tema', 'clar');

  // Efecte de sincronització amb el DOM: l'atribut viu fora de React
  useEffect(() => {
    document.documentElement.dataset.tema = tema;
  }, [tema]);

  const alternarTema = useCallback(() => {
    setTema((actual) => (actual === 'clar' ? 'fosc' : 'clar'));
  }, [setTema]);

  // value estabilitzat: sense això, cada render del proveïdor repinta TOTS els consumidors
  const valor = useMemo(() => ({ tema, alternarTema }), [tema, alternarTema]);

  return <ContextTema.Provider value={valor}>{children}</ContextTema.Provider>;
}

export function useTema() {
  const context = useContext(ContextTema);
  if (context === null) {
    throw new Error('useTema s\'ha d\'utilitzar dins de ProveidorTema.');
  }
  return context;
}

Dos detalls que ja es van explicar i aquí s'apliquen sense discussió: el value memoïtzat de 07-02, que evita que tot l'arbre es repinti quan el proveïdor es renderitzi per qualsevol motiu; i el guardià al hook, que converteix l'error silenciós d'utilitzar useTema fora del proveïdor —on el context valdria null i explotaria amb un missatge incomprensible— en un error explícit que diu exactament què falta.

  1. Els components base i el seu contracte de props

Cinc components. Tota la resta es construeix a sobre. El seu valor no és al codi, que és trivial, sinó en el fet que existeixin i s'utilitzin: sense ells, en dues setmanes hi ha set botons diferents i cap no s'assembla als altres.

Primer, la utilitat que combina classes, ja coneguda del mòdul 2:

// src/utilitats/classes.js
export function classes(...valors) {
  return valors.filter(Boolean).join(' ');
}

Boto

// src/components/base/Boto.jsx
import { classes } from '../../utilitats/classes.js';
import estils from './Boto.module.css';

function Boto({
  variant = 'primari',
  mida = 'mitjana',
  tipus = 'button',
  carregant = false,
  ampleComplet = false,
  children,
  className,
  ...resta
}) {
  return (
    <button
      type={tipus}
      className={classes(
        estils.boto,
        estils[variant],
        estils[mida],
        ampleComplet && estils.ampleComplet,
        className
      )}
      aria-busy={carregant || undefined}
      disabled={carregant || resta.disabled}
      {...resta}
    >
      {carregant ? 'Un moment…' : children}
    </button>
  );
}

export default Boto;
Prop Tipus Per defecte Per a què
variant 'primari' | 'secundari' | 'perill' | 'text' 'primari' Jerarquia visual de l'acció
mida 'petita' | 'mitjana' | 'gran' 'mitjana' Densitat segons el context
tipus 'button' | 'submit' 'button' Per defecte button: evita enviaments accidentals
carregant boolean false Deshabilita i marca aria-busy
ampleComplet boolean false Ocupa tot l'ample (mòbil, formularis)
...resta onClick, disabled, aria-*, data-testid

Tres decisions del contracte que mereixen explicació:

  1. type="button" per defecte. El valor per defecte de l'HTML és submit, i aquest detall provoca una de les fallades més difícils de diagnosticar del desenvolupament web: un botó secundari dins d'un formulari que l'envia en polsar-lo. Invertir el valor per defecte i exigir tipus="submit" explícit ho elimina d'arrel.
  2. {...resta} al final. Permet passar qualsevol atribut natiu sense ampliar el contracte, i en anar després de className permet que qui l'utilitza sobreescrigui el que necessiti. És composició, no configuració (04-02).
  3. carregant deshabilita a més d'indicar. Un botó que diu «Un moment…» però es pot continuar polsant produeix reserves duplicades. L'estat visual i el funcional no se separen.

Camp

El component que garanteix que no pot existir un control sense etiqueta en tota l'aplicació.

// src/components/base/Camp.jsx
import { useId } from 'react';
import { classes } from '../../utilitats/classes.js';
import estils from './Camp.module.css';

function Camp({ etiqueta, error, ajuda, obligatori = false, children }) {
  const id = useId();
  const idError = `${id}-error`;
  const idAjuda = `${id}-ajuda`;

  // Es descriu amb l'ajuda i, si n'hi ha, amb l'error
  const descritPer = classes(ajuda && idAjuda, error && idError) || undefined;

  return (
    <div className={classes(estils.camp, error && estils.ambError)}>
      <label className={estils.etiqueta} htmlFor={id}>
        {etiqueta}
        {obligatori && (
          <span className={estils.obligatori} aria-hidden="true">
            {' '}*
          </span>
        )}
      </label>

      {ajuda && (
        <p className={estils.ajuda} id={idAjuda}>
          {ajuda}
        </p>
      )}

      {/* El control es rep com a funció per poder-hi injectar id i aria-* */}
      {children({
        id,
        'aria-invalid': error ? true : undefined,
        'aria-describedby': descritPer,
        'aria-required': obligatori || undefined
      })}

      {error && (
        <p className={estils.error} id={idError} role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

export default Camp;
// Ús
<Camp etiqueta="Durada (hores)" obligatori error={errors.hores} ajuda="Entre 1 i 24 hores">
  {(props) => (
    <input {...props} type="number" min="1" max="24" value={hores} onChange={gestionarHores} />
  )}
</Camp>

El patró de fills com a funció (04-02) resol aquí un problema real: Camp necessita generar un id únic amb useId i passar-lo al control per a l'htmlFor, però no sap si el control és un input, un select o un textarea. En passar les props calculades a una funció, qui l'utilitza decideix l'element i Camp garanteix el cablejat accessible. El resultat és que és més fàcil escriure un camp accessible que un que no ho sigui, que és l'única manera que l'accessibilitat sobrevisqui a les presses.

Panell, Etiqueta i Modal

// src/components/base/Panell.jsx
import { classes } from '../../utilitats/classes.js';
import estils from './Panell.module.css';

function Panell({ titol, nivell = 2, accions, children, className }) {
  const Encapcalament = `h${nivell}`;   // el nivell el decideix la pantalla, no el component

  return (
    <section className={classes(estils.panell, className)}>
      {(titol || accions) && (
        <header className={estils.capcalera}>
          {titol && <Encapcalament className={estils.titol}>{titol}</Encapcalament>}
          {accions && <div className={estils.accions}>{accions}</div>}
        </header>
      )}
      <div className={estils.cos}>{children}</div>
    </section>
  );
}

export default Panell;
// src/components/base/Etiqueta.jsx  (EtiquetaEstat n'és l'especialització)
import { classes } from '../../utilitats/classes.js';
import estils from './Etiqueta.module.css';

const TEXTOS = {
  disponible: 'Disponible',
  alquilada: 'Llogada',
  mantenimiento: 'En manteniment'
};

export function EtiquetaEstat({ estat }) {
  return (
    <span className={classes(estils.etiqueta, estils[estat])}>
      {TEXTOS[estat] ?? estat}
    </span>
  );
}

La prop nivell de Panell sembla un detall i no ho és: si Panell fixés sempre un <h2>, una pantalla amb panells imbricats produiria una jerarquia d'encapçalaments trencada, que és com qui navega amb lector de pantalla es perd. El component no pot saber a quin nivell del document el col·locaran; qui el col·loca, sí.

I Modal, amb les quatre obligacions d'un diàleg (03-06):

// src/components/base/Modal.jsx
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useEsdevenimentTeclat } from '../../hooks/useEsdevenimentTeclat.js';
import estils from './Modal.module.css';

function Modal({ obert, titol, alTancar, children }) {
  const contenidor = useRef(null);
  const elementPrevi = useRef(null);

  useEsdevenimentTeclat('Escape', alTancar, { actiu: obert });

  useEffect(() => {
    if (!obert) return;

    // 1) Recordar el focus d'origen i 2) portar-lo al diàleg
    elementPrevi.current = document.activeElement;
    contenidor.current?.focus();

    // 3) Bloquejar el desplaçament del fons
    const overflowPrevi = document.body.style.overflow;
    document.body.style.overflow = 'hidden';

    return () => {
      document.body.style.overflow = overflowPrevi;
      // 4) Retornar el focus a on era
      elementPrevi.current?.focus?.();
    };
  }, [obert]);

  if (!obert) return null;

  return createPortal(
    <div className={estils.vel} onClick={alTancar}>
      <div
        ref={contenidor}
        className={estils.dialeg}
        role="dialog"
        aria-modal="true"
        aria-labelledby="titol-modal"
        tabIndex={-1}
        onClick={(esdeveniment) => esdeveniment.stopPropagation()}
      >
        <h2 id="titol-modal" className={estils.titol}>{titol}</h2>
        {children}
      </div>
    </div>,
    document.body
  );
}

export default Modal;

El createPortal (05-03) és el que evita que el diàleg quedi atrapat dins d'un contenidor amb overflow: hidden o amb un z-index que el tapi. L'stopPropagation de l'interior impedeix que un clic dins del diàleg arribi al vel i el tanqui.

  1. Disseny: el marc de l'aplicació

// src/components/Disseny.jsx
import { Outlet } from 'react-router';
import Capcalera from './Capcalera.jsx';
import MollesDePa from './MollesDePa.jsx';
import LlistaAvisos from './LlistaAvisos.jsx';
import PeuDePagina from './PeuDePagina.jsx';
import estils from './Disseny.module.css';

function Disseny() {
  return (
    <div className={estils.contenidor}>
      <a className={estils.salta} href="#contingut">
        Salta al contingut principal
      </a>

      <Capcalera />
      <main className={estils.principal} id="contingut" tabIndex={-1}>
        <MollesDePa />
        <LlistaAvisos />
        <Outlet />
      </main>

      <PeuDePagina />
    </div>
  );
}

export default Disseny;
/* src/components/Disseny.module.css */
.contenidor {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}

.principal {
  flex: 1;
  width: 100%;
  max-width: var(--ample-maxim);
  margin: 0 auto;
  padding: var(--espai-l) var(--espai);
}

/* L'enllaç de salt: fora de pantalla fins que rep el focus */
.salta {
  position: absolute;
  left: -9999px;
  z-index: 100;
  padding: var(--espai-s) var(--espai);
  background: var(--color-marca);
  color: #fff;
  border-radius: 0 0 var(--radi) 0;
  text-decoration: none;
}

.salta:focus {
  left: 0;
  top: 0;
}

Cinc coses d'aquesta plantilla que valen per a tota l'aplicació:

  1. L'enllaç de salt és el primer element enfocable del document. Qui navega amb teclat no ha de travessar els sis enllaços de la navegació a cada pantalla. Està ocult visualment i apareix en rebre el focus: és el patró estàndard i no molesta a ningú més.
  2. min-height: 100vh amb flex: 1 al main manté el peu avall encara que la pàgina tingui tres línies. Sense això, la pantalla de «sense reserves» deixa el peu flotant a mitja alçada.
  3. tabIndex={-1} al main el fa enfocable per programa —no per tabulació— perquè l'enllaç de salt pugui portar-hi el focus de debò. Sense aquest atribut, el navegador mou el desplaçament però deixa el focus on era, i la tabulació següent torna al principi.
  4. LlistaAvisos va dins del main i abans de l'Outlet. Els missatges de resultat han d'aparèixer al flux del contingut, no com una capa flotant que un lector de pantalla anunciï fora de context.
  5. L'ample màxim viu aquí, no a cada pantalla. Una pantalla que necessiti tot l'ample el demanarà explícitament; la resta l'hereta.

  1. Navegació amb NavLink i molles de pa

// src/components/Capcalera.jsx
import { NavLink, Link } from 'react-router';
import MenuUsuari from './MenuUsuari.jsx';
import BotoTema from './BotoTema.jsx';
import { classes } from '../utilitats/classes.js';
import { NOM_APP } from '../configuracio.js';
import estils from './Capcalera.module.css';

const ENLLACOS = [
  { a: '/', text: 'Catàleg', exacte: true },
  { a: '/estaciones', text: 'Estacions' },
  { a: '/reservas', text: 'Les meves reserves' },
  { a: '/taller', text: 'Taller', nomesOperari: true }
];

function Capcalera() {
  // A 11-03 sortirà de la sessió real; ara es fixa per poder veure la pantalla
  const esOperari = true;

  const visibles = ENLLACOS.filter((enllac) => !enllac.nomesOperari || esOperari);

  return (
    <header className={estils.capcalera}>
      <div className={estils.interior}>
        <Link to="/" className={estils.marca}>
          <span aria-hidden="true">🚲</span> {NOM_APP}
        </Link>

        <nav className={estils.navegacio} aria-label="Principal">
          <ul className={estils.llista}>
            {visibles.map((enllac) => (
              <li key={enllac.a}>
                <NavLink
                  to={enllac.a}
                  end={enllac.exacte}
                  className={({ isActive }) =>
                    classes(estils.enllac, isActive && estils.actiu)
                  }
                >
                  {enllac.text}
                </NavLink>
              </li>
            ))}
          </ul>
        </nav>

        <div className={estils.accions}>
          <BotoTema />
          <MenuUsuari />
        </div>
      </div>
    </header>
  );
}

export default Capcalera;
/* src/components/Capcalera.module.css — l'estat actiu */
.enllac {
  display: block;
  padding: var(--espai-s) var(--espai);
  color: var(--color-text-suau);
  text-decoration: none;
  border-radius: var(--radi);
  transition: background-color var(--transicio);
}

.enllac:hover {
  background-color: var(--color-fons);
  color: var(--color-text);
}

.actiu {
  color: var(--color-marca);
  font-weight: 600;
  /* El color NO pot ser l'única senyal: s'afegeix una marca de forma */
  box-shadow: inset 0 -2px 0 var(--color-marca);
}

Dos punts que es van decidir al mòdul 6 i aquí es recullen:

  • end a l'enllaç al catàleg. Sense ell, / coincideix amb totes les rutes —perquè NavLink compara per prefix— i l'enllaç del catàleg apareixeria actiu estant a /reservas.
  • L'estat actiu no es senyala només amb color. Una persona amb daltonisme o amb un monitor en escala de grisos no distingeix l'enllaç actual si l'única diferència és el to. El subratllat gruixut ho resol, i no costa res.

Les molles de pa aprofiten el handle que ja portava cada ruta:

// src/components/MollesDePa.jsx
import { Link, useMatches } from 'react-router';
import estils from './MollesDePa.module.css';

function MollesDePa() {
  const coincidencies = useMatches().filter((m) => m.handle?.molla);

  if (coincidencies.length <= 1) return null;   // a l'inici no aporten res

  return (
    <nav aria-label="Ruta de navegació" className={estils.molles}>
      <ol className={estils.llista}>
        {coincidencies.map((coincidencia, index) => {
          const esDarrera = index === coincidencies.length - 1;
          const text =
            typeof coincidencia.handle.molla === 'function'
              ? coincidencia.handle.molla(coincidencia)
              : coincidencia.handle.molla;

          return (
            <li key={coincidencia.id} className={estils.molla}>
              {esDarrera ? (
                <span aria-current="page">{text}</span>
              ) : (
                <>
                  <Link to={coincidencia.pathname}>{text}</Link>
                  <span aria-hidden="true" className={estils.separador}>/</span>
                </>
              )}
            </li>
          );
        })}
      </ol>
    </nav>
  );
}

export default MollesDePa;

El separador / és decoratiu i porta aria-hidden: sense ell, un lector de pantalla llegiria «Inici barra Estacions barra Plaça Major». L'últim element no és un enllaç —no té sentit enllaçar a la pàgina on ja ets— i es marca amb aria-current="page".

  1. Accessibilitat estructural: punts de referència i salt al contingut

Abans d'escriure pantalles, convé fixar l'estructura del document que totes compartiran:

flowchart TD
    BODY["body"]
    BODY --> SALTO["a.salta → #contingut"]
    BODY --> HEAD["header · banner"]
    HEAD --> NAV["nav aria-label='Principal'"]
    HEAD --> ACC["Accions: tema i usuari"]
    BODY --> MAIN["main#contingut · main"]
    MAIN --> MIGAS["nav aria-label='Ruta de navegació'"]
    MAIN --> AVISOS["LlistaAvisos · role='status'"]
    MAIN --> OUT["Outlet: h1 de la pantalla + seccions"]
    BODY --> PIE["footer · contentinfo"]

Les regles que s'apliquen a totes les pantalles sense excepció:

Regla Per què
Un sol <h1> per pantalla, i és el títol de la pantalla És l'ancora d'orientació de qualsevol lector de pantalla
Els nivells no se salten (h1 → h2 → h3) Saltar d'h1 a h3 suggereix una secció inexistent
Cada <nav> amb el seu aria-label Hi ha dues navegacions; sense etiqueta són indistingibles a la llista de punts de referència
Només un <main> per document És la destinació de l'enllaç de salt
Les icones decoratives amb aria-hidden="true" «Emoji bicicleta Catàleg» no ajuda ningú
Els botons amb text real, no només icona I si la icona va sola, aria-label obligatori

  1. Les dades d'exemple mentre no hi ha xarxa

// src/dades/domini.js — còpia estàtica de db.json per treballar sense API
export const bicicletes = [
  { id: 'bici-001', model: 'Urbana Clàssica', tipus: 'urbana', estat: 'disponible', estacioId: 'est-01', preuHora: 2.5 },
  { id: 'bici-002', model: 'Elèctrica Pro', tipus: 'electrica', estat: 'alquilada', estacioId: 'est-01', preuHora: 4.0 },
  { id: 'bici-003', model: 'Càrrega Max', tipus: 'carga', estat: 'mantenimiento', estacioId: 'est-02', preuHora: 5.5 },
  { id: 'bici-004', model: 'Urbana Clàssica', tipus: 'urbana', estat: 'disponible', estacioId: 'est-03', preuHora: 2.5 },
  { id: 'bici-005', model: 'Elèctrica Pro', tipus: 'electrica', estat: 'disponible', estacioId: 'est-02', preuHora: 4.0 }
];

export const estacions = [
  { id: 'est-01', nom: 'Plaça Major', barri: 'Centre', places: 20 },
  { id: 'est-02', nom: 'Parc Nord', barri: 'Nord', places: 15 },
  { id: 'est-03', nom: 'Estació Central', barri: 'Eixample', places: 30 }
];

export const usuaris = [
  { id: 'usr-01', nom: 'Ana Ribera', email: '[email protected]', rol: 'cliente' },
  { id: 'usr-02', nom: 'Marc Solé', email: '[email protected]', rol: 'operario' }
];

export const reserves = [
  { id: 'res-01', bicicletaId: 'bici-002', usuari: 'usr-01', dataInici: '2026-05-04T09:00', hores: 2, estat: 'activa' }
];

És una còpia literal del db.json, a propòsit. Quan 11-03 substitueixi aquestes importacions per hooks de consulta, la forma de les dades serà idèntica i cap component haurà de canviar. Aquest és el motiu de mantenir la duplicació en lloc d'inventar dades noves aquí.

El fitxer no s'esborra a 11-03: passa a utilitzar-se a les proves i al db.json de sembra de Cypress.

  1. Els quatre estats de cada pantalla

Cada pantalla que mostri dades té quatre cares. Escriure-les ara costa minuts; afegir-les després, quan la lògica ja està entrellaçada, costa hores.

Estat Què es veu Component
Carregant Blocs grisos amb la forma del contingut final EsqueletPagina
Buit Explicació de per què no hi ha res i què fer Bloc propi de la pantalla
Error Missatge comprensible i botó de reintent Avis amb role="alert"
Amb dades El contingut La pantalla
// src/components/EsqueletPagina.jsx
import estils from './EsqueletPagina.module.css';

function EsqueletPagina({ files = 3, ambCapcalera = true }) {
  return (
    <div className={estils.esquelet} data-testid="esqueleto-pagina" aria-hidden="true">
      {ambCapcalera && <div className={estils.titol} />}
      {Array.from({ length: files }, (_, index) => (
        <div key={index} className={estils.fila} />
      ))}
    </div>
  );
}

export default EsqueletPagina;
// L'anunci per a lectors de pantalla va a part, perquè l'esquelet és aria-hidden
function IndicadorDeCarrega({ text = 'Carregant…' }) {
  return (
    <p role="status" aria-live="polite" className="visualment-ocult">
      {text}
    </p>
  );
}

El repartiment és deliberat: l'esquelet és informació purament visual i per a un lector de pantalla és soroll —anunciaria una dotzena de contenidors buits—, així que es amaga amb aria-hidden. La informació equivalent es dona amb un text en role="status" que és invisible però s'anuncia. Dos canals, un mateix missatge.

I l'estat buit, que és el que més es descuida:

// Malament: no diu res
<p>No hi ha reserves.</p>

// Bé: explica i ofereix la sortida
<div className={estils.buit}>
  <h2>Encara no tens reserves</h2>
  <p>Quan reservis una bicicleta apareixerà aquí amb la seva hora d'inici i la seva durada.</p>
  <Boto onClick={() => navegar('/')}>Veure el catàleg</Boto>
</div>

  1. Pantalla 1: el catàleg

La pantalla més visitada i la que més peces compon. Aquí, amb estat local; a 11-03, el filtre es trasllada a la URL i les dades a TanStack Query.

// src/pagines/PaginaCataleg.jsx
import { useState, useMemo, useDeferredValue } from 'react';
import Panell from '../components/base/Panell.jsx';
import SelectorTipus from '../components/SelectorTipus.jsx';
import CercadorBicicletes from '../components/CercadorBicicletes.jsx';
import LlistaBicicletes from '../components/LlistaBicicletes.jsx';
import ResumFlota from '../components/ResumFlota.jsx';
import EsqueletPagina from '../components/EsqueletPagina.jsx';
import Avis from '../components/Avis.jsx';
import { bicicletes, estacions } from '../dades/domini.js';
import estils from './PaginaCataleg.module.css';

// Interruptors temporals per revisar els estats. A 11-03 els substitueixen isPending i isError
const CARREGANT = false;
const AMB_ERROR = false;

function PaginaCataleg() {
  const [tipus, setTipus] = useState('todos');
  const [terme, setTerme] = useState('');

  // El terme diferit manté el camp fluid amb llistes grans (08-03)
  const termeDiferit = useDeferredValue(terme);

  const visibles = useMemo(() => {
    const text = termeDiferit.trim().toLowerCase();
    return bicicletes
      .filter((bici) => tipus === 'todos' || bici.tipus === tipus)
      .filter((bici) => bici.model.toLowerCase().includes(text));
  }, [tipus, termeDiferit]);

  if (CARREGANT) return <EsqueletPagina files={5} />;

  if (AMB_ERROR) {
    return (
      <Avis to="error" titol="No s'han pogut carregar les bicicletes">
        Comprova la teva connexió i torna-ho a provar.
      </Avis>
    );
  }

  return (
    <>
      <h1 className={estils.titol}>Catàleg de bicicletes</h1>
      <p className={estils.entradeta}>
        {visibles.length} de {bicicletes.length} bicicletes
      </p>

      <ResumFlota bicicletes={bicicletes} />

      <Panell titol="Filtres" nivell={2} className={estils.filtres}>
        <SelectorTipus tipusEscollit={tipus} alCanviarTipus={setTipus} />
        <CercadorBicicletes terme={terme} alCercar={setTerme} />
      </Panell>

      {visibles.length === 0 ? (
        <div className={estils.buit}>
          <h2>Cap bicicleta coincideix amb la cerca</h2>
          <p>Prova amb un altre tipus o esborra el text del cercador.</p>
        </div>
      ) : (
        <LlistaBicicletes bicicletes={visibles} estacions={estacions} />
      )}
    </>
  );
}

export default PaginaCataleg;

SelectorTipus com a grup de botons, amb useTransition perquè el canvi de filtre no bloquegi la interfície (08-01):

// src/components/SelectorTipus.jsx
import { useTransition } from 'react';
import { classes } from '../utilitats/classes.js';
import estils from './SelectorTipus.module.css';

const TIPUS = [
  { valor: 'todos', text: 'Totes' },
  { valor: 'urbana', text: 'Urbana' },
  { valor: 'electrica', text: 'Elèctrica' },
  { valor: 'carga', text: 'De càrrega' }
];

function SelectorTipus({ tipusEscollit, alCanviarTipus }) {
  const [pendent, iniciarTransicio] = useTransition();

  return (
    <div
      className={classes(estils.grup, pendent && estils.pendent)}
      role="group"
      aria-label="Filtrar per tipus de bicicleta"
    >
      {TIPUS.map((tipus) => (
        <button
          key={tipus.valor}
          type="button"
          className={classes(estils.boto, tipusEscollit === tipus.valor && estils.actiu)}
          aria-pressed={tipusEscollit === tipus.valor}
          onClick={() => iniciarTransicio(() => alCanviarTipus(tipus.valor))}
        >
          {tipus.text}
        </button>
      ))}
    </div>
  );
}

export default SelectorTipus;

aria-pressed és el que converteix quatre botons solts en un grup d'alternança comprensible: un lector de pantalla anuncia «Elèctrica, botó d'alternança, activat», que és exactament el que veu qui mira la pantalla.

I la targeta, memoïtzada perquè és l'element que més es repeteix (08-02):

// src/components/TargetaBicicleta.jsx
import { memo } from 'react';
import { Link } from 'react-router';
import { EtiquetaEstat } from './base/Etiqueta.jsx';
import Boto from './base/Boto.jsx';
import estils from './TargetaBicicleta.module.css';

function TargetaBicicleta({ bicicleta, estacio, alReservar }) {
  const disponible = bicicleta.estat === 'disponible';

  return (
    <article
      className={estils.targeta}
      data-testid="tarjeta-bicicleta"
      data-bicicleta={bicicleta.id}
    >
      <header className={estils.capcalera}>
        <h3 className={estils.model}>
          <Link to={`/bicicletas/${bicicleta.id}`}>{bicicleta.model}</Link>
        </h3>
        <EtiquetaEstat estat={bicicleta.estat} />
      </header>

      <dl className={estils.dades}>
        <div>
          <dt>Estació</dt>
          <dd>{estacio?.nom ?? 'Sense assignar'}</dd>
        </div>
        <div>
          <dt>Preu</dt>
          <dd>{bicicleta.preuHora.toFixed(2)} €/h</dd>
        </div>
      </dl>

      <Boto
        variant="primari"
        disabled={!disponible}
        onClick={() => alReservar?.(bicicleta.id)}
        aria-label={`Reservar ${bicicleta.model}`}
      >
        {disponible ? 'Reservar' : 'No disponible'}
      </Boto>
    </article>
  );
}

export default memo(TargetaBicicleta);

Fixa't en el <dl>: model i preu no són paràgrafs solts, són parells de terme i definició, i marcar-los així fa que un lector de pantalla anunciï «Estació: Plaça Major» en lloc de dos textos inconnexos. L'aria-label del botó resol el problema dels «cinc botons que diuen Reservar»: en navegar per la llista de botons, cada un diu quin és.

  1. Pantalla 2: la fitxa d'una bicicleta

// src/pagines/PaginaFitxaBicicleta.jsx
import { useParams, useNavigate, Link } from 'react-router';
import Panell from '../components/base/Panell.jsx';
import Boto from '../components/base/Boto.jsx';
import { EtiquetaEstat } from '../components/base/Etiqueta.jsx';
import { bicicletes, estacions } from '../dades/domini.js';
import estils from './PaginaFitxaBicicleta.module.css';

function PaginaFitxaBicicleta() {
  const { bicicletaId } = useParams();
  const navegar = useNavigate();

  const bicicleta = bicicletes.find((b) => b.id === bicicletaId);

  // L'identificador inexistent NO és una pantalla en blanc (H3)
  if (!bicicleta) {
    return (
      <div className={estils.noTrobada}>
        <h1>Aquesta bicicleta no existeix</h1>
        <p>L'identificador <code>{bicicletaId}</code> no correspon a cap bicicleta.</p>
        <Link to="/">Tornar al catàleg</Link>
      </div>
    );
  }

  const estacio = estacions.find((e) => e.id === bicicleta.estacioId);
  const disponible = bicicleta.estat === 'disponible';

  return (
    <>
      <header className={estils.capcalera}>
        <h1>{bicicleta.model}</h1>
        <EtiquetaEstat estat={bicicleta.estat} />
      </header>

      <div className={estils.columnes}>
        <Panell titol="Dades de la bicicleta" nivell={2}>
          <dl className={estils.dades}>
            <div><dt>Identificador</dt><dd>{bicicleta.id}</dd></div>
            <div><dt>Tipus</dt><dd>{bicicleta.tipus}</dd></div>
            <div><dt>Preu per hora</dt><dd>{bicicleta.preuHora.toFixed(2)} €</dd></div>
          </dl>
        </Panell>

        <Panell titol="Ubicació" nivell={2}>
          {estacio ? (
            <p>
              <Link to={`/estaciones/${estacio.id}`}>{estacio.nom}</Link>
              {' · '}Barri {estacio.barri} · {estacio.places} places
            </p>
          ) : (
            <p>Aquesta bicicleta no està assignada a cap estació.</p>
          )}
        </Panell>
      </div>

      <div className={estils.accions}>
        <Boto
          disabled={!disponible}
          onClick={() => navegar(`/reservas/nueva?bicicleta=${bicicleta.id}`)}
        >
          {disponible ? 'Reservar aquesta bicicleta' : 'No disponible ara mateix'}
        </Boto>
        {!disponible && (
          <p className={estils.motiu}>
            {bicicleta.estat === 'alquilada'
              ? 'Està llogada. Torna a consultar-la més tard.'
              : 'Està en manteniment. L\'equip del taller l\'està revisant.'}
          </p>
        )}
      </div>
    </>
  );
}

export default PaginaFitxaBicicleta;

Dos detalls de producte, no de codi: el botó deshabilitat explica per què ho està. Un control apagat sense explicació és la manera més ràpida de frustrar algú. I la reserva es preselecciona per la URL (?bicicleta=bici-001), de manera que qui arriba des de la fitxa no ha de tornar a triar la bicicleta al formulari.

  1. Pantalles 3 i 4: estacions i detall amb pestanyes

// src/pagines/PaginaEstacions.jsx
import TargetaEstacio from '../components/TargetaEstacio.jsx';
import { estacions, bicicletes } from '../dades/domini.js';
import estils from './PaginaEstacions.module.css';

function PaginaEstacions() {
  return (
    <>
      <h1>Estacions</h1>
      <p className={estils.entradeta}>{estacions.length} estacions en servei.</p>

      <ul className={estils.graella}>
        {estacions.map((estacio) => {
          const deLEstacio = bicicletes.filter((b) => b.estacioId === estacio.id);
          return (
            <li key={estacio.id}>
              <TargetaEstacio
                estacio={estacio}
                totalBicicletes={deLEstacio.length}
                disponibles={deLEstacio.filter((b) => b.estat === 'disponible').length}
              />
            </li>
          );
        })}
      </ul>
    </>
  );
}

export default PaginaEstacions;

El detall utilitza rutes imbricades per a les pestanyes (06-03), no estat local. Així la pestanya d'incidències és enllaçable i el botó enrere funciona:

// src/pagines/PaginaDetallEstacio.jsx
import { NavLink, Outlet, useParams } from 'react-router';
import { classes } from '../utilitats/classes.js';
import { estacions } from '../dades/domini.js';
import estils from './PaginaDetallEstacio.module.css';

function PaginaDetallEstacio() {
  const { estacionId } = useParams();
  const estacio = estacions.find((e) => e.id === estacionId);

  if (!estacio) {
    return <h1>Aquesta estació no existeix</h1>;
  }

  const classeEnllac = ({ isActive }) => classes(estils.pestanya, isActive && estils.activa);

  return (
    <>
      <h1>{estacio.nom}</h1>
      <p className={estils.entradeta}>
        Barri {estacio.barri} · {estacio.places} places
      </p>

      <nav className={estils.pestanyes} aria-label="Seccions de l'estació">
        <NavLink to="." end className={classeEnllac}>Flota</NavLink>
        <NavLink to="incidencias" className={classeEnllac}>Incidències</NavLink>
      </nav>

      {/* El contingut de la pestanya activa */}
      <Outlet context={{ estacio }} />
    </>
  );
}

export default PaginaDetallEstacio;
Pestanyes amb estat local Pestanyes com a rutes imbricades
No són enllaçables /estaciones/est-01/incidencias es pot compartir
El botó enrere surt de la pantalla El botó enrere torna a la pestanya anterior
Es perden en recarregar Sobreviuen a la recàrrega
Tot el contingut es carrega sempre Cada pestanya es pot carregar amb lazy

  1. Pantalla 5: les meves reserves

// src/pagines/PaginaReserves.jsx
import { useState } from 'react';
import { Link } from 'react-router';
import PanellReserves from '../components/PanellReserves.jsx';
import Modal from '../components/base/Modal.jsx';
import Boto from '../components/base/Boto.jsx';
import { reserves, bicicletes } from '../dades/domini.js';
import estils from './PaginaReserves.module.css';

function PaginaReserves() {
  // Estat local pur: quina reserva està pendent de confirmació de cancel·lació
  const [reservaACancellar, setReservaACancellar] = useState(null);

  return (
    <>
      <div className={estils.capcalera}>
        <h1>Les meves reserves</h1>
        <Link to="/reservas/nueva" className={estils.enllacAccio}>
          Nova reserva
        </Link>
      </div>

      {reserves.length === 0 ? (
        <div className={estils.buit}>
          <h2>Encara no tens reserves</h2>
          <p>Quan reservis una bicicleta apareixerà aquí amb la seva hora d'inici i la seva durada.</p>
          <Link to="/">Veure el catàleg</Link>
        </div>
      ) : (
        <PanellReserves
          reserves={reserves}
          bicicletes={bicicletes}
          alCancellar={setReservaACancellar}
        />
      )}

      <Modal
        obert={reservaACancellar !== null}
        titol="Cancel·lar la reserva"
        alTancar={() => setReservaACancellar(null)}
      >
        <p>Segur que vols cancel·lar aquesta reserva? Aquesta acció no es pot desfer.</p>
        <div className={estils.accionsModal}>
          <Boto variant="secundari" onClick={() => setReservaACancellar(null)}>
            Tornar
          </Boto>
          <Boto variant="perill" onClick={() => setReservaACancellar(null)}>
            Sí, cancel·la
          </Boto>
        </div>
      </Modal>
    </>
  );
}

export default PaginaReserves;

La taula de reserves, amb l'ancoratge de prova ja col·locat:

// src/components/PanellReserves.jsx (extracte)
<table className={estils.taula}>
  <caption className="visualment-ocult">Llista de les teves reserves</caption>
  <thead>
    <tr>
      <th scope="col">Bicicleta</th>
      <th scope="col">Inici</th>
      <th scope="col">Hores</th>
      <th scope="col">Estat</th>
      <th scope="col"><span className="visualment-ocult">Accions</span></th>
    </tr>
  </thead>
  <tbody>
    {reserves.map((reserva) => (
      <tr key={reserva.id} data-testid="fila-reserva">
        <td>{bicicletaDe(reserva)?.model ?? 'Desconeguda'}</td>
        <td>{formatarData(reserva.dataInici)}</td>
        <td>{reserva.hores}</td>
        <td><EtiquetaEstat estat={reserva.estat} /></td>
        <td>
          <Boto
            variant="text"
            onClick={() => alCancellar(reserva)}
            disabled={reserva.estat === 'cancelada'}
          >
            Cancel·la
          </Boto>
        </td>
      </tr>
    ))}
  </tbody>
</table>

Una taula de dades ha de ser una <table>, no una graella de <div>. Amb scope="col" a les capçaleres, un lector de pantalla anuncia «Estat: activa» en recórrer la fila; amb divs, llegeix cinc valors solts sense saber a què corresponen. El <caption> ocult visualment dona el context de la taula completa.

  1. Pantalla 6: nova reserva

El formulari és la pantalla amb més càrrega d'accessibilitat de tot el projecte.

// src/components/FormulariReserva.jsx
import { useState } from 'react';
import Camp from './base/Camp.jsx';
import Boto from './base/Boto.jsx';
import estils from './FormulariReserva.module.css';

function FormulariReserva({ bicicletes, valorInicial, enviant = false, alEnviar }) {
  const [dades, setDades] = useState(valorInicial);
  const [errors, setErrors] = useState({});

  const reservables = bicicletes.filter((b) => b.estat === 'disponible');
  const escollida = bicicletes.find((b) => b.id === dades.bicicletaId);
  const total = escollida ? escollida.preuHora * Number(dades.hores || 0) : 0;

  function gestionarCanvi(camp, valor) {
    setDades((actual) => ({ ...actual, [camp]: valor }));
  }

  function gestionarEnviar(esdeveniment) {
    esdeveniment.preventDefault();
    // A 11-03 aquí entra validarReserva i la mutació
    alEnviar?.(dades);
  }

  return (
    <form className={estils.formulari} onSubmit={gestionarEnviar} noValidate>
      <Camp etiqueta="Bicicleta" obligatori error={errors.bicicletaId}>
        {(props) => (
          <select
            {...props}
            value={dades.bicicletaId}
            onChange={(e) => gestionarCanvi('bicicletaId', e.target.value)}
          >
            <option value="">Tria una bicicleta</option>
            {reservables.map((bici) => (
              <option key={bici.id} value={bici.id}>
                {bici.model} — {bici.preuHora.toFixed(2)} €/h
              </option>
            ))}
          </select>
        )}
      </Camp>

      <Camp
        etiqueta="Inici de la reserva"
        obligatori
        error={errors.dataInici}
        ajuda="No pot començar en el passat"
      >
        {(props) => (
          <input
            {...props}
            type="datetime-local"
            value={dades.dataInici}
            onChange={(e) => gestionarCanvi('dataInici', e.target.value)}
          />
        )}
      </Camp>

      <Camp etiqueta="Durada (hores)" obligatori error={errors.hores} ajuda="Entre 1 i 24">
        {(props) => (
          <input
            {...props}
            type="number"
            min="1"
            max="24"
            value={dades.hores}
            onChange={(e) => gestionarCanvi('hores', e.target.value)}
          />
        )}
      </Camp>

      <Camp etiqueta="Accepto les condicions d'ús" error={errors.condicions}>
        {(props) => (
          <input
            {...props}
            type="checkbox"
            checked={dades.condicions}
            onChange={(e) => gestionarCanvi('condicions', e.target.checked)}
          />
        )}
      </Camp>

      <p className={estils.total} data-testid="total-reserva">
        Total estimat: <strong>{total.toFixed(2)} €</strong>
      </p>

      <Boto tipus="submit" carregant={enviant} ampleComplet>
        Crear reserva
      </Boto>
    </form>
  );
}

export default FormulariReserva;

Les decisions del formulari, totes heretades de 03-04 i 03-05:

Decisió Motiu
noValidate al <form> Es desactiva la validació del navegador per utilitzar la pròpia, amb missatges en català, consistents i traduïbles
El select només llista bicicletes disponible Impedir l'error és millor que informar-ne després
El total es calcula al render Mai es desa en estat un valor derivat (05-01)
Els errors arriben per prop des de Camp El cablejat aria-invalid / aria-describedby es resol en un sol lloc
tipus="submit" explícit Conseqüència del valor per defecte invertit de Boto
data-testid="total-reserva" Ancoratge estable per a 11-04

  1. Pantalles 7 i 8: accés i taller

// src/pagines/PaginaAcces.jsx (extracte)
function PaginaAcces() {
  const [correu, setCorreu] = useState('');
  const [error, setError] = useState(null);

  function gestionarEnviar(esdeveniment) {
    esdeveniment.preventDefault();
    // A 11-03: despatx a sliceSessio i redirecció a l'origen
  }

  return (
    <div className={estils.centre}>
      <h1>Accedir a CicloUrbano</h1>

      {error && (
        <div role="alert" className={estils.error}>
          {error}
        </div>
      )}

      <form onSubmit={gestionarEnviar} noValidate>
        <Camp etiqueta="Correu electrònic" obligatori ajuda="Prova amb [email protected]">
          {(props) => (
            <input
              {...props}
              type="email"
              autoComplete="email"
              value={correu}
              onChange={(e) => setCorreu(e.target.value)}
            />
          )}
        </Camp>

        <Boto tipus="submit" ampleComplet>Entrar</Boto>
      </form>
    </div>
  );
}

L'autoComplete="email" no és opcional: és el que permet que el gestor de contrasenyes i l'emplenament automàtic del navegador funcionin, i la seva absència és una de les queixes més freqüents de les persones usuàries en formularis d'accés.

I el taller, que en aquesta lliçó és accessible per poder revisar-lo:

// src/components/RutaProtegida.jsx — versió d'11-02
import { Outlet } from 'react-router';

function RutaProtegida() {
  // TODO(11-03): comprovar la sessió real i redirigir a /acceso conservant l'origen
  return <Outlet />;
}

export default RutaProtegida;
// src/pagines/PaginaTaller.jsx (extracte)
function PaginaTaller() {
  const enManteniment = bicicletes.filter((b) => b.estat === 'mantenimiento');
  const resta = bicicletes.filter((b) => b.estat !== 'mantenimiento');

  return (
    <>
      <h1>Taller</h1>
      <p className={estils.entradeta}>
        Gestió de l'estat de la flota. Només per a personal operari.
      </p>

      <Panell titol={`En manteniment (${enManteniment.length})`} nivell={2}>
        {enManteniment.length === 0 ? (
          <p>Tota la flota està operativa.</p>
        ) : (
          <ul className={estils.llista}>
            {enManteniment.map((bici) => (
              <li key={bici.id}>
                {bici.model} <code>{bici.id}</code>
                <Boto variant="secundari" mida="petita">Retornar al servei</Boto>
              </li>
            ))}
          </ul>
        )}
      </Panell>

      <Panell titol={`Resta de la flota (${resta.length})`} nivell={2}>
        {/* mateix patró, amb el botó "Enviar a manteniment" */}
      </Panell>
    </>
  );
}

Fixa't en l'ordre: el que l'operari necessita veure primer està primer. És la conseqüència directa del quadre de persones usuàries d'11-01, i és el tipus de decisió que només es pren bé si s'ha fet aquesta feina prèvia.

  1. Les pantalles d'error

Tres, i cadascuna respon a una situació diferent:

// src/pagines/PaginaNoTrobada.jsx — ruta * : la URL no existeix
function PaginaNoTrobada() {
  return (
    <div className={estils.centre}>
      <h1>Aquesta pàgina no existeix</h1>
      <p>Pot ser que l'enllaç estigui mal escrit o que la pàgina s'hagi mogut.</p>
      <Link to="/">Anar al catàleg</Link>
    </div>
  );
}
// src/pagines/PaginaSensePermisos.jsx — la URL existeix, però el rol no n'hi ha prou
function PaginaSensePermisos() {
  return (
    <div className={estils.centre}>
      <h1>No tens permís per veure aquesta pàgina</h1>
      <p>L'àrea de taller està reservada al personal operari.</p>
      <Link to="/">Tornar al catàleg</Link>
    </div>
  );
}
// src/pagines/PaginaErrorRuta.jsx — errorElement: alguna cosa ha fallat en renderitzar la ruta
import { useRouteError, isRouteErrorResponse, Link } from 'react-router';
import { registrarError } from '../utilitats/monitoritzacio.js';

function PaginaErrorRuta() {
  const error = useRouteError();
  registrarError(error, { origen: 'errorElement' });

  const missatge = isRouteErrorResponse(error)
    ? `Error ${error.status}: ${error.statusText}`
    : 'S\'ha produït un problema en mostrar aquesta pantalla.';

  return (
    <div className={estils.centre} role="alert">
      <h1>Alguna cosa no ha anat bé</h1>
      <p>{missatge}</p>
      <Link to="/">Tornar al catàleg</Link>
      {import.meta.env.DEV && <pre className={estils.detall}>{String(error)}</pre>}
    </div>
  );
}
Pantalla Quan apareix Qui l'activa
PaginaNoTrobada La URL no coincideix amb cap ruta La ruta *
PaginaSensePermisos Hi ha sessió, però el rol no n'hi ha prou RequereixRol (a 11-03)
PaginaErrorRuta Excepció en renderitzar una ruta errorElement de React Router
LimitError Excepció fora de l'enrutador El límit de main.jsx

El detall tècnic de l'error només es mostra en desenvolupament (import.meta.env.DEV). En producció, una traça de pila en pantalla no ajuda l'usuari i sí que orienta qui busqui forats.

  1. Disseny adaptable amb CSS Modules

La graella del catàleg, sense un sol càlcul en JavaScript:

/* src/components/LlistaBicicletes.module.css */
.llista {
  list-style: none;
  padding: 0;
  display: grid;
  gap: var(--espai);
  /* Tantes columnes com hi càpiguen, amb un mínim de 260px cadascuna */
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}

Aquesta única línia substitueix el que en altres temps eren tres media queries: auto-fill amb minmax reparteix l'espai disponible sense que ningú hagi de decidir punts de ruptura. Quan sí que calen, van així:

/* src/components/Capcalera.module.css */
.llista {
  display: flex;
  gap: var(--espai-s);
  list-style: none;
  margin: 0;
  padding: 0;
}

@media (max-width: 48rem) {
  .interior {
    flex-wrap: wrap;
  }

  .navegacio {
    order: 3;
    width: 100%;
  }

  .llista {
    overflow-x: auto;          /* la navegació es desplaça en horitzontal */
    scrollbar-width: none;
    padding-bottom: var(--espai-xs);
  }
}

Per què les media queries van al CSS i no en JavaScript, encara que el projecte tingui un hook useAmpladaFinestra:

CSS (@media) JavaScript (useAmpladaFinestra)
Quan s'aplica Al primer pintat Després de muntar i mesurar: hi ha un parpelleig
Cost Nul: ho resol el motor d'estils Un esdeveniment de resize i un repintat de React
Si el JavaScript falla o triga Continua funcionant La maquetació queda malament
Redimensionar la finestra Instantani Repinta tot l'arbre afectat
Quan és l'opció correcta Gairebé sempre Quan cal renderitzar components diferents, no només estilar-los

A CicloUrbano, useAmpladaFinestra es reserva per a un únic cas: decidir si el menú d'usuari es desplega cap avall o obre un panell a pantalla completa, perquè són estructures de DOM diferents i no una diferència d'estils. Tota la resta —graella, navegació, tipografies, espaiats— és CSS.

Els tres punts de ruptura del projecte, i cap més:

Nom Ample Què canvia
Mòbil < 48rem Una columna, navegació desplaçable, botons a ample complet
Tauleta 48rem – 64rem Dues columnes al catàleg, navegació en línia
Escriptori > 64rem Graella completa, columnes laterals a la fitxa

  1. La llista de comprovació d'accessibilitat per pantalla

S'aplica a cada pantalla abans de donar-la per acabada. Vuit punts, cinc minuts:

# Comprovació Com es verifica
1 Un sol <h1> i jerarquia sense salts Extensió d'accessibilitat del navegador, vista d'encapçalaments
2 Es pot completar la tasca només amb el teclat Guarda el ratolí i recorre la pantalla amb Tab, Enter i Escape
3 El focus és visible en tot moment Mentre tabules, sempre es veu on ets
4 L'ordre de tabulació segueix l'ordre visual Si salta cap enrere, el DOM i el CSS discrepen
5 Tot control té nom accessible Inspector d'accessibilitat: cap element sense nom
6 Els errors s'anuncien (role="alert") i s'associen (aria-describedby) Provoca un error i escolta amb un lector de pantalla
7 El contrast arriba a 4.5:1 en text normal Eina de contrast del navegador, en els dos temes
8 La informació no depèn només del color Mira la pantalla en escala de grisos

El punt 7 té trampa i convé assenyalar-ho: un tema fosc ben contrastat no s'obté invertint els colors. --color-marca: #12805c compleix sobre blanc i es queda curt sobre #131a22; per això el tema fosc el puja a #1fa87a. Cal mesurar els dos temes per separat.

  1. Recorregut de revisió de la interfície acabada

Abans de connectar res, es recorre l'aplicació sencera. Aquest és el guió, amb el que s'ha de veure a cada parada:

# Ruta Què s'ha de veure
1 / Capçalera amb quatre enllaços, «Catàleg» actiu amb el seu subratllat. Resum de flota amb 3 disponibles, 1 llogada i 1 en manteniment. Graella de cinc targetes: bici-002 i bici-003 amb el botó apagat i la seva etiqueta de color
2 / + filtre «Elèctrica» Dues targetes. El botó queda marcat com a activat
3 / + cercar «carga» Una targeta. En esborrar el text, tornen les cinc
4 / + filtre «De càrrega» + cercar «urbana» Estat buit amb la seva explicació, no una graella en blanc
5 /bicicletas/bici-001 Fitxa amb dos panells, molles «Inici / Fitxa de bicicleta», botó de reserva actiu
6 /bicicletas/bici-003 Botó apagat amb el motiu escrit a sota
7 /bicicletas/no-existe Missatge propi de bicicleta inexistent, amb enllaç de tornada
8 /estaciones Tres targetes amb el seu recompte de bicicletes i disponibles
9 /estaciones/est-01 Pestanya «Flota» activa, dues bicicletes. En prémer «Incidències», la URL canvia i el botó enrere torna
10 /reservas Taula amb una fila. Prémer «Cancel·la» obre el modal, Escape el tanca i el focus torna al botó
11 /reservas/nueva Formulari amb els quatre camps etiquetats i el total recalculant-se en canviar les hores
12 /acceso Formulari centrat, camp amb autoComplete
13 /taller Secció de manteniment primer, resta de la flota després
14 /ruta-inventada PaginaNoTrobada amb la capçalera i el peu al seu lloc
15 Botó de tema Tota l'aplicació canvia; en recarregar, el tema es manté
16 Finestra a 375 px Una columna, navegació desplaçable, sense desplaçament horitzontal al cos

I les cinc comprovacions transversals que tanquen la revisió:

  • [ ] Amb el teclat, de cap a cap: primer Tab a «Salta al contingut principal», i des d'aquí s'arriba a tot.
  • [ ] Sense desplaçament horitzontal a 320 px d'ample en cap pantalla.
  • [ ] Amb el tema fosc activat, cap text perd contrast.
  • [ ] Amb la consola oberta, ni un avís de React (claus duplicades, props desconegudes, imbricació no vàlida).
  • [ ] npm run lint en verd, incloses les regles de jsx-a11y.

És en aquest punt on s'ensenya l'aplicació a qui la utilitzarà. Els canvis que surtin d'aquesta conversa —«el preu s'hauria de veure abans que l'estació», «cancel·lar hauria d'estar més amagat»— costen avui deu minuts. Després d'11-03 costarien una tarda.

Errors Comuns i Consells

  • Començar per les pantalles i no pel sistema de disseny. És la via ràpida per tenir sis grisos diferents i quatre mides de botó. Mitja hora a index.css i als cinc components base estalvia dies d'harmonització posterior.
  • Dissenyar només el cas feliç. Si l'estat buit, el de càrrega i el d'error no es maqueten alhora que el contingut, no es maqueten mai: apareixen en producció com un buit en blanc o un text solt al racó.
  • <div onClick> en lloc de <button>. No rep focus, no respon a Enter ni a espai i no s'anuncia com a control. jsx-a11y ho detecta, però l'hàbit correcte és utilitzar l'element natiu i estilar-lo.
  • outline: none per «que quedi més net». Deixa sense indicador de focus qui navega amb teclat. Si l'anell per defecte no encaixa, se substitueix per un altre amb :focus-visible; mai s'elimina.
  • Fixar el nivell d'encapçalament dins d'un component reutilitzable. Un Panell que sempre pinta <h2> trenca la jerarquia tan bon punt s'imbrica. El nivell és una prop.
  • Media queries simulades en JavaScript. useAmpladaFinestra per decidir el nombre de columnes produeix parpelleig a la càrrega i repintats a cada resize. El CSS ho fa millor, abans i gratis.
  • Placeholder en lloc de <label>. El text desapareix en escriure, molts lectors de pantalla no l'anuncien com a nom i el contrast sol ser insuficient. Camp força l'etiqueta precisament per això.
  • Botó deshabilitat sense explicació. «No disponible» sense dir per què converteix una limitació comprensible en una frustració.
  • Consell: revisa cada pantalla en escala de grisos. Si deixes de distingir l'estat d'una bicicleta o l'enllaç actiu, el color està fent una feina que no pot fer sol.
  • Consell: deixa els interruptors CARREGANT i AMB_ERROR al codi mentre duri aquesta lliçó. Posar-ne un a true és la forma més ràpida de revisar un estat que encara no pot passar, i a 11-03 se substitueixen per isPending i isError sense tocar el marcatge.

Exercicis

Exercici 1. ResumFlota mostra el recompte de bicicletes per estat. Escriu-lo complet: rep bicicletes, calcula quantes n'hi ha en cada estat, i les presenta amb una barra proporcional. Requisits: ha d'estar memoïtzat, la informació no pot dependre només del color, el recompte ha de ser llegible per a un lector de pantalla, i no pot trencar-se amb una llista buida. Indica també quin nivell d'encapçalament faries servir i per què.

Exercici 2. La revisió de l'apartat 19 retorna tres problemes de la pantalla /reservas: (a) en tancar el modal amb Escape, el focus es perd i la tabulació següent comença per la capçalera; (b) en un mòbil de 360 px, la taula provoca desplaçament horitzontal a tota la pàgina; (c) l'estat de la reserva es distingeix només pel color de l'etiqueta. Diagnostica i corregeix cadascun indicant el fitxer que tocaries.

Exercici 3. L'equip proposa substituir el SelectorTipus de quatre botons per un <select> desplegable «perquè ocupa menys». Avalua la proposta des de tres angles —usabilitat, accessibilitat i el que farà 11-03 amb el filtre— i dona una recomanació raonada. Si l'acceptes en algun cas, escriu el codi de la variant i explica en quines condicions es faria servir cadascuna.

Solucions

Solució 1.

// src/components/ResumFlota.jsx
import { memo, useMemo } from 'react';
import estils from './ResumFlota.module.css';

const ESTATS = [
  { clau: 'disponible', text: 'Disponibles', simbol: '●' },
  { clau: 'alquilada', text: 'Llogades', simbol: '▲' },
  { clau: 'mantenimiento', text: 'En manteniment', simbol: '■' }
];

function ResumFlota({ bicicletes }) {
  const recompte = useMemo(() => {
    return bicicletes.reduce(
      (acumulat, bici) => ({ ...acumulat, [bici.estat]: (acumulat[bici.estat] ?? 0) + 1 }),
      { disponible: 0, alquilada: 0, mantenimiento: 0 }
    );
  }, [bicicletes]);

  const total = bicicletes.length;

  // Estat buit: sense això hi hauria una divisió per zero en el percentatge
  if (total === 0) {
    return (
      <section className={estils.resum} aria-labelledby="titol-resum">
        <h2 id="titol-resum" className={estils.titol}>Estat de la flota</h2>
        <p>No hi ha bicicletes registrades.</p>
      </section>
    );
  }

  return (
    <section className={estils.resum} aria-labelledby="titol-resum">
      <h2 id="titol-resum" className={estils.titol}>Estat de la flota</h2>

      <ul className={estils.llista}>
        {ESTATS.map(({ clau, text, simbol }) => {
          const quantitat = recompte[clau];
          const percentatge = Math.round((quantitat / total) * 100);

          return (
            <li key={clau} className={estils.item}>
              <span className={estils.encapcalament}>
                {/* Símbol: segona senyal a més del color */}
                <span className={estils[clau]} aria-hidden="true">{simbol}</span>
                <span className={estils.text}>{text}</span>
                <span className={estils.quantitat}>
                  {quantitat}
                  <span className="visualment-ocult"> de {total} bicicletes</span>
                </span>
              </span>

              {/* La barra és purament visual: la dada ja és al text */}
              <div className={estils.pista} aria-hidden="true">
                <div
                  className={`${estils.barra} ${estils[clau]}`}
                  style={{ inlineSize: `${percentatge}%` }}
                />
              </div>
            </li>
          );
        })}
      </ul>
    </section>
  );
}

export default memo(ResumFlota);

Les decisions i la seva justificació:

Requisit Com es compleix
Memoïtzat memo a l'exportació i useMemo al recompte. bicicletes arriba com a referència estable des del catàleg, així que memo funciona de debò (08-02)
No només color Cada estat porta símbol diferent (, , ) a més del seu color, i el nom escrit
Llegible per lector de pantalla La dada és en text (3 de 5 bicicletes), la barra és aria-hidden i el número porta el seu context en un text ocult visualment
Sense trencar-se amb llista buida Retorn anticipat amb total === 0, que a més evita NaN% a la barra
Nivell d'encapçalament h2: l'h1 és «Catàleg de bicicletes» i aquesta és una secció de primer nivell dins de la pantalla. A més, aria-labelledby connecta la <section> amb el seu títol, perquè aparegui anomenada a la llista de regions

Un detall que sol passar-se per alt: el percentatge s'arrodoneix per pintar, però mai substitueix el número. «60 %» no diu quantes bicicletes són; «3 de 5» sí.

Solució 2.

(a) El focus perdut en tancar el modal. El diagnòstic és que Modal guarda document.activeElement a l'efecte que s'executa quan obert passa a true, però el component ja està retornant null abans d'això si es munta tancat… i sobretot, l'element que va obrir el modal pot haver-se desmuntat. A PaginaReserves passa el segon de forma subtil: en tancar, es crida setReservaACancellar(null) i la fila es torna a renderitzar, amb la qual cosa elementPrevi.current pot apuntar a un node que ja no és al document. Enfocar un node desconnectat és una operació silenciosa: no falla, simplement no fa res, i el focus cau al <body>.

La correcció, a src/components/base/Modal.jsx, amb una comprovació de connexió i una destinació de reserva:

useEffect(() => {
  if (!obert) return;

  elementPrevi.current = document.activeElement;
  contenidor.current?.focus();

  const overflowPrevi = document.body.style.overflow;
  document.body.style.overflow = 'hidden';

  return () => {
    document.body.style.overflow = overflowPrevi;

    const previ = elementPrevi.current;
    // Només si encara existeix I està connectat al document
    if (previ instanceof HTMLElement && previ.isConnected) {
      previ.focus();
    } else {
      // Reserva: el contingut principal, que és enfocable per tabIndex={-1}
      document.getElementById('contingut')?.focus();
    }
  };
}, [obert]);

La reserva al main no és un pegat: és la conducta correcta quan l'origen del focus ha desaparegut, perquè deixa la persona al principi del contingut i no al llimbe del <body>.

(b) Desplaçament horitzontal per la taula. L'error de diagnòstic habitual aquí és tocar el body. El problema no és la pàgina: és que la taula és més ampla que el seu contenidor i arrossega tota la resta. La regla és que el contingut ample es desplaça dins de la seva pròpia caixa, mai la pàgina sencera. A src/components/PanellReserves.module.css:

.contenidorTaula {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}

.taula {
  width: 100%;
  min-width: 34rem;      /* per sota, les columnes s'apilen: millor desplaçar */
  border-collapse: collapse;
}

/* I en mòbil, columnes menys crítiques fora */
@media (max-width: 30rem) {
  .columnaHores {
    display: none;
  }
}
// PanellReserves.jsx
<div className={estils.contenidorTaula}>
  <table className={estils.taula}>…</table>
</div>

Ocultar la columna «Hores» en pantalles molt petites és acceptable perquè la dada continua sent al detall de la reserva; si fos l'única manera de conèixer-la, amagar-la seria perdre informació, no adaptar-la.

(c) L'estat només per color. A src/components/base/Etiqueta.module.css i al component: l'etiqueta ja porta el text («Disponible», «Llogada»), així que si el problema persisteix és perquè a la taula es va reduir a un punt de color. La correcció és mantenir sempre el text i afegir una segona senyal de forma:

.etiqueta {
  display: inline-flex;
  align-items: center;
  gap: var(--espai-xs);
  padding: 0.15rem var(--espai-s);
  border-radius: 999px;
  font-size: var(--text-s);
  font-weight: 600;
  border: 1px solid currentColor;   /* la vora dona forma a més de color */
}

.disponible { color: var(--color-marca); background: color-mix(in srgb, var(--color-marca) 12%, transparent); }
.alquilada { color: var(--color-llogada); background: color-mix(in srgb, var(--color-llogada) 12%, transparent); }
.mantenimiento { color: var(--color-manteniment); background: color-mix(in srgb, var(--color-manteniment) 12%, transparent); }

La prova definitiva és la de l'apartat 18: posar la pantalla en escala de grisos. Si amb els colors anul·lats continues distingint els tres estats —perquè el text els anomena—, està ben resolt.

Solució 3.

Recomanació: mantenir el grup de botons al catàleg. L'argument té tres parts.

Usabilitat. El criteri establert per triar entre botons i desplegable és el nombre d'opcions i la freqüència de canvi:

Grup de botons <select>
Opcions visibles sense interactuar Totes Només l'escollida
Clics per canviar de filtre 1 2 (obrir i triar)
Descobriment del que es pot filtrar Immediat Cal obrir-lo
Espai ocupat Més Menys
Recomanat a partir de ≤ 5-6 opcions > 6-7 opcions

Amb quatre opcions i sent el control més utilitzat de la pantalla més visitada, el desplegable empitjora la tasca principal per estalviar un espai que en escriptori sobra.

Accessibilitat. Aquí no hi ha un guanyador clar, i convé ser honest: un <select> natiu amb la seva <label> és perfectament accessible i en mòbil obre el selector del sistema, que és una experiència excel·lent. El grup de botons també ho és si porta role="group", aria-label i aria-pressed, com el de l'apartat 10. La diferència real és en un altre lloc: el desplegable anuncia només l'opció actual, mentre que els botons anuncien l'estat de totes, la qual cosa ajuda a construir el model mental de quins filtres existeixen.

El que farà 11-03 amb el filtre. Aquest és l'argument que decideix. El filtre es trasllada a la URL (?tipo=electrica) per ser compartible. Amb botons, cada opció pot a més renderitzar-se com un enllaç real si algun dia interessa —amb la seva URL a la barra d'estat i el seu «obrir en pestanya nova»—; amb un <select> això és impossible sense JavaScript. El grup de botons encaixa millor amb la decisió A6 de l'acta.

On sí que ho acceptaria, i el codi. Al formulari de nova reserva i en qualsevol pantalla estreta amb més de sis opcions. I al mateix catàleg, si algun dia els tipus passen de quatre a dotze, la solució no és el desplegable sinó un component que s'adapti:

// src/components/SelectorTipus.jsx — variant adaptable
function SelectorTipus({ tipusEscollit, alCanviarTipus, variant = 'auto' }) {
  const [pendent, iniciarTransicio] = useTransition();

  const canviar = (valor) => iniciarTransicio(() => alCanviarTipus(valor));

  if (variant === 'desplegable' || TIPUS.length > 6) {
    return (
      <Camp etiqueta="Tipus de bicicleta">
        {(props) => (
          <select {...props} value={tipusEscollit} onChange={(e) => canviar(e.target.value)}>
            {TIPUS.map((tipus) => (
              <option key={tipus.valor} value={tipus.valor}>{tipus.text}</option>
            ))}
          </select>
        )}
      </Camp>
    );
  }

  return (
    <div className={classes(estils.grup, pendent && estils.pendent)}
         role="group" aria-label="Filtrar per tipus de bicicleta">
      {TIPUS.map((tipus) => (
        <button key={tipus.valor} type="button"
                className={classes(estils.boto, tipusEscollit === tipus.valor && estils.actiu)}
                aria-pressed={tipusEscollit === tipus.valor}
                onClick={() => canviar(tipus.valor)}>
          {tipus.text}
        </button>
      ))}
    </div>
  );
}

La condició que fa vàlida aquesta variant és que les dues formes comparteixen exactament el mateix contracte de props (tipusEscollit i alCanviarTipus). Qui l'utilitza no s'assabenta de quina es pinta, i les proves d'11-04 poden interactuar amb la que correspongui sense que la pàgina canviï ni una línia. Si la variant obligués a canviar el contracte, serien dos components diferents i caldria triar-ne un.

Conclusió

CicloUrbano ja existeix. Es pot obrir, recórrer, ensenyar i criticar, i encara no ha fet ni una petició: exactament el punt que es buscava.

El primer que queda muntat és el sistema de disseny, i és el que sosté tota la resta: un index.css amb reinici, una escala d'espaiat tancada, variables de color i forma, :focus-visible definit una sola vegada perquè cap part de l'aplicació pugui quedar-se sense indicador de focus, i prefers-reduced-motion respectat des del primer dia. El tema fosc es resol redefinint variables sota :root[data-tema='fosc'], sense tocar una regla de component, amb el recordatori que un tema fosc no és un tema clar invertit: els colors de marca cal tornar a mesurar-los sobre el fons nou.

A sobre hi viuen els cinc components base, el valor dels quals és que existeixin i s'utilitzin. Boto inverteix el valor per defecte de type per eliminar d'arrel els enviaments accidentals i uneix l'estat visual al funcional a carregant. Camp utilitza fills com a funció per generar l'id amb useId i cablejar htmlFor, aria-invalid i aria-describedby en un sol lloc, de manera que escriure un camp accessible sigui més fàcil que escriure'n un que no ho sigui. Panell rep el nivell d'encapçalament com a prop, perquè un component no pot saber a quina profunditat el col·locaran. Etiqueta anomena l'estat a més de colorir-lo. I Modal compleix les quatre obligacions d'un diàleg: portal, aria-modal, tancament amb Escape i devolució del focus a l'origen.

El marc aporta l'enllaç de salt com a primer element enfocable, els punts de referència ben separats, el main enfocable per programa, la navegació amb NavLink i el seu end al catàleg, l'estat actiu senyalat amb alguna cosa més que color, i unes molles de pa que llegeixen el handle de cada ruta i marquen l'actual amb aria-current.

De les vuit pantalles, el que cal endur-se no és el marcatge sinó el mètode: cadascuna s'ha escrit amb els seus quatre estats —carregant amb EsqueletPagina marcat com a aria-hidden i el seu role="status" paral·lel, buit amb explicació i sortida, error amb missatge comprensible i reintent, i amb dades—; les taules són taules de debò amb scope i caption; els botons deshabilitats diuen per què ho estan; les pestanyes d'estació són rutes imbricades i no estat local, perquè siguin enllaçables i sobrevisquin al botó enrere; i l'ordre del contingut respon al que cada persona usuària necessita veure primer, que és una decisió presa a 11-01 i cobrada aquí.

En adaptabilitat, la regla queda fixada: les media queries van al CSS perquè s'apliquen al primer pintat, no costen res i funcionen encara que el JavaScript trigui; useAmpladaFinestra es reserva per a quan cal renderitzar estructures diferents, no estils diferents. I grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)) substitueix tres punts de ruptura amb una línia.

Tanca la lliçó la llista de comprovació d'accessibilitat de vuit punts que s'aplica pantalla a pantalla, i el recorregut de revisió de setze parades que es fa abans de connectar res: és el moment més barat de tot el projecte per canviar d'opinió.

Ara l'aplicació és una maqueta preciosa que no fa res. Les dades són un fitxer, els filtres només viuen en memòria, el formulari no envia, la sessió no existeix i RutaProtegida deixa passar tothom. Estat i Integració d'API li dona vida: la capa d'accés a dades que no sap res de React, TanStack Query amb les seves claus jeràrquiques i els seus valors per defecte justificats, la mutació completa de crear una reserva amb validació, invalidació i redirecció, l'actualització optimista de confirmar i cancel·lar amb la seva reversió, Redux per a sessió i catàleg, el filtre traslladant-se a la URL, les rutes protegides connectades de debò i una taula de decisió que respon a la pregunta més difícil de totes: quan alguna cosa falla, qui ho mostra.

Curs de React

Mòdul 1: Introducció a React

Mòdul 2: Components de React

Mòdul 3: Treballar amb Esdeveniments

Mòdul 4: Conceptes Avançats de Components

Mòdul 5: Hooks de React

Mòdul 6: Enrutament a React

Mòdul 7: Gestió de l'Estat

Mòdul 8: Optimització del Rendiment

Mòdul 9: Proves a React

Mòdul 10: Temes Avançats

Mòdul 11: Projecte: Construir una Aplicació Completa

© Copyright 2026. Tots els drets reservats