09 - API e Routing

Anglar API

repo di partenza
Per integrare le api su angular dobbiamo andare a modificare il codice che avevamo su angular per adattarlo al nuovo formato dei dati.

article.ts diventa product.ts

export interface Product {
  id: string;
  name: string;
  description: string;
  netPrice: number;
  discount: number;
  weight: number;
}

cart-article.ts diventa cat-item.ts

import { Product } from "./product";

export interface CartItem {
  id: string;
  product: Product;
  quantity: number;
}

Andiamo ad adattare i vari componenti.

Integrazione alle api

La prima cosa da fare per poter interagire con le api è andare ad importare HttpClientModule.
Una volta importato abbiamo a disposizione tramite la DI HttpClient, tramite il quale possiamo andare ad effettuare chiamate http.
cart-source.service.ts

import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { CartItem } from '../interfaces/cart-item';
import { HttpClient } from '@angular/common/http';

@Injectable({
  providedIn: 'root'
})
export class CartSourceService {
  private _items$ = new BehaviorSubject<CartItem[]>([]);
  items$ = this._items$.asObservable();

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

  setQuantity(id: string, quantity: number) {
    const index = this._items$.value.findIndex(i => i.id === id);
    const clone = structuredClone(this._items$.value);
    clone[index].quantity = quantity;
    this._items$.next(clone);
  }

  fetch() {
    this.http.get<CartItem[]>('/api/cart-items')
      .subscribe(items => this._items$.next(items));
  }
}

Abbiamo però un problema, attualmente le nostre api sono ad un indirizzo diverso rispetto a quello della pagina web.
Potremmo andare a scrivere nel metodo get l'indirizzo completo, ma questo solitamente cambia tra lo sviluppo e il deploy ufficiale dell'applicazione. Di solito in produzione è tutto sotto lo stesso dominio, quindi vorremo fare anche in dev le chiamate a un indirizzo relativo e ottenere una risposta. Per fare questo angular mette a disposizione un proxy che si può configurare per indirizzare tutte le chiamate che iniziano con /api a un indirizzo specifico.

creare il file proxy.conf.json

{
  "/api": {
    "target": "http://localhost:3000",
    "secure": false
  }
}

modificare il file angular.json aggiungendo

"architect": {
    "serve": {
      "builder": "@angular-devkit/build-angular:dev-server",
      "options": {
        "proxyConfig": "./proxy.conf.json"
      },

Riavviando l'applicazione si può vedere che la pagina adesso fa ancora le chiamate a localhost:4200/api/... ma queste vengono intercettate e reindirizzate al nostro api server.

Andiamo ora a modificare il metodo setQuantity del servizio:
in genere ci sono due approcci quando si va a fare un aggiornamento dei dati, quello ottimistico e quello pessimistico:

  • ottimistico: vengono aggiornati i dati in locale prima di lanciare la chiamata alle api
    • pro: l'interfaccia si aggiorna subito, dando una esperienza utente più fluida
    • contro: se le chiamate falliscono devo tornare allo stato precedente, e non c'è un modo automatico per farlo. Inoltre se il server fa elaborazioni particolari sui dati devo occuparmi di replicare lo stesso funzionamento anche sul client
  • pessimistico: chiamo le api e solo quando mi tornano il risultato vado ad aggiornare i dati in locale
    • pro: sono più sicuro sul risultato e devo scrivere meno codice per gestire l'aggiornamento e il rollback in caso di errori
    • contro: l'utente ha una risposta un po' più lenta quando esegue l'azione

Nel nostro caso andremo ad utilizzare l'approccio pessimistico, va bene nella maggior parte dei casi, almeno finché le nostre api sono abbastanza veloci. Ed è più sicuro.

  setQuantity(id: string, quantity: number) {
    this.http.patch<CartItem>(`/api/cart-items/${id}`, { quantity } )
      .subscribe(updated => {
        const index = this._items$.value.findIndex(i => i.id === updated.id);
        const clone = structuredClone(this._items$.value);
        clone[index] = updated;
        this._items$.next(clone);
      })
  }

Normalmente in un approccio pessimistico si sta attenti a disabilitare i pulsanti che lanciano le chiamate fino a che la chiamata non è completata, per evitare che vengano chiamate più volte di fila le api. In questo caso ad esempio lanciare più chiamate di update in contemporanea (ad esempio con lo scroll del mouse sull'input) potrebbe dare risultati previsti, in quanto non abbiamo la certezza dell'ordine di esecuzione nel server.

Andiamo a proteggerci lato component:

import { Component, OnDestroy, OnInit } from '@angular/core';
import { CartSourceService } from './services/cart-source.service';
import { VatService } from './services/vat.service';
import { CartItem } from './interfaces/cart-item';
import { Subject } from 'rxjs';
import { debounceTime, takeUntil } from 'rxjs/operators';


@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit, OnDestroy {
  items$ = this.cartService.items$;
  vat$ = this.vatService.vat$;

  private updateQuantitySubject$ = new Subject<{id: string, quantity: number}>();
  private destroyed$ = new Subject<void>();

  constructor(private cartService: CartSourceService,
              private vatService: VatService) {
  }

  ngOnInit(): void {
    let country = 'IT';
    this.vatService.setCountry(country);

    this.updateQuantitySubject$
      .pipe(
        takeUntil(this.destroyed$),
        debounceTime(150)
      )
      .subscribe(({id, quantity}) => {
        this.cartService.setQuantity(id, quantity);
      })
  }

  ngOnDestroy(): void {
    this.destroyed$.next();
    this.destroyed$.complete();
  }

  updateQuantity(item: CartItem, quantity: number) {
    this.updateQuantitySubject$.next({id: item.id, quantity});
  }

  trackById(_: number, item: CartItem) {
    return item.id;
  }
}

Angular routing

Angular contiene le funzionalità necessarie a navigare tra più pagine dell'app, in fase di creazione del progetto viene chiesto se si vuole aggiungere questa funzionalità e il generatore va a creare i file necessari, nel nostro caso però non lo abbiamo fatto, quindi andiamo a creare tutto manualmente.

Andiamo a creare un nuovo file
app-routing.module.ts

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';

const routes: Routes = [];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

e in app.module lo andiamo ad importare.

Adesso dobbiamo andare ad dividere la nostra applicazione in pagine, ogni pagina generalmente ha un componente.
Spostiamo la logica e il template di AppComponent in un nuovo componente:

ng generate component pages/checkout

copiamo la logica e il template, aggiustando gli import.

Svuotiamo app.component.ts e app.component.html
in app.component.html andiamo ad inserire solo:

<router-outlet></router-outlet>

A questo punto la nostra app appare bianca, questo perché non abbiamo ancora configurato nulla. Andiamo ad aggiungere la route checkout ad app-routing.module.ts

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { CheckoutComponent } from './pages/checkout/checkout.component';

const routes: Routes = [
  {
    path: 'checkout',
    component: CheckoutComponent
  }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

ng generate component pages/products

e andiamo subito a configurarla come route:

import { ProductsComponent } from './pages/products/products.component';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { CheckoutComponent } from './pages/checkout/checkout.component';

const routes: Routes = [
  {
    path: 'checkout',
    component: CheckoutComponent
  },
  {
    path: 'products',
    component: ProductsComponent
  },
  {
    path: '',
    redirectTo: '/products',
    pathMatch: 'full'
  }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

il codice html base è:

<div class="container-fluid mt-5">
  <div class="row">
    <div class="col">
      <form>
        <div class="row align-items-end">
          <div class="col">
            <label class="form-label">Name:</label>
            <input type="text" class="form-control" placeholder="Product name">
          </div>
          <div class="col">
            <label class="form-label">Min Price:</label>
            <input type="number" class="form-control">
          </div>
          <div class="col">
            <label class="form-label">Max Price:</label>
            <input type="number" class="form-control">
          </div>
          <div class="col">
            <button class="btn btn-primary">Search</button>
          </div>
        </div>
      </form>

      <div class="row row-cols-1 row-cols-md-4 g-6 mt-5">
        <div class="col">
          <div class="card mt-2">
            <div class="card-body">
              <h5 class="card-title">item  name</h5>
              <h6 class="card-subtitle mb-2 text-muted">150€</h6>
              <p class="card-text">item description</p>
              <a href="#" class="btn btn-link">details</a>
            </div>
            <div class="card-footer text-muted text-end">
              <input type="number" value="1" class="product-card-quantity">
              <button class="btn btn-link">Add</button>
            </div>
          </div>
        </div>
      </div>
    </div>
    <div class="col side-cart">
      side cart
    </div>
  </div>

</div>

e il css:

.product-card-quantity {
  width: 70px;
}

.side-cart {
  max-width: 300px;
  border-left: 1px solid silver;
}

andiamo a creare un servizio per tornare i prodotti:

ng generate service services/product
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Product } from '../interfaces/product';

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

  constructor(private http: HttpClient) { }

  list() {
    return this.http.get<Product[]>('/api/products');
  }
}

Gestione della ricerca

Per gestire la ricerca dobbiamo andare ad attaccarci agli eventi del form della pagina. Invece di usare ngModel come abbiamo fatto per la quantità stavolta andiamo ad utilizza i ReactiveForm che ci mette a disposizione angular.
ReactiveFormModule mette a disposizione le classi FormControl per controllare gli input e FormGroup che permette di raggruppare più FormControl e controllarli assieme.
Oltre a questo ci sono altre funzionalità che tornano utili:

  • .valueChanges è una proprietà di tipo observable che emette un valore quando il valore del form cambia
  • si possono impostare da codice le regole di validazione e controllare se il valore è valido
  • si può settare in un'unica operazione il valore a tutti gli input del gruppo
  • si possono dare da codice dei valori iniziali agli input
  • si può controllare se l'utente ha interagito con il form

Per avere a disposizione queste funzionalità è necessario andare importare in AppModule ReactiveFormsModule.

Andiamo a creare un FormGroup con i control necessari a far funzionare i nostri filtri:
products.component.ts

import { Component, OnInit } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { ProductService } from 'src/app/services/product.service';

@Component({
  selector: 'app-products',
  templateUrl: './products.component.html',
  styleUrls: ['./products.component.css']
})
export class ProductsComponent implements OnInit {
  filters = this.fb.group({
    name: '',
    minPrice: null,
    maxPrice: null
  });

  products$ = this.productSrv.list();

  constructor(private productSrv: ProductService,
              private fb: FormBuilder) {}

  ngOnInit() {
    this.filters.valueChanges.subscribe(value => console.log(value));
  }
}

Per collegare i controlli al template si deve collegare il gruppo al contenitore e poi usare le direttive formControlName:

<form [formGroup]="filters">
        <div class="row align-items-end">
          <div class="col">
            <label class="form-label">Name:</label>
            <input formControlName="name" type="text" class="form-control" placeholder="Product name">
          </div>

come possiamo vedere ogni volta che cambia un valore nel form viene fatto il console.log che abbiamo scritto in ngOnInit.
Questo comportamento è configurabile per l'intero gruppo o volendo anche per singolo componente tramite l'opzione {updateOn: 'blur'}. I valori disponibili sono change, blur e submit.
Possiamo quindi ad esempio decidere che per il nome vogliamo che la ricerca parta già scrivendo, mentre per i price sia necessario il submit:

  filters = this.fb.group({
    name: ['', {updateOn: 'change'}],
    minPrice: [null, {updateOn: 'submit'}],
    maxPrice: [null, {updateOn: 'submit'}]
  });

Posso anche aggiungere direttamente delle validazioni alla definizione:

filters = this.fb.group({
    name: ['', {validators: [Validators.required]}],
    minPrice: [null, {updateOn: 'submit', validators: [Validators.min(0)]}],
    maxPrice: [null, {updateOn: 'submit'}]
  });

ngOnInit() {
    this.filters.valueChanges.subscribe(value => {
      console.log(this.filters.valid);
      console.log(value)
    });
  }

Andiamo a richiamare di nuovo il servizio per tornare i dati ogni volta che l'observable emette un nuovo valore.
Per fare questo potremmo semplicemente chiamare una funzione all'interno del subscribe, ma in realtà è consigliato sfruttare le trasformazioni degli observable tramite il metodo .pipe

Per prima cosa andiamo a modificare il servizio in modo che accetti dei parametri:
(ci serve lodash)

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Product } from '../interfaces/product';
import { omitBy, isNil } from 'lodash';

export interface ProductFilter {
  name?: string | null;
  minPrice?: number | null;
  maxPrice?: number | null;
}

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

  constructor(private http: HttpClient) { }

  list(filters: ProductFilter) {
    const q = omitBy(filters, isNil);
    return this.http.get<Product[]>('/api/products', {params: q});
  }
}
  products$ = this.filters.valueChanges
                .pipe(
                  filter(_ => this.filters.valid),
                  switchMap(filters => {
                    return this.productSrv.list(filters)
                      .pipe(
                        catchError(err => {
                          console.error(err)
                          return [];
                        })
                      )
                  })
                );

A questo punto però all'apertura la nostra pagina è vuota, questo perché l'observable emette solo quando il valore del form cambia. Per correggere questo comportamento dobbiamo aggiungere startsWith:

products$ = this.filters.valueChanges
                .pipe(
                  filter(_ => this.filters.valid),
                  startWith({}),
                  switchMap(filters => {
                    return this.productSrv.list(filters)
                      .pipe(
                        catchError(err => {
                          console.error(err)
                          return [];
                        })
                      )
                  })
                );

Limitiamo il numero di richieste andando ad aggiungere un debounceTime

Dividiamo la parte del filtro in un componente a parte:

ng generate component components/product-filer

product-filters.component.html

<form [formGroup]="filtersForm">
  <div class="row align-items-end">
    <div class="col">
      <label class="form-label">Name:</label>
      <input formControlName="name" type="text" class="form-control" placeholder="Product name">
    </div>
    <div class="col">
      <label class="form-label">Min Price:</label>
      <input formControlName="minPrice" type="number" class="form-control">
    </div>
    <div class="col">
      <label class="form-label">Max Price:</label>
      <input formControlName="maxPrice" type="number" class="form-control">
    </div>
    <div class="col">
      <button class="btn btn-primary" type="submit">Search</button>
    </div>
  </div>
</form>

product-filters.component.ts

import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { Subject, filter, takeUntil } from 'rxjs';
import { ProductFilter } from 'src/app/services/product.service';

@Component({
  selector: 'app-product-filters',
  templateUrl: './product-filters.component.html',
  styleUrls: ['./product-filters.component.css']
})
export class ProductFiltersComponent implements OnInit, OnDestroy {
  filtersForm = this.fb.group({
    name: this.fb.control<string|null>(''),
    minPrice: this.fb.control<number|null>(null, {updateOn: 'submit', validators: [Validators.min(0)]}),
    maxPrice: this.fb.control<number|null>(null, {updateOn: 'submit'}),
  });

  @Input()
  set filters(value: ProductFilter) {
    this.filtersForm.patchValue(value, {emitEvent: false});
    this.filtersForm.markAsPristine();
  }

  @Output()
  filtersChange = new EventEmitter<ProductFilter>();

  private destroyed$ = new Subject<void>();

  constructor(private fb: FormBuilder) {}

  ngOnInit(): void {
      this.filtersForm.valueChanges
        .pipe(
          takeUntil(this.destroyed$),
          filter(_ => this.filtersForm.valid)
        )
        .subscribe(value => this.filtersChange.emit(value));
  }

  ngOnDestroy(): void {
    this.destroyed$.next();
    this.destroyed$.complete();
  }

}

Angular Resolvers

Mettiamo caso di voler gestire i filtri della pagina non solo attraverso il form, ma che siano anche codificati nell'url della pagina stessa, in modo che l'utente possa ricaricare e rimanere sui suoi risultati e usare il tasti avanti e indietro per navigare tra le pagine mantenendo le ricerche fatte.

Facciamo in modo che quando cambiano i filtri vengano settati dei queryParams nell'url:

import { Component, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { isNil, omitBy } from 'lodash';
import { ReplaySubject, Subject } from 'rxjs';
import { switchMap, catchError, startWith, debounceTime, takeUntil, map } from 'rxjs/operators';
import { ProductFilter, ProductService } from 'src/app/services/product.service';

@Component({
  selector: 'app-products',
  templateUrl: './products.component.html',
  styleUrls: ['./products.component.css']
})
export class ProductsComponent implements OnInit, OnDestroy {
  filters$ = new ReplaySubject<ProductFilter>();

  products$ = this.filters$
                .pipe(
                  startWith({}),
                  debounceTime(300),
                  switchMap(filters => {
                    return this.productSrv.list(filters)
                      .pipe(
                        catchError(err => {
                          console.error(err)
                          return [];
                        })
                      )
                  })
                );

  private destroyed$ = new Subject<void>();

  constructor(private productSrv: ProductService,
              private router: Router) {}

  ngOnInit() {

    this.filters$
      .pipe(
        takeUntil(this.destroyed$),
        map(filters => omitBy(filters, isNil)),
        map(filters => omitBy(filters, val => val === '')),
        debounceTime(300)
      )
      .subscribe(filters => {
        this.router.navigate([], {queryParams: filters});
      })
  }

  ngOnDestroy(): void {
      this.destroyed$.next();
      this.destroyed$.complete();
  }

  applyFilters(filters: ProductFilter) {
    this.filters$.next(filters);
  }
}

Adesso abbiamo bisogno di andare a leggere i queryparams, caricare i valori nel form e usare i filtri per la chiamata alle api. Per fare questo ci conviene separare le logiche in file diversi, chiamati Resolver, che entrano in gioco già a livello di router e passano dei dati direttamente al componente.
creiamo il file product-filter.resolver.ts

import { Injectable } from "@angular/core";
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from "@angular/router";
import { Observable, of } from "rxjs";
import { ProductFilter } from "src/app/services/product.service";

@Injectable({ providedIn: 'root' })
export class ProductFilterResolver implements Resolve<ProductFilter> {
  constructor() {}

  resolve(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot
  ): Observable<ProductFilter> {
    return of(route.queryParams)
  }
}

nel router aggiungiamo:

  {
    path: 'products',
    component: ProductsComponent,
    resolve: {
      filters: ProductFilterResolver
    },
	runGuardsAndResolvers: 'paramsOrQueryParamsChange',
  },

e a questo punto nel component possiamo andare a leggerci i dati con:

  constructor(private productSrv: ProductService,
              private router: Router,
              private activatedRoute: ActivatedRoute) {}

  ngOnInit() {
    this.activatedRoute.data
      .subscribe(data => {
        console.log(data);
      })

Da notare che quando cambiano solo i queryparams non viene ricreato l'intero component, arrivano solo dei nuovi valori su activatedRoute.data.

Possiamo andare a usare questo observable per aggiornare i valori del form quando carichiamo la pagina:

import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { isNil, omitBy } from 'lodash';
import { ReplaySubject, Subject } from 'rxjs';
import { switchMap, catchError, startWith, debounceTime, takeUntil, map, tap } from 'rxjs/operators';
import { ProductFilter, ProductService } from 'src/app/services/product.service';

@Component({
  selector: 'app-products',
  templateUrl: './products.component.html',
  styleUrls: ['./products.component.css']
})
export class ProductsComponent implements OnInit, OnDestroy {
  filters$ = this.activatedRoute.data
                .pipe(
                  map(({filters}) => filters as ProductFilter)
                );

  private updateQueryParams$ = new Subject<ProductFilter>();

  products$ = this.filters$
                .pipe(
                  switchMap(filters => {
                    return this.productSrv.list(filters)
                      .pipe(
                        catchError(err => {
                          console.error(err)
                          return [];
                        })
                      )
                  })
                );

  private destroyed$ = new Subject<void>();

  constructor(private productSrv: ProductService,
              private router: Router,
              private activatedRoute: ActivatedRoute) {}

  ngOnInit() {
    this.updateQueryParams$
      .pipe(
        takeUntil(this.destroyed$),
        map(filters => omitBy(filters, isNil)),
        map(filters => omitBy(filters, val => val === '')),
        debounceTime(300)
      )
      .subscribe(filters => {
        this.router.navigate([], {queryParams: filters});
      })
  }

  ngOnDestroy(): void {
      this.destroyed$.next();
      this.destroyed$.complete();
  }

  applyFilters(filters: ProductFilter) {
    this.updateQueryParams$.next(filters);
  }
}

Portiamo fuori anche la logica che torna la lista di prodotti:

import { Injectable } from "@angular/core";
import { ActivatedRoute, ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from "@angular/router";
import { Observable, catchError, map, of, shareReplay, switchMap, tap } from "rxjs";
import { Product } from "src/app/interfaces/product";
import { ProductService } from "src/app/services/product.service";

@Injectable({ providedIn: 'root' })
export class ProductsResolver implements Resolve<Product[]> {
  constructor(private productSrv: ProductService) {}

  resolve(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot
  ): Observable<Product[]> {
    return this.productSrv.list(route.queryParams)
            .pipe(
              catchError(err => {
                console.error(err)
                return [];
              })
            );
  }
}


e a questo punto il mio componente diventa:

import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { isNil, omitBy } from 'lodash';
import { ReplaySubject, Subject } from 'rxjs';
import { switchMap, catchError, startWith, debounceTime, takeUntil, map, tap } from 'rxjs/operators';
import { Product } from 'src/app/interfaces/product';
import { ProductFilter, ProductService } from 'src/app/services/product.service';

@Component({
  selector: 'app-products',
  templateUrl: './products.component.html',
  styleUrls: ['./products.component.css']
})
export class ProductsComponent implements OnInit, OnDestroy {
  filters$ = this.activatedRoute.data
                .pipe(
                  map(({filters}) => filters as ProductFilter)
                );

  private updateQueryParams$ = new Subject<ProductFilter>();

  products$ = this.activatedRoute.data
                .pipe(
                  map(({products}) => products as Product[])
                );

  private destroyed$ = new Subject<void>();

  constructor(private productSrv: ProductService,
              private router: Router,
              private activatedRoute: ActivatedRoute) {}

  ngOnInit() {
    this.updateQueryParams$
      .pipe(
        takeUntil(this.destroyed$),
        map(filters => omitBy(filters, isNil)),
        map(filters => omitBy(filters, val => val === '')),
        debounceTime(300)
      )
      .subscribe(filters => {
        this.router.navigate([], {queryParams: filters});
      })
  }

  ngOnDestroy(): void {
      this.destroyed$.next();
      this.destroyed$.complete();
  }

  applyFilters(filters: ProductFilter) {
    this.updateQueryParams$.next(filters);
  }
}

Il resto dell'app

I prossimi passaggi sono:

  • creare un componente per la singola card
    • prodotto in input, un output per il click su add e uno per il click su details
    • internamente calcola il prezzo effettivo considerando sconto e iva
  • gestire la logica per l'aggiunta di un componente al carrello quando si fa click su add
  • creare un componente per il carrello da visualizzare lateralmente
    • visualizza il prezzo totale in alto
    • subito sotto un pulsante per andare alla pagina /checkout
    • ancora sotto la lista degli articoli aggiunti con il pulsante per rimuoverli
  • creare una pagina di dettaglio del prodotto
    • navigare alla pagina quando si fa click su details
    • nella pagina è sempre visibile il carrello lateralmente
    • in una sezione della pagina sarà possibile andare a selezionare la quantità e aggiungere al carrello