Salta el contingut

Pràctica 3: Frontend de Reserves de Vols amb Angular

Objectius de la pràctica

En aquesta pràctica aprendràs a: - Crear una aplicació Angular completa que consumeix una API REST - Implementar autenticació amb JWT (JSON Web Tokens) - Gestionar l'estat de l'usuari autenticat - Crear formularis reactius per al registre i login - Implementar guards per protegir rutes - Crear interceptors HTTP per afegir tokens a les peticions - Dissenyar una interfície d'usuari per cercar vols i gestionar reserves


Prerequisits

  • Haver completat la Pràctica 1 i Pràctica 2
  • Tenir instal·lat Node.js, npm i Angular CLI
  • Tenir IntelliJ IDEA configurat per Angular
  • Tenir el servidor de reserves de vols funcionant (carpeta servidor-reserves-vols)

Part 1: Preparació de l'entorn

1.1 Iniciar el servidor de reserves

Abans de començar amb el frontend, necessitem tenir el servidor funcionant:

cd servidor-reserves-vols
npm install
npm start

El servidor s'executarà a http://localhost:3000. Pots accedir a la documentació Swagger a http://localhost:3000/api-docs.

1.2 Crear el projecte Angular

ng new reserves-vols-frontend

Nota Angular 20: El projecte es crea automàticament amb routing i CSS per defecte. Si vols altres opcions:

# Exemple amb SCSS
ng new reserves-vols-frontend --style=scss

cd reserves-vols-frontend

Part 2: Estructura del projecte

2.1 Crear l'estructura de carpetes

Organitzarem el projecte amb la següent estructura:

mkdir -p src/app/models
mkdir -p src/app/services
mkdir -p src/app/components/auth
mkdir -p src/app/components/flights
mkdir -p src/app/components/bookings
mkdir -p src/app/components/shared
mkdir -p src/app/guards
mkdir -p src/app/interceptors

2.2 Crear els models

Model d'Usuari (src/app/models/user.model.ts)

export interface User {
  id: number;
  name: string;
  email: string;
}

export interface LoginRequest {
  email: string;
  password: string;
}

export interface RegisterRequest {
  name: string;
  email: string;
  password: string;
}

export interface AuthResponse {
  message: string;
  token: string;
  user: User;
}

Model de Vol (src/app/models/flight.model.ts)

export interface Flight {
  id: number;
  flightNumber: string;
  origin: string;
  destination: string;
  departureDate: string;
  departureTime: string;
  arrivalTime: string;
  price: number;
  availableSeats: number;
  airline: string;
}

Model de Reserva (src/app/models/booking.model.ts)

import { Flight } from './flight.model';

export interface Booking {
  id: number;
  userId: number;
  flightId: number;
  flight?: Flight;
  passengers: number;
  totalPrice: number;
  status: 'confirmed' | 'cancelled' | 'pending';
  createdAt: string;
}

export interface CreateBookingRequest {
  flightId: number;
  passengers: number;
}

Índex de models (src/app/models/index.ts)

export * from './user.model';
export * from './flight.model';
export * from './booking.model';

Part 3: Configurar HttpClient

3.1 Configurar el proveïdor HTTP

Edita src/app/app.config.ts:

import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';

import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes),
    provideHttpClient()
  ]
};

Part 4: Crear el servei d'autenticació

4.1 Servei d'autenticació (src/app/services/auth.service.ts)

import { Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { BehaviorSubject, Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { User, LoginRequest, RegisterRequest, AuthResponse } from '../models';

@Injectable({
  providedIn: 'root'
})
export class AuthService {

  private apiUrl = 'http://localhost:3000/api/auth';
  private currentUserSubject = new BehaviorSubject<User | null>(null);
  private tokenKey = 'auth_token';

  currentUser$ = this.currentUserSubject.asObservable();

  constructor(private http: HttpClient) {
    this.loadUserFromStorage();
  }

  private loadUserFromStorage(): void {
    const token = localStorage.getItem(this.tokenKey);
    const user = localStorage.getItem('current_user');
    if (token && user) {
      this.currentUserSubject.next(JSON.parse(user));
    }
  }

  register(data: RegisterRequest): Observable<AuthResponse> {
    return this.http.post<AuthResponse>(`${this.apiUrl}/register`, data)
      .pipe(
        tap(response => this.handleAuthSuccess(response)),
        catchError(this.handleError)
      );
  }

  login(data: LoginRequest): Observable<AuthResponse> {
    return this.http.post<AuthResponse>(`${this.apiUrl}/login`, data)
      .pipe(
        tap(response => this.handleAuthSuccess(response)),
        catchError(this.handleError)
      );
  }

  logout(): void {
    localStorage.removeItem(this.tokenKey);
    localStorage.removeItem('current_user');
    this.currentUserSubject.next(null);
  }

  getToken(): string | null {
    return localStorage.getItem(this.tokenKey);
  }

  isAuthenticated(): boolean {
    return !!this.getToken();
  }

  getCurrentUser(): User | null {
    return this.currentUserSubject.value;
  }

  getProfile(): Observable<User> {
    return this.http.get<User>(`${this.apiUrl}/profile`)
      .pipe(catchError(this.handleError));
  }

  private handleAuthSuccess(response: AuthResponse): void {
    localStorage.setItem(this.tokenKey, response.token);
    localStorage.setItem('current_user', JSON.stringify(response.user));
    this.currentUserSubject.next(response.user);
  }

  private handleError(error: HttpErrorResponse) {
    let errorMessage = 'Error desconegut';
    if (error.error && error.error.error) {
      errorMessage = error.error.error;
    } else if (error.status === 0) {
      errorMessage = 'No es pot connectar amb el servidor';
    }
    return throwError(() => new Error(errorMessage));
  }
}

Part 5: Crear l'interceptor d'autenticació

5.1 Interceptor HTTP (src/app/interceptors/auth.interceptor.ts)

import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService);
  const token = authService.getToken();

  if (token) {
    const clonedRequest = req.clone({
      setHeaders: {
        Authorization: `Bearer ${token}`
      }
    });
    return next(clonedRequest);
  }

  return next(req);
};

5.2 Afegir l'interceptor a la configuració

Actualitza src/app/app.config.ts:

import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';

import { routes } from './app.routes';
import { authInterceptor } from './interceptors/auth.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes),
    provideHttpClient(withInterceptors([authInterceptor]))
  ]
};

Part 6: Crear el guard d'autenticació

6.1 Guard de rutes protegides (src/app/guards/auth.guard.ts)

import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { AuthService } from '../services/auth.service';

export const authGuard: CanActivateFn = (route, state) => {
  const authService = inject(AuthService);
  const router = inject(Router);

  if (authService.isAuthenticated()) {
    return true;
  }

  router.navigate(['/login']);
  return false;
};

Part 7: Crear el servei de vols

7.1 Servei de vols (src/app/services/flights.service.ts)

import { Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { Flight } from '../models';

@Injectable({
  providedIn: 'root'
})
export class FlightsService {

  private apiUrl = 'http://localhost:3000/api/flights';

  constructor(private http: HttpClient) { }

  getFlights(filters?: { origin?: string; destination?: string; date?: string }): Observable<Flight[]> {
    let params = new HttpParams();

    if (filters) {
      if (filters.origin) params = params.set('origin', filters.origin);
      if (filters.destination) params = params.set('destination', filters.destination);
      if (filters.date) params = params.set('date', filters.date);
    }

    return this.http.get<Flight[]>(this.apiUrl, { params })
      .pipe(catchError(this.handleError));
  }

  getFlight(id: number): Observable<Flight> {
    return this.http.get<Flight>(`${this.apiUrl}/${id}`)
      .pipe(catchError(this.handleError));
  }

  getOrigins(): Observable<string[]> {
    return this.http.get<string[]>(`${this.apiUrl}/search/origins`)
      .pipe(catchError(this.handleError));
  }

  getDestinations(): Observable<string[]> {
    return this.http.get<string[]>(`${this.apiUrl}/search/destinations`)
      .pipe(catchError(this.handleError));
  }

  private handleError(error: HttpErrorResponse) {
    let errorMessage = 'Error al carregar els vols';
    if (error.error && error.error.error) {
      errorMessage = error.error.error;
    }
    return throwError(() => new Error(errorMessage));
  }
}

Part 8: Crear el servei de reserves

8.1 Servei de reserves (src/app/services/bookings.service.ts)

import { Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { Booking, CreateBookingRequest } from '../models';

@Injectable({
  providedIn: 'root'
})
export class BookingsService {

  private apiUrl = 'http://localhost:3000/api/bookings';

  constructor(private http: HttpClient) { }

  getBookings(): Observable<Booking[]> {
    return this.http.get<Booking[]>(this.apiUrl)
      .pipe(catchError(this.handleError));
  }

  getBooking(id: number): Observable<Booking> {
    return this.http.get<Booking>(`${this.apiUrl}/${id}`)
      .pipe(catchError(this.handleError));
  }

  createBooking(data: CreateBookingRequest): Observable<{ message: string; booking: Booking }> {
    return this.http.post<{ message: string; booking: Booking }>(this.apiUrl, data)
      .pipe(catchError(this.handleError));
  }

  updateBooking(id: number, passengers: number): Observable<{ message: string; booking: Booking }> {
    return this.http.put<{ message: string; booking: Booking }>(`${this.apiUrl}/${id}`, { passengers })
      .pipe(catchError(this.handleError));
  }

  cancelBooking(id: number): Observable<{ message: string; booking: Booking }> {
    return this.http.delete<{ message: string; booking: Booking }>(`${this.apiUrl}/${id}`)
      .pipe(catchError(this.handleError));
  }

  private handleError(error: HttpErrorResponse) {
    let errorMessage = 'Error en la operació';
    if (error.error && error.error.error) {
      errorMessage = error.error.error;
    }
    return throwError(() => new Error(errorMessage));
  }
}

Part 9: Components d'autenticació

9.1 Component de Login

Genera el component:

ng generate component components/auth/login

Nota Angular 20: El component es genera amb noms de fitxers simplificats: login.ts, login.html, login.css.

login.ts

import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service';

@Component({
  selector: 'app-login',
  imports: [FormsModule, RouterLink],
  templateUrl: './login.html',
  styleUrl: './login.css'
})
export class Login {
  email = '';
  password = '';
  error = signal<string | null>(null);
  loading = signal(false);

  constructor(
    private authService: AuthService,
    private router: Router
  ) {}

  onSubmit(): void {
    if (!this.email || !this.password) {
      this.error.set('Tots els camps són obligatoris');
      return;
    }

    this.loading.set(true);
    this.error.set(null);

    this.authService.login({ email: this.email, password: this.password })
      .subscribe({
        next: () => {
          this.router.navigate(['/flights']);
        },
        error: (err) => {
          this.error.set(err.message);
          this.loading.set(false);
        }
      });
  }
}

login.html

<div class="auth-container">
  <div class="auth-card">
    <h2>Iniciar Sessió</h2>

    @if (error()) {
      <div class="error-message">
        {{ error() }}
      </div>
    }

    <form (ngSubmit)="onSubmit()">
      <div class="form-group">
        <label for="email">Email</label>
        <input 
          type="email" 
          id="email" 
          [(ngModel)]="email" 
          name="email"
          placeholder="exemple@correu.com"
          required
        >
      </div>

      <div class="form-group">
        <label for="password">Contrasenya</label>
        <input 
          type="password" 
          id="password" 
          [(ngModel)]="password" 
          name="password"
          placeholder="La teva contrasenya"
          required
        >
      </div>

      <button type="submit" [disabled]="loading()" class="btn-primary">
        {{ loading() ? 'Carregant...' : 'Entrar' }}
      </button>
    </form>

    <p class="auth-link">
      No tens compte? <a routerLink="/register">Registra't</a>
    </p>
  </div>
</div>

login.css

.auth-container {
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 80vh;
  padding: 20px;
}

.auth-card {
  background: white;
  padding: 40px;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0,0,0,0.1);
  width: 100%;
  max-width: 400px;
}

.auth-card h2 {
  text-align: center;
  color: #333;
  margin-bottom: 30px;
}

.form-group {
  margin-bottom: 20px;
}

.form-group label {
  display: block;
  margin-bottom: 8px;
  color: #555;
  font-weight: 500;
}

.form-group input {
  width: 100%;
  padding: 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 16px;
  box-sizing: border-box;
}

.form-group input:focus {
  border-color: #3f51b5;
  outline: none;
}

.btn-primary {
  width: 100%;
  padding: 14px;
  background: #3f51b5;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 16px;
  cursor: pointer;
  transition: background 0.3s;
}

.btn-primary:hover {
  background: #303f9f;
}

.btn-primary:disabled {
  background: #9fa8da;
  cursor: not-allowed;
}

.error-message {
  background: #ffebee;
  color: #c62828;
  padding: 12px;
  border-radius: 4px;
  margin-bottom: 20px;
  text-align: center;
}

.auth-link {
  text-align: center;
  margin-top: 20px;
  color: #666;
}

.auth-link a {
  color: #3f51b5;
  text-decoration: none;
}

.auth-link a:hover {
  text-decoration: underline;
}

9.2 Component de Registre

Genera el component:

ng generate component components/auth/register

register.component.ts

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service';

@Component({
  selector: 'app-register',
  standalone: true,
  imports: [CommonModule, FormsModule, RouterLink],
  templateUrl: './register.component.html',
  styleUrls: ['./register.component.css']
})
export class RegisterComponent {
  name = '';
  email = '';
  password = '';
  confirmPassword = '';
  error: string | null = null;
  loading = false;

  constructor(
    private authService: AuthService,
    private router: Router
  ) {}

  onSubmit(): void {
    if (!this.name || !this.email || !this.password) {
      this.error = 'Tots els camps són obligatoris';
      return;
    }

    if (this.password !== this.confirmPassword) {
      this.error = 'Les contrasenyes no coincideixen';
      return;
    }

    if (this.password.length < 6) {
      this.error = 'La contrasenya ha de tenir mínim 6 caràcters';
      return;
    }

    this.loading = true;
    this.error = null;

    this.authService.register({ 
      name: this.name, 
      email: this.email, 
      password: this.password 
    }).subscribe({
      next: () => {
        this.router.navigate(['/flights']);
      },
      error: (err) => {
        this.error = err.message;
        this.loading = false;
      }
    });
  }
}

register.component.html

<div class="auth-container">
  <div class="auth-card">
    <h2>Crear Compte</h2>

    <div *ngIf="error" class="error-message">
      {{ error }}
    </div>

    <form (ngSubmit)="onSubmit()">
      <div class="form-group">
        <label for="name">Nom complet</label>
        <input 
          type="text" 
          id="name" 
          [(ngModel)]="name" 
          name="name"
          placeholder="El teu nom"
          required
        >
      </div>

      <div class="form-group">
        <label for="email">Email</label>
        <input 
          type="email" 
          id="email" 
          [(ngModel)]="email" 
          name="email"
          placeholder="exemple@correu.com"
          required
        >
      </div>

      <div class="form-group">
        <label for="password">Contrasenya</label>
        <input 
          type="password" 
          id="password" 
          [(ngModel)]="password" 
          name="password"
          placeholder="Mínim 6 caràcters"
          required
        >
      </div>

      <div class="form-group">
        <label for="confirmPassword">Confirmar contrasenya</label>
        <input 
          type="password" 
          id="confirmPassword" 
          [(ngModel)]="confirmPassword" 
          name="confirmPassword"
          placeholder="Repeteix la contrasenya"
          required
        >
      </div>

      <button type="submit" [disabled]="loading" class="btn-primary">
        {{ loading ? 'Carregant...' : 'Registrar-se' }}
      </button>
    </form>

    <p class="auth-link">
      Ja tens compte? <a routerLink="/login">Inicia sessió</a>
    </p>
  </div>
</div>

Part 10: Component de llista de vols

10.1 Genera el component

ng generate component components/flights/flight-list

flight-list.component.ts

import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { FlightsService } from '../../../services/flights.service';
import { AuthService } from '../../../services/auth.service';
import { Flight } from '../../../models';

@Component({
  selector: 'app-flight-list',
  standalone: true,
  imports: [CommonModule, FormsModule],
  templateUrl: './flight-list.component.html',
  styleUrls: ['./flight-list.component.css']
})
export class FlightListComponent implements OnInit {

  flights: Flight[] = [];
  origins: string[] = [];
  destinations: string[] = [];

  selectedOrigin = '';
  selectedDestination = '';
  selectedDate = '';

  loading = true;
  error: string | null = null;

  constructor(
    private flightsService: FlightsService,
    private authService: AuthService,
    private router: Router
  ) {}

  ngOnInit(): void {
    this.loadFlights();
    this.loadFilters();
  }

  loadFlights(): void {
    this.loading = true;
    this.error = null;

    const filters: any = {};
    if (this.selectedOrigin) filters.origin = this.selectedOrigin;
    if (this.selectedDestination) filters.destination = this.selectedDestination;
    if (this.selectedDate) filters.date = this.selectedDate;

    this.flightsService.getFlights(filters).subscribe({
      next: (data) => {
        this.flights = data;
        this.loading = false;
      },
      error: (err) => {
        this.error = err.message;
        this.loading = false;
      }
    });
  }

  loadFilters(): void {
    this.flightsService.getOrigins().subscribe({
      next: (data) => this.origins = data
    });
    this.flightsService.getDestinations().subscribe({
      next: (data) => this.destinations = data
    });
  }

  onSearch(): void {
    this.loadFlights();
  }

  clearFilters(): void {
    this.selectedOrigin = '';
    this.selectedDestination = '';
    this.selectedDate = '';
    this.loadFlights();
  }

  bookFlight(flight: Flight): void {
    if (!this.authService.isAuthenticated()) {
      this.router.navigate(['/login']);
      return;
    }
    this.router.navigate(['/book', flight.id]);
  }

  isAuthenticated(): boolean {
    return this.authService.isAuthenticated();
  }
}

flight-list.component.html

<div class="container">
  <h2>Cerca de Vols</h2>

  <!-- Filtres -->
  <div class="search-filters">
    <div class="filter-group">
      <label>Origen</label>
      <select [(ngModel)]="selectedOrigin">
        <option value="">Tots</option>
        <option *ngFor="let origin of origins" [value]="origin">{{ origin }}</option>
      </select>
    </div>

    <div class="filter-group">
      <label>Destinació</label>
      <select [(ngModel)]="selectedDestination">
        <option value="">Totes</option>
        <option *ngFor="let dest of destinations" [value]="dest">{{ dest }}</option>
      </select>
    </div>

    <div class="filter-group">
      <label>Data</label>
      <input type="date" [(ngModel)]="selectedDate">
    </div>

    <div class="filter-actions">
      <button (click)="onSearch()" class="btn-search">Cercar</button>
      <button (click)="clearFilters()" class="btn-clear">Netejar</button>
    </div>
  </div>

  <!-- Indicador de càrrega -->
  <div *ngIf="loading" class="loading">
    <p>Carregant vols...</p>
  </div>

  <!-- Missatge d'error -->
  <div *ngIf="error" class="error">
    <p>{{ error }}</p>
    <button (click)="loadFlights()">Tornar a provar</button>
  </div>

  <!-- Llista de vols -->
  <div *ngIf="!loading && !error" class="flights-grid">
    <div *ngIf="flights.length === 0" class="no-results">
      <p>No s'han trobat vols amb els criteris seleccionats.</p>
    </div>

    <div *ngFor="let flight of flights" class="flight-card">
      <div class="flight-header">
        <span class="flight-number">{{ flight.flightNumber }}</span>
        <span class="airline">{{ flight.airline }}</span>
      </div>

      <div class="flight-route">
        <div class="city">
          <span class="time">{{ flight.departureTime }}</span>
          <span class="name">{{ flight.origin }}</span>
        </div>
        <div class="arrow">✈️ →</div>
        <div class="city">
          <span class="time">{{ flight.arrivalTime }}</span>
          <span class="name">{{ flight.destination }}</span>
        </div>
      </div>

      <div class="flight-info">
        <span class="date">📅 {{ flight.departureDate }}</span>
        <span class="seats">🪑 {{ flight.availableSeats }} seients</span>
      </div>

      <div class="flight-footer">
        <span class="price">{{ flight.price | currency:'EUR' }}</span>
        <button 
          (click)="bookFlight(flight)" 
          [disabled]="flight.availableSeats === 0"
          class="btn-book"
        >
          {{ flight.availableSeats === 0 ? 'Sense places' : 'Reservar' }}
        </button>
      </div>
    </div>
  </div>
</div>

flight-list.component.css

.container {
  padding: 20px;
  max-width: 1200px;
  margin: 0 auto;
}

h2 {
  color: #3f51b5;
  text-align: center;
  margin-bottom: 30px;
}

.search-filters {
  background: white;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  display: flex;
  flex-wrap: wrap;
  gap: 20px;
  margin-bottom: 30px;
  align-items: flex-end;
}

.filter-group {
  flex: 1;
  min-width: 150px;
}

.filter-group label {
  display: block;
  margin-bottom: 8px;
  font-weight: 500;
  color: #555;
}

.filter-group select,
.filter-group input {
  width: 100%;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
}

.filter-actions {
  display: flex;
  gap: 10px;
}

.btn-search, .btn-clear {
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}

.btn-search {
  background: #3f51b5;
  color: white;
}

.btn-clear {
  background: #e0e0e0;
  color: #333;
}

.loading, .error, .no-results {
  text-align: center;
  padding: 40px;
}

.error {
  color: #d32f2f;
}

.flights-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
  gap: 20px;
}

.flight-card {
  background: white;
  border-radius: 8px;
  padding: 20px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  transition: transform 0.2s;
}

.flight-card:hover {
  transform: translateY(-5px);
}

.flight-header {
  display: flex;
  justify-content: space-between;
  margin-bottom: 15px;
}

.flight-number {
  font-weight: bold;
  color: #3f51b5;
}

.airline {
  color: #666;
  font-size: 14px;
}

.flight-route {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 15px;
  padding: 15px 0;
  border-top: 1px solid #eee;
  border-bottom: 1px solid #eee;
}

.city {
  text-align: center;
}

.city .time {
  display: block;
  font-size: 24px;
  font-weight: bold;
  color: #333;
}

.city .name {
  display: block;
  color: #666;
  font-size: 14px;
}

.arrow {
  font-size: 20px;
}

.flight-info {
  display: flex;
  justify-content: space-between;
  margin-bottom: 15px;
  color: #666;
  font-size: 14px;
}

.flight-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.price {
  font-size: 24px;
  font-weight: bold;
  color: #2e7d32;
}

.btn-book {
  padding: 12px 24px;
  background: #3f51b5;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}

.btn-book:hover {
  background: #303f9f;
}

.btn-book:disabled {
  background: #ccc;
  cursor: not-allowed;
}

Part 11: Component de creació de reserva

11.1 Genera el component

ng generate component components/bookings/create-booking

create-booking.component.ts

import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { FlightsService } from '../../../services/flights.service';
import { BookingsService } from '../../../services/bookings.service';
import { Flight } from '../../../models';

@Component({
  selector: 'app-create-booking',
  standalone: true,
  imports: [CommonModule, FormsModule],
  templateUrl: './create-booking.component.html',
  styleUrls: ['./create-booking.component.css']
})
export class CreateBookingComponent implements OnInit {

  flight: Flight | null = null;
  passengers = 1;
  loading = true;
  submitting = false;
  error: string | null = null;
  success: string | null = null;

  constructor(
    private route: ActivatedRoute,
    private router: Router,
    private flightsService: FlightsService,
    private bookingsService: BookingsService
  ) {}

  ngOnInit(): void {
    const flightId = Number(this.route.snapshot.paramMap.get('id'));
    this.loadFlight(flightId);
  }

  loadFlight(id: number): void {
    this.flightsService.getFlight(id).subscribe({
      next: (data) => {
        this.flight = data;
        this.loading = false;
      },
      error: (err) => {
        this.error = err.message;
        this.loading = false;
      }
    });
  }

  get totalPrice(): number {
    return this.flight ? this.flight.price * this.passengers : 0;
  }

  onSubmit(): void {
    if (!this.flight) return;

    this.submitting = true;
    this.error = null;

    this.bookingsService.createBooking({
      flightId: this.flight.id,
      passengers: this.passengers
    }).subscribe({
      next: (response) => {
        this.success = response.message;
        setTimeout(() => {
          this.router.navigate(['/bookings']);
        }, 2000);
      },
      error: (err) => {
        this.error = err.message;
        this.submitting = false;
      }
    });
  }
}

create-booking.component.html

<div class="container">
  <h2>Crear Reserva</h2>

  <div *ngIf="loading" class="loading">
    <p>Carregant informació del vol...</p>
  </div>

  <div *ngIf="error && !flight" class="error">
    <p>{{ error }}</p>
    <button routerLink="/flights">Tornar als vols</button>
  </div>

  <div *ngIf="success" class="success">
    <p>✓ {{ success }}</p>
    <p>Redirigint a les teves reserves...</p>
  </div>

  <div *ngIf="flight && !success" class="booking-form">
    <div class="flight-summary">
      <h3>Detalls del vol</h3>
      <div class="flight-details">
        <p><strong>Vol:</strong> {{ flight.flightNumber }} - {{ flight.airline }}</p>
        <p><strong>Ruta:</strong> {{ flight.origin }} → {{ flight.destination }}</p>
        <p><strong>Data:</strong> {{ flight.departureDate }}</p>
        <p><strong>Horari:</strong> {{ flight.departureTime }} - {{ flight.arrivalTime }}</p>
        <p><strong>Preu per passatger:</strong> {{ flight.price | currency:'EUR' }}</p>
        <p><strong>Seients disponibles:</strong> {{ flight.availableSeats }}</p>
      </div>
    </div>

    <form (ngSubmit)="onSubmit()">
      <div *ngIf="error" class="error-message">
        {{ error }}
      </div>

      <div class="form-group">
        <label for="passengers">Nombre de passatgers</label>
        <select id="passengers" [(ngModel)]="passengers" name="passengers">
          <option *ngFor="let i of [1,2,3,4,5,6,7,8,9]" 
                  [value]="i" 
                  [disabled]="i > flight.availableSeats">
            {{ i }} {{ i === 1 ? 'passatger' : 'passatgers' }}
          </option>
        </select>
      </div>

      <div class="total-price">
        <span>Preu total:</span>
        <span class="price">{{ totalPrice | currency:'EUR' }}</span>
      </div>

      <div class="form-actions">
        <button type="button" routerLink="/flights" class="btn-cancel">Cancel·lar</button>
        <button type="submit" [disabled]="submitting" class="btn-confirm">
          {{ submitting ? 'Processant...' : 'Confirmar Reserva' }}
        </button>
      </div>
    </form>
  </div>
</div>

Part 12: Component de llista de reserves

12.1 Genera el component

ng generate component components/bookings/booking-list

booking-list.component.ts

import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BookingsService } from '../../../services/bookings.service';
import { Booking } from '../../../models';

@Component({
  selector: 'app-booking-list',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './booking-list.component.html',
  styleUrls: ['./booking-list.component.css']
})
export class BookingListComponent implements OnInit {

  bookings: Booking[] = [];
  loading = true;
  error: string | null = null;

  constructor(private bookingsService: BookingsService) {}

  ngOnInit(): void {
    this.loadBookings();
  }

  loadBookings(): void {
    this.loading = true;
    this.error = null;

    this.bookingsService.getBookings().subscribe({
      next: (data) => {
        this.bookings = data;
        this.loading = false;
      },
      error: (err) => {
        this.error = err.message;
        this.loading = false;
      }
    });
  }

  cancelBooking(id: number): void {
    if (confirm('Estàs segur que vols cancel·lar aquesta reserva?')) {
      this.bookingsService.cancelBooking(id).subscribe({
        next: () => {
          this.loadBookings();
        },
        error: (err) => {
          alert(err.message);
        }
      });
    }
  }

  getStatusClass(status: string): string {
    switch (status) {
      case 'confirmed': return 'status-confirmed';
      case 'cancelled': return 'status-cancelled';
      case 'pending': return 'status-pending';
      default: return '';
    }
  }

  getStatusText(status: string): string {
    switch (status) {
      case 'confirmed': return 'Confirmada';
      case 'cancelled': return 'Cancel·lada';
      case 'pending': return 'Pendent';
      default: return status;
    }
  }
}

booking-list.component.html

<div class="container">
  <h2>Les Meves Reserves</h2>

  <div *ngIf="loading" class="loading">
    <p>Carregant reserves...</p>
  </div>

  <div *ngIf="error" class="error">
    <p>{{ error }}</p>
    <button (click)="loadBookings()">Tornar a provar</button>
  </div>

  <div *ngIf="!loading && !error">
    <div *ngIf="bookings.length === 0" class="no-bookings">
      <p>No tens cap reserva.</p>
      <a routerLink="/flights" class="btn-primary">Cercar vols</a>
    </div>

    <div class="bookings-list">
      <div *ngFor="let booking of bookings" class="booking-card">
        <div class="booking-header">
          <span class="booking-id">Reserva #{{ booking.id }}</span>
          <span [class]="'status ' + getStatusClass(booking.status)">
            {{ getStatusText(booking.status) }}
          </span>
        </div>

        <div *ngIf="booking.flight" class="flight-info">
          <h4>{{ booking.flight.flightNumber }} - {{ booking.flight.airline }}</h4>
          <p class="route">
            {{ booking.flight.origin }} → {{ booking.flight.destination }}
          </p>
          <p class="date">
            📅 {{ booking.flight.departureDate }} | 
            🕐 {{ booking.flight.departureTime }} - {{ booking.flight.arrivalTime }}
          </p>
        </div>

        <div class="booking-details">
          <p>👥 {{ booking.passengers }} {{ booking.passengers === 1 ? 'passatger' : 'passatgers' }}</p>
          <p class="total-price">💰 {{ booking.totalPrice | currency:'EUR' }}</p>
        </div>

        <div class="booking-actions" *ngIf="booking.status === 'confirmed'">
          <button (click)="cancelBooking(booking.id)" class="btn-cancel">
            Cancel·lar reserva
          </button>
        </div>
      </div>
    </div>
  </div>
</div>

Part 13: Configurar les rutes

13.1 Edita src/app/app.routes.ts

import { Routes } from '@angular/router';
import { authGuard } from './guards/auth.guard';

export const routes: Routes = [
  { path: '', redirectTo: '/flights', pathMatch: 'full' },
  { 
    path: 'login', 
    loadComponent: () => import('./components/auth/login/login.component')
      .then(m => m.LoginComponent) 
  },
  { 
    path: 'register', 
    loadComponent: () => import('./components/auth/register/register.component')
      .then(m => m.RegisterComponent) 
  },
  { 
    path: 'flights', 
    loadComponent: () => import('./components/flights/flight-list/flight-list.component')
      .then(m => m.FlightListComponent) 
  },
  { 
    path: 'book/:id', 
    loadComponent: () => import('./components/bookings/create-booking/create-booking.component')
      .then(m => m.CreateBookingComponent),
    canActivate: [authGuard]
  },
  { 
    path: 'bookings', 
    loadComponent: () => import('./components/bookings/booking-list/booking-list.component')
      .then(m => m.BookingListComponent),
    canActivate: [authGuard]
  }
];

Part 14: Component principal i navegació

14.1 Edita src/app/app.component.ts

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
import { AuthService } from './services/auth.service';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  constructor(public authService: AuthService) {}

  logout(): void {
    this.authService.logout();
  }
}

14.2 Edita src/app/app.component.html

<header>
  <div class="header-content">
    <h1>✈️ Reserves de Vols</h1>
    <nav>
      <a routerLink="/flights" routerLinkActive="active">Vols</a>

      <ng-container *ngIf="authService.isAuthenticated(); else notLoggedIn">
        <a routerLink="/bookings" routerLinkActive="active">Les meves reserves</a>
        <span class="user-name">{{ (authService.currentUser$ | async)?.name }}</span>
        <button (click)="logout()" class="btn-logout">Sortir</button>
      </ng-container>

      <ng-template #notLoggedIn>
        <a routerLink="/login" routerLinkActive="active">Entrar</a>
        <a routerLink="/register" routerLinkActive="active">Registrar-se</a>
      </ng-template>
    </nav>
  </div>
</header>

<main>
  <router-outlet></router-outlet>
</main>

<footer>
  <p>Pràctica 3 - Frontend de Reserves de Vols amb Angular</p>
</footer>

14.3 Edita src/app/app.component.css

header {
  background: linear-gradient(135deg, #1565c0, #0d47a1);
  color: white;
  padding: 15px 20px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}

.header-content {
  max-width: 1200px;
  margin: 0 auto;
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: 15px;
}

h1 {
  margin: 0;
  font-size: 24px;
}

nav {
  display: flex;
  align-items: center;
  gap: 15px;
}

nav a {
  color: white;
  text-decoration: none;
  padding: 8px 16px;
  border-radius: 4px;
  transition: background 0.3s;
}

nav a:hover {
  background: rgba(255,255,255,0.15);
}

nav a.active {
  background: rgba(255,255,255,0.25);
}

.user-name {
  color: rgba(255,255,255,0.9);
  font-size: 14px;
}

.btn-logout {
  background: rgba(255,255,255,0.2);
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
  transition: background 0.3s;
}

.btn-logout:hover {
  background: rgba(255,255,255,0.3);
}

main {
  min-height: calc(100vh - 130px);
  background: #f5f5f5;
}

footer {
  background: #333;
  color: white;
  text-align: center;
  padding: 15px;
}

Part 15: Executar i provar l'aplicació

15.1 Iniciar el servidor backend

En una terminal:

cd servidor-reserves-vols
npm start

15.2 Iniciar el frontend

En una altra terminal:

cd reserves-vols-frontend
ng serve

15.3 Provar l'aplicació

  1. Obre el navegador a http://localhost:4200
  2. Registra un nou usuari
  3. Cerca vols disponibles
  4. Crea una reserva
  5. Consulta les teves reserves
  6. Cancel·la una reserva

Part 16: Exercicis pràctics

Exercici 1: Afegir validació de formularis reactius

Modifica els components de login i registre per utilitzar ReactiveFormsModule amb validacions avançades.

Exercici 2: Afegir detall de vol

Crea un component que mostri tots els detalls d'un vol quan l'usuari hi faci clic.

Exercici 3: Modificar reserva

Implementa la funcionalitat per modificar el nombre de passatgers d'una reserva existent.

Exercici 4: Afegir notificacions

Implementa un sistema de notificacions (toast messages) per mostrar missatges d'èxit i error.

Exercici 5: Persistència del filtre

Guarda els filtres de cerca al localStorage per mantenir-los entre sessions.


Conceptes clau apresos

Concepte Descripció
JWT Token d'autenticació que s'envia a cada petició
Interceptor Middleware que intercepta peticions HTTP
Guard Protecció de rutes que requereixen autenticació
Signal Funció reactiva per gestionar l'estat (signal(), .set())
BehaviorSubject Observable que manté l'últim valor emès
@if / @for Nova sintaxi de control de flux (Angular 20)
Lazy Loading Càrrega de components sota demanda
LocalStorage Emmagatzematge persistent al navegador

Diagrama de l'arquitectura

┌─────────────────────────────────────────────────────────────┐
│                      APP COMPONENT                          │
│  ┌───────────────────────────────────────────────────────┐ │
│  │                      ROUTER                            │ │
│  │  ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌────────────┐ │ │
│  │  │  Login  │ │Register │ │FlightList│ │BookingList │ │ │
│  │  └────┬────┘ └────┬────┘ └────┬─────┘ └─────┬──────┘ │ │
│  └───────┼───────────┼───────────┼─────────────┼────────┘ │
└──────────┼───────────┼───────────┼─────────────┼──────────┘
           │           │           │             │
           ▼           ▼           ▼             ▼
    ┌──────────────────────────────────────────────────┐
    │                   SERVICES                        │
    │  ┌───────────┐  ┌──────────┐  ┌──────────────┐  │
    │  │AuthService│  │FlightsSvc│  │BookingService│  │
    │  └─────┬─────┘  └────┬─────┘  └──────┬───────┘  │
    └────────┼─────────────┼───────────────┼──────────┘
             │             │               │
             └─────────────┼───────────────┘
              ┌────────────────────────┐
              │    HTTP INTERCEPTOR    │
              │  (afegeix JWT token)   │
              └───────────┬────────────┘
              ┌────────────────────────┐
              │      HttpClient        │
              └───────────┬────────────┘
              ┌────────────────────────┐
              │   SERVIDOR API REST    │
              │  (localhost:3000)      │
              └────────────────────────┘

Resum

En aquesta pràctica has après a: - ✅ Crear una aplicació Angular 20 completa amb múltiples components - ✅ Implementar autenticació amb JWT - ✅ Crear serveis per comunicar-se amb una API REST - ✅ Implementar interceptors HTTP per afegir tokens - ✅ Protegir rutes amb guards d'autenticació - ✅ Gestionar l'estat amb Signals i BehaviorSubject - ✅ Utilitzar la nova sintaxi de control de flux (@if, @for) - ✅ Crear formularis per al registre, login i reserves - ✅ Mostrar llistes de vols i reserves amb filtres - ✅ Implementar operacions CRUD (crear, llegir, actualitzar, eliminar)


Recursos addicionals