01 Prerrequisitos
Descarga e instala Node.js LTS desde: https://nodejs.org
Verifica en la terminal que tengas las versiones correctas:
node --version npm --version
Instala el CLI de Angular globalmente:
npm install -g @angular/cli ng version
Angular Language Service para autocompletado inteligente de templates, detección de errores en tiempo real y navegación por el código.02 Crear el proyecto Angular
2.1 — Crear el proyecto
ng new pokedex-angular --standalone --style=css --routing=false --skip-git
Confirma las opciones cuando el CLI lo solicite:
- Would you like to add Angular routing? → No
- Which stylesheet format? → CSS
2.2 — Entrar al proyecto
cd pokedex-angular
2.3 — Verificar que funciona
ng serve
Abre http://localhost:4200 en tu navegador. Deberías ver la página de bienvenida de Angular.
2.4 — Instalar dependencias extra
npm install @angular/animations
Verifica que package.json incluya las dependencias de Angular 21, RxJS y TypeScript 5.9+.
03 Configurar Tailwind CSS
3.1 — Instalar Tailwind y PostCSS
npm install -D tailwindcss postcss autoprefixer
3.2 — Crear archivo de configuración
npx tailwindcss init
Reemplaza el contenido de tailwind.config.js:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{html,ts}"],
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
animation: {
'heart-beat': 'heartBeat 1.2s ease-in-out infinite',
},
keyframes: {
heartBeat: {
'0%, 100%': { transform: 'scale(1)' },
'50%': { transform: 'scale(1.2)' },
},
},
},
},
plugins: [],
}
3.3 — Crear archivo PostCSS
Crea postcss.config.js en la raíz del proyecto:
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
3.4 — Importar Google Fonts
En src/index.html, dentro del <head>:
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
3.5 — Configurar estilos globales
En src/styles.css:
@tailwind base;
@tailwind components;
@tailwind utilities;
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', system-ui, sans-serif;
background: radial-gradient(ellipse at center,
#2d1b4e 0%, #1a1a2e 50%, #0f0c29 100%);
min-height: 100vh;
overflow-x: hidden;
}
.glass {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.skeleton {
background: linear-gradient(90deg,
rgba(255,255,255,0.05) 25%,
rgba(255,255,255,0.15) 50%,
rgba(255,255,255,0.05) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: rgba(255,255,255,0.05); }
::-webkit-scrollbar-thumb {
background: rgba(147, 51, 234, 0.5);
border-radius: 4px;
}
04 Planificación de la estructura
├── app.component.ts ← Controlador principal
├── app.component.html ← Interfaz principal
├── app.component.css ← (vacío, usamos Tailwind)
├── app.config.ts ← Proveedores globales
├── models/
│ └── pokemon.model.ts ← Interfaces / Types
└── services/
└── pokemon.service.ts ← Lógica de datos y API
NgModule. Los componentes se declaran con standalone: true e importan directamente los módulos que necesitan (CommonModule, FormsModule, etc.).05 Crear los modelos / interfaces
Crea el archivo src/app/models/pokemon.model.ts:
export interface Pokemon {
id: number;
name: string;
sprites: {
front_default: string;
other: { 'official-artwork': { front_default: string } };
};
types: PokemonType[];
height: number;
weight: number;
abilities: Ability[];
stats: Stat[];
}
export interface PokemonType {
slot: number;
type: { name: string; url: string };
}
export interface Ability {
ability: { name: string; url: string };
is_hidden: boolean;
slot: number;
}
export interface Stat {
base_stat: number;
stat: { name: string; url: string };
}
export interface PokemonListItem { name: string; url: string; }
export interface PokemonListResponse { count: number; results: PokemonListItem[]; }
export interface PokemonSpecies {
id: number;
name: string;
evolution_chain: { url: string };
habitat: { name: string } | null;
flavor_text_entries: FlavorTextEntry[];
generation: { name: string };
}
export interface FlavorTextEntry {
flavor_text: string;
language: { name: string };
}
export interface EvolutionChain { id: number; chain: EvolutionNode; }
export interface EvolutionNode {
species: { name: string; url: string };
evolution_details: EvolutionDetail[];
evolves_to: EvolutionNode[];
}
export interface EvolutionChainDisplay {
name: string;
id: number;
sprites: { front_default: string };
types: PokemonType[];
}
export interface EvolutionChainParsed {
id: number;
chain: EvolutionChainDisplay[];
}
pokemon. y recibes errores si accedes a una propiedad que no existe.06 Crear el servicio de Pokémon
Crea src/app/services/pokemon.service.ts. Este servicio centraliza toda la lógica de datos:
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, map, switchMap } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class PokemonService {
private readonly http = inject(HttpClient);
private readonly BASE_URL = 'https://pokeapi.co/api/v2';
private readonly FAVORITES_KEY = 'pokedex_favorites';
// ── PETICIONES A LA API ──────────────────────────
getPokemonList(offset = 0, limit = 151): Observable<PokemonListItem[]> {
const params = new HttpParams()
.set('offset', offset.toString())
.set('limit', limit.toString());
return this.http.get<PokemonListResponse>(`${this.BASE_URL}/pokemon`, { params })
.pipe(map(r => r.results));
}
getPokemon(nameOrId: string | number): Observable<Pokemon> {
return this.http.get<Pokemon>(`${this.BASE_URL}/pokemon/${nameOrId}`);
}
getPokemonSpecies(nameOrId: string | number): Observable<PokemonSpecies> {
return this.http.get<PokemonSpecies>(`${this.BASE_URL}/pokemon-species/${nameOrId}`);
}
getEvolutionChain(url: string): Observable<EvolutionChain> {
return this.http.get<EvolutionChain>(url);
}
// ── DATOS COMBINADOS ─────────────────────────────
getPokemonWithEvolutionChain(nameOrId: string | number) {
return this.getPokemon(nameOrId).pipe(
switchMap(pokemon =>
this.getPokemonSpecies(nameOrId).pipe(
switchMap(species =>
this.getEvolutionChain(species.evolution_chain.url).pipe(
map(chain => ({
pokemon,
evolutionChain: this.parseEvolutionChain(chain)
}))
)
)
)
)
);
}
// ── FAVORITOS (localStorage) ──────────────────────
getFavorites(): number[] {
return JSON.parse(localStorage.getItem(this.FAVORITES_KEY) || '[]');
}
toggleFavorite(id: number): boolean {
const favs = this.getFavorites();
if (favs.includes(id)) {
localStorage.setItem(this.FAVORITES_KEY,
JSON.stringify(favs.filter(f => f !== id)));
return false;
}
favs.push(id);
localStorage.setItem(this.FAVORITES_KEY, JSON.stringify(favs));
return true;
}
// ── UTILIDADES ────────────────────────────────────
getPokemonImage(id: number): string {
return `https://raw.githubusercontent.com/PokeAPI/sprites/master/` +
`sprites/pokemon/other/official-artwork/${id}.png`;
}
getTypeColor(type: string): string {
const colors: Record<string, string> = {
fire: '#f97316', water: '#3b82f6', grass: '#22c55e',
electric: '#eab308', psychic: '#ec4899', ice: '#67e8f9',
dragon: '#8b5cf6', dark: '#374151', normal: '#9ca3af',
poison: '#a855f7', ground: '#d97706', flying: '#60a5fa',
bug: '#84cc16', rock: '#78716c', ghost: '#6d28d9',
steel: '#6b7280', fighting: '#dc2626', fairy: '#f472b6',
};
return colors[type] ?? '#6b7280';
}
}
Configurar los proveedores en app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
provideAnimations()
]
};
07 Diseñar el layout principal
main.ts — punto de entrada
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [...appConfig.providers]
}).catch(err => console.error(err));
Estructura de app.component.html
El template principal tiene cinco grandes bloques:
- Fondo decorativo — círculos con
blury gradientes usando Tailwind - Header glassmorphism — logo Pokéball, buscador con debounce, botón "Favoritos"
- Grid de Pokémon — componente
<app-pokemon-grid> - Modal de detalles — componente
<app-pokemon-modal> - Footer — créditos de PokeAPI
backdrop-filter: blur() + fondo semitransparente + borde blanco tenue. Requiere que haya contenido visual detrás del elemento.08 Mostrar la lista de Pokémon en cards
Genera el componente con el CLI:
ng generate component pokemon-grid
El componente recibe datos del padre mediante @Input() y emite eventos con @Output():
@Input() pokemonList: Pokemon[] = []; @Input() loading = false; @Input() favorites = new Set<number>(); @Output() selectPokemon = new EventEmitter<Pokemon>(); @Output() toggleFavorite = new EventEmitter<number>();
Cada card muestra:
- Imagen del sprite oficial (high-res)
- Nombre y número formateado (
#001) - Tipos con sus colores característicos
- Altura y peso
- Lista de habilidades
- Botón de favorito (❤️ / 🤍) animado
- Skeleton loading mientras los datos llegan
shimmer mientras se esperan los datos. Mejora la UX porque el usuario ve estructura antes del contenido real.09 Implementar la barra de búsqueda
Usa un BehaviorSubject con operadores RxJS para evitar llamadas excesivas mientras el usuario escribe:
private searchSubject = new BehaviorSubject<string>('');
private destroy$ = new Subject<void>();
setupSearch(): void {
this.searchSubject.pipe(
debounceTime(500), // espera 500ms tras el último keystroke
distinctUntilChanged(), // solo emite si el valor cambió
takeUntil(this.destroy$) // limpieza al destruir el componente
).subscribe(() => this.applyFilter());
}
applyFilter(): void {
const q = this.searchValue.toLowerCase().trim();
this.filteredPokemon = this.pokemonList.filter(p => {
const matchFav = this.searchMode === 'favorites'
? this.favorites.has(p.id) : true;
const matchSearch = q ? p.name.includes(q) : true;
return matchFav && matchSearch;
});
}
10 Agregar favoritos con localStorage
La lógica vive en el servicio (sección 6). En el componente principal cárgalos como un Set para búsquedas O(1):
// Al inicializar
this.favorites = new Set(this.pokemonService.getFavorites());
// Al hacer toggle desde el grid
onToggleFavorite(id: number): void {
const isNowFavorite = this.pokemonService.toggleFavorite(id);
if (isNowFavorite) {
this.favorites.add(id);
} else {
this.favorites.delete(id);
}
// forzar detección de cambios si usas OnPush
this.favorites = new Set(this.favorites);
}
Set es O(1), mientras que en un Array sería O(n). Con 151 Pokémon no hay diferencia notable, pero es buena práctica.11 Crear el modal de detalles
ng generate component pokemon-modal
Inputs que recibe:
pokemon: Pokemon— datos completos del Pokémon seleccionadoevolutionChain: EvolutionChainParsed | null— cadena de evoluciónloadingEvolutions: boolean— estado de carga de evoluciones
Outputs que emite:
closeModal— cierra el modalgoToPokemon(id: number)— carga otro Pokémon desde evoluciones
Contenido del modal:
- Imagen grande con gradiente del tipo principal
- Altura, peso, tipos, habilidades
- Barras de estadísticas con colores por stat y animación
- Cadena de evoluciones con flechas
(click)="closeModal.emit()" en el backdrop y (click)="$event.stopPropagation()" en el contenido.12 Mostrar la cadena de evoluciones
La API devuelve un árbol recursivo. Usa una función recursiva para aplanarlo en un array lineal:
parseEvolutionChain(chain: EvolutionChain): EvolutionChainParsed {
const parseNode = (node: EvolutionNode): EvolutionChainDisplay[] => {
const idStr = node.species.url.split('/').filter(Boolean).pop() ?? '0';
const id = parseInt(idStr, 10);
const current: EvolutionChainDisplay = {
name: node.species.name,
id,
sprites: { front_default: this.getPokemonImage(id) },
types: []
};
const rest = node.evolves_to.flatMap(child => parseNode(child));
return [current, ...rest];
};
return { id: chain.id, chain: parseNode(chain.chain) };
}
En el template, muestra cada eslabón con flechas entre ellos:
<div *ngFor="let evo of evolutionChain.chain; let last = last"> <app-evolution-card [pokemon]="evo" (click)="goToPokemon.emit(evo.id)"></app-evolution-card> <span *ngIf="!last" class="arrow">→</span> </div>
13 Pulir estilos y animaciones
Glassmorphism
.glass {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
Hover en cards (Tailwind)
<div class="transition-all duration-300
hover:-translate-y-2 hover:shadow-2xl
cursor-pointer">
Barras de estadísticas animadas
/* En styles.css */
@keyframes slideIn {
from { width: 0%; }
to { width: var(--stat-width); }
}
.stat-bar {
animation: slideIn 0.8s ease-out forwards;
}
<!-- En el template -->
<div class="stat-bar rounded-full h-2"
[style.--stat-width]="(stat.base_stat / 255 * 100) + '%'"
[style.background]="getStatColor(stat.stat.name)">
</div>
Fuente + scrollbar
/* Importar en index.html */
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet">
/* Scrollbar personalizado */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-thumb {
background: rgba(147, 51, 234, 0.5);
border-radius: 4px;
}
14 Probar y compilar
Modo desarrollo
ng serve
Verificar que compila sin errores
ng build
Build de producción optimizado
ng build --configuration production
Los archivos de producción estarán en dist/pokedex-angular/, listos para subir a cualquier hosting estático (Netlify, Vercel, GitHub Pages, etc.).
Tests unitarios (opcional)
ng test
15 Errores comunes y soluciones
| Error | Causa | Solución |
|---|---|---|
Can't bind to 'X' |
Componente hijo no importado | Agrega el componente al array imports del componente padre |
NullInjectorError: No provider for HttpClient |
Falta el proveedor | Agrega provideHttpClient() en app.config.ts |
Property 'X' does not exist on type 'HTMLElement' |
TypeScript estricto | Usa $any($event.target) o un cast explícito |
| Las imágenes no cargan | URL incorrecta | Verifica que getPokemonImage() recibe un ID numérico válido |
combineLatest no emite |
Un observable nunca emite | Asegúrate de que todos los observables del array emitan al menos un valor |
| Tailwind no aplica estilos | Contenido mal configurado | Revisa que content en tailwind.config.js incluye ./src/**/*.{html,ts} |
16 Resumen del flujo de la app
main.ts llama a bootstrapApplication(AppComponent)loadFavorites() desde localStorage · loadPokemonList() → API · setupSearch() → BehaviorSubjectforkJoin() o peticiones individuales por cada nombre de la listadebounceTime(500) → applyFilter() sobre el array local (sin red)toggleFavorite(id) → localStorage · actualiza el Set de favoritos en memoria