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ó¶
- Obre el navegador a
http://localhost:4200 - Registra un nou usuari
- Cerca vols disponibles
- Crea una reserva
- Consulta les teves reserves
- 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¶
- Angular HTTP Client
- Angular Router Guards
- Angular Interceptors
- Angular Signals
- JWT.io - Eina per analitzar tokens JWT
- RxJS BehaviorSubject