Cómo construir un sitio de comercio electrónico con Angular 11, Commerce Layer y Paypal

Publicado: 2022-03-10
Resumen rápido ↬ Tener una tienda de comercio electrónico es crucial para cualquier propietario de una tienda, ya que cada vez más clientes recurren a las compras en línea. En este tutorial, veremos cómo crear un sitio de comercio electrónico con Angular 11. El sitio utilizará la capa de comercio como una API de comercio electrónico sin cabeza y utilizará Paypal para procesar los pagos.

Hoy en día es esencial tener una presencia en línea cuando se ejecuta un negocio. Se realizan muchas más compras online que en años anteriores. Tener una tienda de comercio electrónico permite a los propietarios de tiendas abrir otras fuentes de ingresos que no podrían aprovechar con solo una tienda física. Sin embargo, otros dueños de tiendas manejan sus negocios completamente en línea sin una presencia física. Esto hace que tener una tienda en línea sea crucial.

Los sitios como Etsy, Shopify y Amazon facilitan la configuración de una tienda con bastante rapidez sin tener que preocuparse por desarrollar un sitio. Sin embargo, puede haber casos en los que los propietarios de tiendas deseen una experiencia personalizada o tal vez ahorrar en el costo de tener una tienda en algunas de estas plataformas.

Las plataformas API de comercio electrónico sin cabeza proporcionan backends con los que los sitios de tiendas pueden interactuar. Gestionan todos los procesos y datos relacionados con la tienda como clientes, pedidos, envíos, pagos, etc. Todo lo que se necesita es una interfaz para interactuar con esta información. Esto brinda a los propietarios mucha flexibilidad cuando se trata de decidir cómo sus clientes experimentarán su tienda en línea y cómo elegirán administrarla.

En este artículo, cubriremos cómo construir una tienda de comercio electrónico usando Angular 11. Usaremos Commerce Layer como nuestra API de comercio electrónico sin interfaz. Aunque puede haber toneladas de formas de procesar pagos, le mostraremos cómo usar solo una, Paypal.

  • Ver código fuente en GitHub →

requisitos previos

Antes de compilar la aplicación, debe tener instalado Angular CLI. Lo usaremos para inicializar y montar la aplicación. Si aún no lo tiene instalado, puede obtenerlo a través de npm.

 npm install -g @angular/cli

También necesitará una cuenta de desarrollador de Commerce Layer. Con la cuenta de desarrollador, deberá crear una organización de prueba y sembrarla con datos de prueba. La inicialización facilita el desarrollo de la aplicación primero sin preocuparse por los datos que tendrá que usar. Puede crear una cuenta en este enlace y una organización aquí.

Tablero de organizaciones de cuentas de desarrollador de Commerce Layer
Tablero de organizaciones de cuentas de desarrollador de Commerce Layer donde agrega su organización. (Vista previa grande)
Formulario de creación de organizaciones de Commerce Layer
Marque la casilla Semilla con datos de prueba al crear una nueva organización. (Vista previa grande)

Por último, necesitará una cuenta Paypal Sandbox. Tener este tipo de cuenta nos permitirá probar transacciones entre empresas y usuarios sin arriesgar dinero real. Tú puedes crear uno aquí. Una cuenta sandbox tiene una cuenta empresarial de prueba y una cuenta personal de prueba ya creadas para ella.

¡Más después del salto! Continúe leyendo a continuación ↓

Capa de comercio y configuración de Paypal

Para que los pagos de Paypal Sandbox sean posibles en Commerce Layer, deberá configurar claves API. Dirígete a la descripción general de las cuentas de tu cuenta de desarrollador de Paypal. Seleccione una cuenta comercial y, en la pestaña Credenciales API de los detalles de la cuenta, encontrará la Aplicación predeterminada en Aplicaciones REST .

Pestaña Credenciales de API en la ventana emergente de detalles de la cuenta empresarial de Paypal Sandbox
Dónde encontrar la aplicación REST predeterminada en la ventana emergente de detalles de la cuenta comercial de Paypal. (Vista previa grande)
Descripción general de la aplicación predeterminada en la configuración de la cuenta comercial de Paypal Sandbox
Descripción general de la aplicación predeterminada en la configuración de la cuenta comercial de Paypal Sandbox donde puede obtener la identificación y el secreto del cliente API REST. (Vista previa grande)

Para asociar su cuenta comercial de Paypal con su organización de Commerce Layer, vaya al panel de control de su organización. Aquí agregará una pasarela de pago de Paypal y un método de pago de Paypal para sus diversos mercados. En Configuración > Pagos , seleccione Pasarelas de pago > Paypal y agregue su ID y secreto de cliente de Paypal.

Nuevo tablero de pasarela de pagos en la capa de comercio
Dónde en el tablero de Commerce Layer para crear una pasarela de pagos de Paypal. (Vista previa grande)

Después de crear la puerta de enlace, deberá crear un método de pago de Paypal para cada mercado al que se dirige para que Paypal esté disponible como una opción. Hará esto en Configuración > Pagos > Métodos de pago > Nuevo método de pago .

Tablero de métodos de pago en la capa de comercio
Dónde en el tablero de Commerce Layer para crear un método de pago de Paypal. (Vista previa grande)

Una nota sobre las rutas utilizadas

Commerce Layer proporciona una ruta para la autenticación y otro conjunto diferente de rutas para su API. Su ruta de autenticación /oauth/token intercambia credenciales por un token. Este token es necesario para acceder a su API. El resto de las rutas de la API toman el patrón /api/:resource .

El alcance de este artículo solo cubre la parte frontal de esta aplicación. Opté por almacenar el lado del servidor de tokens, usar sesiones para rastrear la propiedad y proporcionar cookies de solo http con una identificación de sesión para el cliente. Esto no se tratará aquí ya que está fuera del alcance de este artículo. Sin embargo, las rutas siguen siendo las mismas y corresponden exactamente a la API de la capa de comercio. Aunque hay un par de rutas personalizadas que no están disponibles en la API de la capa de comercio que usaremos. Estos se ocupan principalmente de la gestión de sesiones. Los señalaré a medida que lleguemos a ellos y describiré cómo puede lograr un resultado similar.

Otra inconsistencia que puede notar es que los cuerpos de la solicitud difieren de lo que requiere la API de la capa de comercio. Dado que las solicitudes se pasan a otro servidor para que se completen con un token, estructuré los cuerpos de manera diferente. Esto fue para facilitar el envío de solicitudes. Siempre que existan inconsistencias en los cuerpos de la solicitud, se señalarán en los servicios.

Dado que esto está fuera del alcance, deberá decidir cómo almacenar tokens de forma segura. También deberá modificar ligeramente los cuerpos de las solicitudes para que coincidan exactamente con lo que requiere la API de la capa de comercio. Cuando haya una inconsistencia, vincularé la referencia de la API y las guías que detallan cómo estructurar correctamente el cuerpo.

Estructura de la aplicación

Para organizar la aplicación, la dividiremos en cuatro partes principales. Una mejor descripción de lo que hace cada uno de los módulos se da en sus secciones correspondientes:

  1. el módulo central,
  2. el módulo de datos,
  3. el módulo compartido,
  4. los módulos de características.

Los módulos de funciones agruparán páginas y componentes relacionados. Habrá cuatro módulos de funciones:

  1. el módulo de autenticación,
  2. el módulo de producto,
  3. el módulo del carro,
  4. el módulo de pago.

A medida que lleguemos a cada módulo, explicaré cuál es su propósito y desglosaré su contenido.

A continuación se muestra un árbol de la carpeta src/app y dónde reside cada módulo.

 src ├── app │ ├── core │ ├── data │ ├── features │ │ ├── auth │ │ ├── cart │ │ ├── checkout │ │ └── products └── shared

Generación de la aplicación y adición de dependencias

Comenzaremos generando la aplicación. Nuestra organización se llamará The LIme Brand y tendrá datos de prueba ya sembrados por Commerce Layer.

 ng new lime-app

Necesitaremos un par de dependencias. Principalmente Material Angular y Hasta Destruir. Angular Material proporcionará componentes y estilo. Hasta que Destroy se da de baja automáticamente de los observables cuando se destruyen los componentes. Para instalarlos ejecuta:

 npm install @ngneat/until-destroy ng add @angular/material

Activos

Al agregar direcciones a Commerce Layer, se debe usar un código de país alfa-2. Agregaremos un archivo json que contenga estos códigos a la carpeta de recursos en assets assets/json/country-codes.json . Puede encontrar este archivo vinculado aquí.

Estilos

Los componentes que crearemos comparten un estilo global. Los colocaremos en styles.css que se puede encontrar en este enlace.

Ambiente

Nuestra configuración constará de dos campos. La apiUrl que debe apuntar a la API de la capa de comercio. apiUrl es utilizado por los servicios que crearemos para obtener datos. clientUrl debe ser el dominio en el que se ejecuta la aplicación. Usamos esto cuando configuramos URL de redirección para Paypal. Puede encontrar este archivo en este enlace.

Módulo compartido

El módulo compartido contendrá servicios, canalizaciones y componentes compartidos entre los otros módulos.

 ng gm shared

Consta de tres componentes, una tubería y dos servicios. Así es como se verá.

 src/app/shared ├── components │ ├── item-quantity │ │ ├── item-quantity.component.css │ │ ├── item-quantity.component.html │ │ └── item-quantity.component.ts │ ├── simple-page │ │ ├── simple-page.component.css │ │ ├── simple-page.component.html │ │ └── simple-page.component.ts │ └── title │ ├── title.component.css │ ├── title.component.html │ └── title.component.ts ├── pipes │ └── word-wrap.pipe.ts ├── services │ ├── http-error-handler.service.ts │ └── local-storage.service.ts └── shared.module.ts

También usaremos el módulo compartido para exportar algunos componentes de material angular de uso común. Esto hace que sea más fácil usarlos listos para usar en lugar de importar cada componente en varios módulos. Esto es lo que contendrá shared.module.ts .

 @NgModule({ declarations: [SimplePageComponent, TitleComponent, WordWrapPipe, ItemQuantityComponent], imports: [CommonModule, MatIconModule, MatButtonModule, MatTooltipModule, MatMenuModule, RouterModule], exports: [ CommonModule, ItemQuantityComponent, MatButtonModule, MatIconModule, MatSnackBarModule, MatTooltipModule, SimplePageComponent, TitleComponent, WordWrapPipe ] }) export class SharedModule { }

Componentes

Artículo Cantidad Componente

Este componente establece la cantidad de artículos al agregarlos al carrito. Se utilizará en los módulos de carrito y productos. Un selector de materiales habría sido una opción fácil para este propósito. Sin embargo, el estilo de la selección de material no coincidía con las entradas de material utilizadas en todas las demás formas. Un menú de materiales se parecía mucho a las entradas de materiales utilizadas. Así que decidí crear un componente de selección con él.

 ng gc shared/components/item-quantity

El componente tendrá tres propiedades de entrada y una propiedad de salida. quantity establece la cantidad inicial de elementos, maxValue indica la cantidad máxima de elementos que se pueden seleccionar de una sola vez y disabled indica si el componente debe deshabilitarse o no. El setQuantityEvent se activa cuando se selecciona una cantidad.

Cuando se inicialice el componente, estableceremos los valores que aparecen en el menú de materiales. También existe un método llamado setQuantity que emitirá eventos setQuantityEvent .

Este es el archivo del componente.

 @Component({ selector: 'app-item-quantity', templateUrl: './item-quantity.component.html', styleUrls: ['./item-quantity.component.css'] }) export class ItemQuantityComponent implements OnInit { @Input() quantity: number = 0; @Input() maxValue?: number = 0; @Input() disabled?: boolean = false; @Output() setQuantityEvent = new EventEmitter<number>(); values: number[] = []; constructor() { } ngOnInit() { if (this.maxValue) { for (let i = 1; i <= this.maxValue; i++) { this.values.push(i); } } } setQuantity(value: number) { this.setQuantityEvent.emit(value); } }

Esta es su plantilla.

 <button mat-stroked-button [matMenuTriggerFor]="menu" [disabled]="disabled"> {{quantity}} <mat-icon *ngIf="!disabled">expand_more</mat-icon> </button> <mat-menu #menu="matMenu"> <button *ngFor="let no of values" (click)="setQuantity(no)" mat-menu-item>{{no}}</button> </mat-menu>

Aquí está su estilo.

 button { margin: 3px; }

Componente de título

Este componente también funciona como un título paso a paso, así como un título simple en algunas páginas más simples. Aunque Angular Material proporciona un componente paso a paso, no era la mejor opción para un proceso de pago bastante largo, no respondía tan bien en pantallas más pequeñas y requería mucho más tiempo para implementarlo. Sin embargo, un título más simple podría reutilizarse como un indicador paso a paso y ser útil en varias páginas.

 ng gc shared/components/title

El componente tiene cuatro propiedades de entrada: un title , un subtitle , un número ( no ) y centerText , para indicar si se debe centrar el texto del componente.

 @Component({ selector: 'app-title', templateUrl: './title.component.html', styleUrls: ['./title.component.css'] }) export class TitleComponent { @Input() title: string = ''; @Input() subtitle: string = ''; @Input() no?: string; @Input() centerText?: boolean = false; }

A continuación se muestra su plantilla. Puede encontrar su estilo vinculado aquí.

 <div> <h1 *ngIf="no" class="mat-display-1">{{no}}</h1> <div [ngClass]="{ 'centered-section': centerText}"> <h1 class="mat-display-2">{{title}}</h1> <p>{{subtitle}}</p> </div> </div>

Componente de página simple

Hay varios casos en los que un título, un icono y un botón eran todo lo que se necesitaba para una página. Estos incluyen una página 404, una página de carrito vacío, una página de error, una página de pago y una página de colocación de pedidos. Ese es el propósito que servirá el componente de página simple. Cuando se hace clic en el botón de la página, se redirigirá a una ruta o realizará alguna acción en respuesta a un evento de buttonEvent .

Para hacerlo:

 ng gc shared/components/simple-page

Este es su archivo componente.

 @Component({ selector: 'app-simple-page', templateUrl: './simple-page.component.html', styleUrls: ['./simple-page.component.css'] }) export class SimplePageComponent { @Input() title: string = ''; @Input() subtitle?: string; @Input() number?: string; @Input() icon?: string; @Input() buttonText: string = ''; @Input() centerText?: boolean = false; @Input() buttonDisabled?: boolean = false; @Input() route?: string | undefined; @Output() buttonEvent = new EventEmitter(); constructor(private router: Router) { } buttonClicked() { if (this.route) { this.router.navigateByUrl(this.route); } else { this.buttonEvent.emit(); } } }

Y su plantilla contiene:

 <div> <app-title no="{{number}}" title="{{title}}" subtitle="{{subtitle}}" [centerText]="centerText"></app-title> <div *ngIf="icon"> <mat-icon color="primary" class="icon">{{icon}}</mat-icon> </div> <button mat-flat-button color="primary" (click)="buttonClicked()" [disabled]="buttonDisabled"> {{buttonText}} </button> </div>

Su estilo se puede encontrar aquí.

Tubería

Tubo de ajuste de palabra

Los nombres de algunos productos y otros tipos de información que se muestran en el sitio son realmente largos. En algunos casos, lograr que estas oraciones largas se envuelvan en componentes materiales es un desafío. Así que usaremos esta canalización para reducir las oraciones a una longitud específica y agregar puntos suspensivos al final del resultado.

Para crearlo ejecuta:

 ng g pipe shared/pipes/word-wrap

Contendrá:

 import { Pipe, PipeTransform } from '@angular/core'; @Pipe({ name: 'wordWrap' }) export class WordWrapPipe implements PipeTransform { transform(value: string, length: number): string { return `${value.substring(0, length)}...`; } }

Servicios

Servicio de controlador de errores HTTP

Hay bastantes servicios http en este proyecto. Crear un controlador de errores para cada método es repetitivo. Por lo tanto, tiene sentido crear un solo controlador que pueda ser utilizado por todos los métodos. El controlador de errores se puede utilizar para dar formato a un error y también transmitir los errores a otras plataformas de registro externas.

generarlo ejecutando:

 ng gs shared/services/http-error-handler

Este servicio contendrá solo un método. El método formateará el mensaje de error que se mostrará dependiendo de si se trata de un error del cliente o del servidor. Sin embargo, hay margen para mejorarlo aún más.

 @Injectable({ providedIn: 'root' }) export class HttpErrorHandler { constructor() { } handleError(err: HttpErrorResponse): Observable { let displayMessage = ''; if (err.error instanceof ErrorEvent) { displayMessage = `Client-side error: ${err.error.message}`; } else { displayMessage = `Server-side error: ${err.message}`; } return throwError(displayMessage); } } @Injectable({ providedIn: 'root' }) export class HttpErrorHandler { constructor() { } handleError(err: HttpErrorResponse): Observable { let displayMessage = ''; if (err.error instanceof ErrorEvent) { displayMessage = `Client-side error: ${err.error.message}`; } else { displayMessage = `Server-side error: ${err.message}`; } return throwError(displayMessage); } }

Servicio de almacenamiento local

Utilizaremos el almacenamiento local para realizar un seguimiento de la cantidad de artículos en un carrito. También es útil almacenar aquí el Id. de un pedido. Un pedido corresponde a un carrito en Commerce Layer.

Para generar el servicio de almacenamiento local, ejecute:

 ng gs shared/services/local-storage

El servicio contendrá cuatro métodos para agregar, eliminar y obtener elementos del almacenamiento local y otro para borrarlo.

 import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class LocalStorageService { constructor() { } addItem(key: string, value: string) { localStorage.setItem(key, value); } deleteItem(key: string) { localStorage.removeItem(key); } getItem(key: string): string | null { return localStorage.getItem(key); } clear() { localStorage.clear(); } }

Módulo de datos

Este módulo es responsable de la recuperación y gestión de datos. Es lo que usaremos para obtener los datos que consume nuestra aplicación. A continuación se muestra su estructura:

 src/app/data ├── data.module.ts ├── models └── services

Para generar la ejecución del módulo:

 ng gm data

Modelos

Los modelos definen cómo se estructuran los datos que consumimos de la API. Tendremos 16 declaraciones de interfaz. Para crearlos ejecuta:

 for model in \ address cart country customer-address \ customer delivery-lead-time line-item order \ payment-method payment-source paypal-payment \ price shipment shipping-method sku stock-location; \ do ng g interface "data/models/${model}"; done

La siguiente tabla enlaza con cada archivo y da una descripción de lo que es cada interfaz.

Interfaz Descripción
Habla a Representa una dirección general.
Carro Versión del lado del cliente de un pedido que rastrea la cantidad de productos que un cliente tiene la intención de comprar.
País Código de país alfa-2.
Dirección del cliente Una dirección asociada con un cliente.
Cliente Un usuario registrado.
Plazo de entrega Representa la cantidad de tiempo que tomará la entrega de un envío.
Elemento en linea Un producto detallado añadido al carrito.
Pedido Un carrito de compras o una colección de artículos de línea.
Método de pago Un tipo de pago disponible para un pedido.
Fuente de pago Un pago asociado a un pedido.
Pago de PayPal Un pago realizado a través de Paypal
Precio Precio asociado a un SKU.
Envío Colección de artículos enviados juntos.
Método de envío Método a través del cual se envía un paquete.
SKU Una unidad única de mantenimiento de existencias.
Ubicación de existencias Ubicación que contiene el inventario de SKU.

Servicios

Esta carpeta contiene los servicios que crean, recuperan y manipulan los datos de la aplicación. Crearemos 11 servicios aquí.

 for service in \ address cart country customer-address \ customer delivery-lead-time line-item \ order paypal-payment shipment sku; \ do ng gs "data/services/${service}"; done

Servicio de direcciones

Este servicio crea y recupera direcciones. Es importante al crear y asignar direcciones de envío y facturación a los pedidos. Tiene dos métodos. Uno para crear una dirección y otro para recuperar una.

La ruta utilizada aquí es /api/addresses . Si va a utilizar la API de la capa de comercio directamente, asegúrese de estructurar los datos como se muestra en este ejemplo.

 @Injectable({ providedIn: 'root' }) export class AddressService { private url: string = `${environment.apiUrl}/api/addresses`; constructor(private http: HttpClient, private eh: HttpErrorHandler) { } createAddress(address: Address): Observable<Address> { return this.http.post<Address>(this.url, address) .pipe(catchError(this.eh.handleError)); } getAddress(id: string): Observable<Address> { return this.http.get<Address>(`${this.url}/${id}`) .pipe(catchError(this.eh.handleError)); } }

Servicio de carrito

El carrito es responsable de mantener la cantidad de artículos agregados y la identificación del pedido. Hacer llamadas a la API para obtener la cantidad de artículos en un pedido cada vez que se crea una nueva línea puede resultar costoso. En su lugar, podríamos usar el almacenamiento local para mantener el conteo en el cliente. Esto elimina la necesidad de realizar pedidos innecesarios cada vez que se agrega un artículo al carrito.

También usamos este servicio para almacenar la identificación del pedido. Un carrito corresponde a un pedido en Commerce Layer. Una vez que se agrega el primer artículo al carrito, se crea un pedido. Necesitamos conservar este ID de pedido para poder obtenerlo durante el proceso de pago.

Además, necesitamos una forma de comunicar al encabezado que se agregó un artículo al carrito. El encabezado contiene el botón del carrito y muestra la cantidad de artículos que contiene. Usaremos un observable de un BehaviorSubject con el valor actual del carrito. El encabezado puede suscribirse a esto y realizar un seguimiento de los cambios en el valor del carrito.

Por último, una vez que se ha completado un pedido, se debe borrar el valor del carrito. Esto garantiza que no haya confusión al crear nuevos pedidos posteriores. Los valores que se almacenaron se borran una vez que el pedido actual se marca como realizado.

Haremos todo esto usando el servicio de almacenamiento local creado anteriormente.

 @Injectable({ providedIn: 'root' }) export class CartService { private cart = new BehaviorSubject({ orderId: this.orderId, itemCount: this.itemCount }); cartValue$ = this.cart.asObservable(); constructor(private storage: LocalStorageService) { } get orderId(): string { const id = this.storage.getItem('order-id'); return id ? id : ''; } set orderId(id: string) { this.storage.addItem('order-id', id); this.cart.next({ orderId: id, itemCount: this.itemCount }); } get itemCount(): number { const itemCount = this.storage.getItem('item-count'); return itemCount ? parseInt(itemCount) : 0; } set itemCount(amount: number) { this.storage.addItem('item-count', amount.toString()); this.cart.next({ orderId: this.orderId, itemCount: amount }); } incrementItemCount(amount: number) { this.itemCount = this.itemCount + amount; } decrementItemCount(amount: number) { this.itemCount = this.itemCount - amount; } clearCart() { this.storage.deleteItem('item-count'); this.cart.next({ orderId: '', itemCount: 0 }); } }

Servicio al País

Al agregar direcciones en Commerce Layer, el código de país debe ser un código alfa 2. Este servicio lee un archivo json que contiene estos códigos para cada país y lo devuelve en su método getCountries .

 @Injectable({ providedIn: 'root' }) export class CountryService { constructor(private http: HttpClient) { } getCountries(): Observable { return this.http.get ('./../../../assets/json/country-codes.json'); } } @Injectable({ providedIn: 'root' }) export class CountryService { constructor(private http: HttpClient) { } getCountries(): Observable { return this.http.get ('./../../../assets/json/country-codes.json'); } } @Injectable({ providedIn: 'root' }) export class CountryService { constructor(private http: HttpClient) { } getCountries(): Observable { return this.http.get ('./../../../assets/json/country-codes.json'); } }

Servicio de dirección de clientes

Este servicio se utiliza para asociar direcciones con clientes. También obtiene una dirección específica o todas las relacionadas con un cliente. Se utiliza cuando el cliente agrega sus direcciones de envío y facturación a su pedido. El método createCustomer crea un cliente, getCustomerAddresses obtiene todas las direcciones de un cliente y getCustomerAddress obtiene una específica.

Al crear una dirección de cliente, asegúrese de estructurar el cuerpo de la publicación de acuerdo con este ejemplo.

 @Injectable({ providedIn: 'root' }) export class CustomerAddressService { private url: string = `${environment.apiUrl}/api/customer_addresses`; constructor(private http: HttpClient, private eh: HttpErrorHandler) { } createCustomerAddress(addressId: string, customerId: string): Observable<CustomerAddress> { return this.http.post<CustomerAddress>(this.url, { addressId: addressId, customerId: customerId }) .pipe(catchError(this.eh.handleError)); } getCustomerAddresses(): Observable<CustomerAddress[]> { return this.http.get<CustomerAddress[]>(`${this.url}`) .pipe(catchError(this.eh.handleError)); } getCustomerAddress(id: string): Observable<CustomerAddress> { return this.http.get<CustomerAddress>(`${this.url}/${id}`) .pipe(catchError(this.eh.handleError)); } }

Servicio al Cliente

Los clientes se crean y su información se recupera utilizando este servicio. Cuando un usuario se registra, se convierte en cliente y se crea mediante createCustomerMethod . getCustomer devuelve el cliente asociado con una identificación específica. getCurrentCustomer devuelve el cliente actualmente conectado.

Al crear un cliente, estructura los datos de esta manera. Puede agregar su nombre y apellido a los metadatos, como se muestra en sus atributos.

La ruta /api/customers/current no está disponible en Commerce Layer. Por lo tanto, deberá descubrir cómo obtener el cliente que ha iniciado sesión actualmente.

 @Injectable({ providedIn: 'root' }) export class CustomerService { private url: string = `${environment.apiUrl}/api/customers`; constructor(private http: HttpClient, private eh: HttpErrorHandler) { } createCustomer(email: string, password: string, firstName: string, lastName: string): Observable<Customer> { return this.http.post<Customer>(this.url, { email: email, password: password, firstName: firstName, lastName: lastName }) .pipe(catchError(this.eh.handleError)); } getCurrentCustomer(): Observable<Customer> { return this.http.get<Customer>(`${this.url}/current`) .pipe(catchError(this.eh.handleError)); } getCustomer(id: string): Observable<Customer> { return this.http.get<Customer>(`${this.url}/${id}`) .pipe(catchError(this.eh.handleError)); } }

Servicio de tiempo de entrega

Este servicio devuelve información sobre los plazos de envío desde varias ubicaciones de stock.

 @Injectable({ providedIn: 'root' }) export class DeliveryLeadTimeService { private url: string = `${environment.apiUrl}/api/delivery_lead_times`; constructor(private http: HttpClient, private eh: HttpErrorHandler) { } getDeliveryLeadTimes(): Observable<DeliveryLeadTime[]> { return this.http.get<DeliveryLeadTime[]>(this.url) .pipe(catchError(this.eh.handleError)); } }

Servicio de elementos de línea

Los artículos agregados al carrito son administrados por este servicio. Con él, puede crear un artículo en el momento en que se agrega al carrito. También se puede obtener la información de un elemento. El artículo también puede actualizarse cuando cambia su cantidad o eliminarse cuando se elimina del carrito.

Al crear elementos o actualizarlos, estructure el cuerpo de la solicitud como se muestra en este ejemplo.

 @Injectable({ providedIn: 'root' }) export class LineItemService { private url: string = `${environment.apiUrl}/api/line_items`; constructor(private http: HttpClient, private eh: HttpErrorHandler) { } createLineItem(lineItem: LineItem): Observable<LineItem> { return this.http.post<LineItem>(this.url, lineItem) .pipe(catchError(this.eh.handleError)); } getLineItem(id: string): Observable<LineItem> { return this.http.get<LineItem>(`${this.url}/${id}`) .pipe(catchError(this.eh.handleError)); } updateLineItem(id: string, quantity: number): Observable<LineItem> { return this.http.patch<LineItem>(`${this.url}/${id}`, { quantity: quantity }) .pipe(catchError(this.eh.handleError)); } deleteLineItem(id: string): Observable<LineItem> { return this.http.delete<LineItem>(`${this.url}/${id}`) .pipe(catchError(this.eh.handleError)); } }

Servicio de pedidos

Similar al servicio de elementos de línea, el servicio de pedidos le permite crear, actualizar, eliminar u obtener un pedido. Además, puede optar por obtener los envíos asociados con un pedido por separado mediante el método getOrderShipments . Este servicio se utiliza mucho durante todo el proceso de pago.

Hay diferentes tipos de información sobre un pedido que se requieren durante el pago. Dado que puede ser costoso obtener un pedido completo y sus relaciones, especificamos lo que queremos obtener de un pedido usando GetOrderParams . El equivalente de esto en la API de CL es el parámetro de consulta de inclusión en el que enumera las relaciones de orden que se incluirán. Puede verificar qué campos deben incluirse para el resumen del carrito aquí y para las distintas etapas de pago aquí.

De la misma manera, al actualizar un pedido, usamos UpdateOrderParams para especificar campos de actualización. Esto se debe a que en el servidor que llena el token, se realizan algunas operaciones adicionales según el campo que se actualice. Sin embargo, si realiza solicitudes directas a la API de CL, no es necesario que especifique esto. Puede eliminarlo ya que la API CL no requiere que los especifique. Aunque, el cuerpo de la solicitud debería parecerse a este ejemplo.

 @Injectable({ providedIn: 'root' }) export class OrderService { private url: string = `${environment.apiUrl}/api/orders`; constructor( private http: HttpClient, private eh: HttpErrorHandler) { } createOrder(): Observable<Order> { return this.http.post<Order>(this.url, {}) .pipe(catchError(this.eh.handleError)); } getOrder(id: string, orderParam: GetOrderParams): Observable<Order> { let params = {}; if (orderParam != GetOrderParams.none) { params = { [orderParam]: 'true' }; } return this.http.get<Order>(`${this.url}/${id}`, { params: params }) .pipe(catchError(this.eh.handleError)); } updateOrder(order: Order, params: UpdateOrderParams[]): Observable<Order> { let updateParams = []; for (const param of params) { updateParams.push(param.toString()); } return this.http.patch<Order>( `${this.url}/${order.id}`, order, { params: { 'field': updateParams } } ) .pipe(catchError(this.eh.handleError)); } getOrderShipments(id: string): Observable<Shipment[]> { return this.http.get<Shipment[]>(`${this.url}/${id}/shipments`) .pipe(catchError(this.eh.handleError)); } }

Servicio de pago de Paypal

Este servicio se encarga de crear y actualizar los pagos de Paypal para los pedidos. Además, podemos obtener un pago de Paypal dado su id. El cuerpo de la publicación debe tener una estructura similar a este ejemplo al crear un pago de Paypal.

 @Injectable({ providedIn: 'root' }) export class PaypalPaymentService { private url: string = `${environment.apiUrl}/api/paypal_payments`; constructor(private http: HttpClient, private eh: HttpErrorHandler) { } createPaypalPayment(payment: PaypalPayment): Observable<PaypalPayment> { return this.http.post<PaypalPayment>(this.url, payment) .pipe(catchError(this.eh.handleError)); } getPaypalPayment(id: string): Observable<PaypalPayment> { return this.http.get<PaypalPayment>(`${this.url}/${id}`) .pipe(catchError(this.eh.handleError)); } updatePaypalPayment(id: string, paypalPayerId: string): Observable<PaypalPayment> { return this.http.patch<PaypalPayment>( `${this.url}/${id}`, { paypalPayerId: paypalPayerId } ) .pipe(catchError(this.eh.handleError)); } }

Servicio de envío

Este servicio recibe un envío o lo actualiza dado su id. El cuerpo de la solicitud de una actualización de envío debe ser similar a este ejemplo.

 @Injectable({ providedIn: 'root' }) export class ShipmentService { private url: string = `${environment.apiUrl}/api/shipments`; constructor(private http: HttpClient, private eh: HttpErrorHandler) { } getShipment(id: string): Observable<Shipment> { return this.http.get<Shipment>(`${this.url}/${id}`) .pipe(catchError(this.eh.handleError)); } updateShipment(id: string, shippingMethodId: string): Observable<Shipment> { return this.http.patch<Shipment>( `${this.url}/${id}`, { shippingMethodId: shippingMethodId } ) .pipe(catchError(this.eh.handleError)); } }

Servicio SKU

El servicio SKU obtiene productos de la tienda. Si se recuperan varios productos, se pueden paginar y tener un tamaño de página establecido. El tamaño de página y el número de página deben establecerse como parámetros de consulta como en este ejemplo si realiza solicitudes directas a la API. También se puede recuperar un solo producto dado su id.

 @Injectable({ providedIn: 'root' }) export class SkuService { private url: string = `${environment.apiUrl}/api/skus`; constructor(private http: HttpClient, private eh: HttpErrorHandler) { } getSku(id: string): Observable<Sku> { return this.http.get<Sku>(`${this.url}/${id}`) .pipe(catchError(this.eh.handleError)); } getSkus(page: number, pageSize: number): Observable<Sku[]> { return this.http.get<Sku[]>( this.url, { params: { 'page': page.toString(), 'pageSize': pageSize.toString() } }) .pipe(catchError(this.eh.handleError)); } }

Módulo principal

El módulo principal contiene todo lo central y común en toda la aplicación. Estos incluyen componentes como el encabezado y páginas como la página 404. Los servicios responsables de la autenticación y la gestión de sesiones también se incluyen aquí, así como los interceptores y guardias de toda la aplicación.

El árbol del módulo central se verá así.

 src/app/core ├── components │ ├── error │ │ ├── error.component.css │ │ ├── error.component.html │ │ └── error.component.ts │ ├── header │ │ ├── header.component.css │ │ ├── header.component.html │ │ └── header.component.ts │ └── not-found │ ├── not-found.component.css │ ├── not-found.component.html │ └── not-found.component.ts ├── core.module.ts ├── guards │ └── empty-cart.guard.ts ├── interceptors │ └── options.interceptor.ts └── services ├── authentication.service.ts ├── header.service.ts └── session.service.ts

Para generar el módulo y su contenido ejecuta:

 ng gm core ng gg core/guards/empty-cart ng gs core/header/header ng g interceptor core/interceptors/options for comp in header error not-found; do ng gc "core/${comp}"; done for serv in authentication session; do ng gs "core/authentication/${serv}"; done

El archivo del módulo central debería tener este aspecto. Tenga en cuenta que las rutas se han registrado para NotFoundComponent y ErrorComponent .

 @NgModule({ declarations: [HeaderComponent, NotFoundComponent, ErrorComponent], imports: [ RouterModule.forChild([ { path: '404', component: NotFoundComponent }, { path: 'error', component: ErrorComponent }, { path: '**', redirectTo: '/404' } ]), MatBadgeModule, SharedModule ], exports: [HeaderComponent] }) export class CoreModule { }

Servicios

La carpeta de servicios contiene los servicios de autenticación, sesión y encabezado.

Servicio de autenticación

El AuthenticationService le permite adquirir tokens de clientes y clientes. Estos tokens se utilizan para acceder al resto de rutas de la API. Los tokens de cliente se devuelven cuando un usuario intercambia un correo electrónico y una contraseña y tiene una gama más amplia de permisos. Los tokens de cliente se emiten sin necesidad de credenciales y tienen permisos más limitados.

getClientSession obtiene un token de cliente. el inicio de login obtiene un token de cliente. Ambos métodos también crean una sesión. El cuerpo de una solicitud de token de cliente debería verse así y el de un token de cliente así.

 @Injectable({ providedIn: 'root' }) export class AuthenticationService { private url: string = `${environment.apiUrl}/oauth/token`; constructor(private http: HttpClient, private eh: HttpErrorHandler) { } getClientSession(): Observable<object> { return this.http.post<object>( this.url, { grantType: 'client_credentials' }) .pipe(catchError(this.eh.handleError)); } login(email: string, password: string): Observable<object> { return this.http.post<object>( this.url, { username: email, password: password, grantType: 'password' }) .pipe(catchError(this.eh.handleError)); } }

Session Service

The SessionService is responsible for session management. The service will contain an observable from a BehaviorSubject called loggedInStatus to communicate whether a user is logged in. setLoggedInStatus sets the value of this subject, true for logged in, and false for not logged in. isCustomerLoggedIn makes a request to the server to check if the user has an existing session. logout destroys the session on the server. The last two methods access routes that are unique to the server that populates the request with a token. They are not available from Commerce Layer. You'll have to figure out how to implement them.

 @Injectable({ providedIn: 'root' }) export class SessionService { private url: string = `${environment.apiUrl}/session`; private isLoggedIn = new BehaviorSubject(false); loggedInStatus = this.isLoggedIn.asObservable(); constructor(private http: HttpClient, private eh: HttpErrorHandler) { } setLoggedInStatus(status: boolean) { this.isLoggedIn.next(status); } isCustomerLoggedIn(): Observable<{ message: string }> { return this.http.get<{ message: string }>(`${this.url}/customer/status`) .pipe(catchError(this.eh.handleError)); } logout(): Observable<{ message: string }> { return this.http.get<{ message: string }>(`${this.url}/destroy`) .pipe(catchError(this.eh.handleError)); } }

Header Service

The HeaderService is used to communicate whether the cart, login, and logout buttons should be shown in the header. These buttons are hidden on the login and signup pages but present on all other pages to prevent confusion. We'll use an observable from a BehaviourSubject called showHeaderButtons that shares this. We'll also have a setHeaderButtonsVisibility method to set this value.

 @Injectable({ providedIn: 'root' }) export class HeaderService { private headerButtonsVisibility = new BehaviorSubject(true); showHeaderButtons = this.headerButtonsVisibility.asObservable(); constructor() { } setHeaderButtonsVisibility(visible: boolean) { this.headerButtonsVisibility.next(visible); } }

Componentes

Error Component

This component is used as an error page. It is useful in instances when server requests fail and absolutely no data is displayed on a page. Instead of showing a blank page, we let the user know that a problem occurred. Below is it's template.

 <app-simple-page title="An error occurred" subtitle="There was a problem fetching your page" buttonText="GO TO HOME" icon="report" [centerText]="true" route="/"> </app-simple-page>

This is what the component will look like.

Screenshot of error page
Screenshot of error page. (Vista previa grande)

Not Found Component

This is a 404 page that the user gets redirected to when they request a route not available on the router. Only its template is modified.

 <app-simple-page title="404: Page not found" buttonText="GO TO HOME" icon="search" subtitle="The requested page could not be found" [centerText]="true" route="/"></app-simple-page>
Screenshot of 404 page
Screenshot of 404 page. (Vista previa grande)

Header Component

The HeaderComponent is basically the header displayed at the top of a page. It will contain the app title, the cart, login, and logout buttons.

When this component is initialized, a request is made to check whether the user has a current session. This happens when subscribing to this.session.isCustomerLoggedIn() . We subscribe to this.session.loggedInStatus to check if the user logs out throughout the life of the app. The this.header.showHeaderButtons subscription decides whether to show all the buttons on the header or hide them. this.cart.cartValue$ gets the count of items in the cart.

Existe un método de logout de sesión que destruye la sesión de un usuario y le asigna un token de cliente. Se asigna un token de cliente porque la sesión que mantiene su token de cliente se destruye y aún se requiere un token para cada solicitud de API. Una barra de material comunica al usuario si su sesión se destruyó con éxito o no.

Usamos el @UntilDestroy({ checkProperties: true }) para indicar que todas las suscripciones deben cancelarse automáticamente cuando se destruye el componente.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-header', templateUrl: './header.component.html', styleUrls: ['./header.component.css'] }) export class HeaderComponent implements OnInit { cartAmount: number = 0; isLoggedIn: boolean = false; showButtons: boolean = true; constructor( private session: SessionService, private snackBar: MatSnackBar, private cart: CartService, private header: HeaderService, private auth: AuthenticationService ) { } ngOnInit() { this.session.isCustomerLoggedIn() .subscribe( () => { this.isLoggedIn = true; this.session.setLoggedInStatus(true); } ); this.session.loggedInStatus.subscribe(status => this.isLoggedIn = status); this.header.showHeaderButtons.subscribe(visible => this.showButtons = visible); this.cart.cartValue$.subscribe(cart => this.cartAmount = cart.itemCount); } logout() { concat( this.session.logout(), this.auth.getClientSession() ).subscribe( () => { this.snackBar.open('You have been logged out.', 'Close', { duration: 4000 }); this.session.setLoggedInStatus(false); }, err => this.snackBar.open('There was a problem logging you out.', 'Close', { duration: 4000 }) ); } }

A continuación se muestra la plantilla de encabezado y el vínculo aquí es su estilo.

 <div> <div routerLink="/"> <h1><span>Lime</span><span>Store</span></h1> </div> <div> <div *ngIf="showButtons"> <button mat-icon-button color="primary" aria-label="shopping cart"> <mat-icon [matBadge]="cartAmount" matBadgeColor="accent" aria-label="shopping cart" routerLink="/cart">shopping_cart</mat-icon> </button> <button mat-icon-button color="primary" aria-label="login" *ngIf="!isLoggedIn"> <mat-icon aria-label="login" matTooltip="login" routerLink="/login">login</mat-icon> </button> <button mat-icon-button color="primary" aria-label="logout" *ngIf="isLoggedIn" (click)="logout()"> <mat-icon aria-label="logout" matTooltip="logout">logout</mat-icon> </button> </div> </div> </div>

guardias

Guardia de carro vacío

Este protector evita que los usuarios accedan a las rutas relacionadas con el pago y la facturación si su carrito está vacío. Esto se debe a que para proceder con el pago, debe haber un pedido válido. Un pedido corresponde a un carrito con artículos en él. Si hay artículos en el carrito, el usuario puede pasar a una página protegida. Sin embargo, si el carrito está vacío, se redirige al usuario a una página de carrito vacío.

 @Injectable({ providedIn: 'root' }) export class EmptyCartGuard implements CanActivate { constructor(private cart: CartService, private router: Router) { } canActivate( route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree { if (this.cart.orderId) { if (this.cart.itemCount > 0) { return true; } } return this.router.parseUrl('/empty'); } }

Interceptores

Interceptor de opciones

Este interceptor intercepta todas las solicitudes HTTP salientes y agrega dos opciones a la solicitud. Estos son un encabezado Content-Type y una propiedad withCredentials . withCredentials especifica si una solicitud debe enviarse con credenciales salientes como las cookies de solo http que usamos. Usamos Content-Type para indicar que estamos enviando recursos json al servidor.

 @Injectable() export class OptionsInterceptor implements HttpInterceptor { constructor() { } intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { request = request.clone({ headers: request.headers.set('Content-Type', 'application/json'), withCredentials: true }); return next.handle(request); } }

Módulos de funciones

Esta sección contiene las principales características de la aplicación. Como se mencionó anteriormente, las funciones se agrupan en cuatro módulos: módulos de autenticación, producto, carrito y pago.

Módulo de Productos

El módulo de productos contiene páginas que muestran productos en venta. Estos incluyen la página del producto y la página de la lista de productos. Está estructurado como se muestra a continuación.

 src/app/features/products ├── pages │ ├── product │ │ ├── product.component.css │ │ ├── product.component.html │ │ └── product.component.ts │ └── product-list │ ├── product-list.component.css │ ├── product-list.component.html │ └── product-list.component.ts └── products.module.ts

Para generarlo y sus componentes:

 ng gm features/products ng gc features/products/pages/product ng gc features/products/pages/product-list

Este es el archivo del módulo:

 @NgModule({ declarations: [ProductListComponent, ProductComponent], imports: [ RouterModule.forChild([ { path: 'product/:id', component: ProductComponent }, { path: '', component: ProductListComponent } ]), LayoutModule, MatCardModule, MatGridListModule, MatPaginatorModule, SharedModule ] }) export class ProductsModule { }

Componente de lista de productos

Este componente muestra una lista paginada de productos disponibles para la venta. Es la primera página que se carga cuando se inicia la aplicación.

Los productos se muestran en una cuadrícula. La lista de cuadrícula de materiales es el mejor componente para esto. Para que la cuadrícula responda, la cantidad de columnas de la cuadrícula cambiará según el tamaño de la pantalla. El servicio BreakpointObserver nos permite determinar el tamaño de la pantalla y asignar las columnas durante la inicialización.

Para obtener los productos, llamamos al método getProducts de SkuService . Devuelve los productos si tiene éxito y los asigna a la cuadrícula. Si no, dirigimos al usuario a la página de error.

Dado que los productos que se muestran están paginados, tendremos un método getNextPage para obtener los productos adicionales.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-product-list', templateUrl: './product-list.component.html', styleUrls: ['./product-list.component.css'] }) export class ProductListComponent implements OnInit { cols = 4; length = 0; pageIndex = 0; pageSize = 20; pageSizeOptions: number[] = [5, 10, 20]; pageEvent!: PageEvent | void; products: Sku[] = []; constructor( private breakpointObserver: BreakpointObserver, private skus: SkuService, private router: Router, private header: HeaderService) { } ngOnInit() { this.getProducts(1, 20); this.header.setHeaderButtonsVisibility(true); this.breakpointObserver.observe([ Breakpoints.Handset, Breakpoints.Tablet, Breakpoints.Web ]).subscribe(result => { if (result.matches) { if (result.breakpoints['(max-width: 599.98px) and (orientation: portrait)'] || result.breakpoints['(max-width: 599.98px) and (orientation: landscape)']) { this.cols = 1; } else if (result.breakpoints['(min-width: 1280px) and (orientation: portrait)'] || result.breakpoints['(min-width: 1280px) and (orientation: landscape)']) { this.cols = 4; } else { this.cols = 3; } } }); } private getProducts(page: number, pageSize: number) { this.skus.getSkus(page, pageSize) .subscribe( skus => { this.products = skus; this.length = skus[0].__collectionMeta.recordCount; }, err => this.router.navigateByUrl('/error') ); } getNextPage(event: PageEvent) { this.getProducts(event.pageIndex + 1, event.pageSize); } trackSkus(index: number, item: Sku) { return `${item.id}-${index}`; } }

La plantilla se muestra a continuación y su estilo se puede encontrar aquí.

 <mat-grid-list cols="{{cols}}" rowHeight="400px" gutterSize="20px" class="grid-layout"> <mat-grid-tile *ngFor="let product of products; trackBy: trackSkus"> <mat-card> <img mat-card-image src="{{product.imageUrl}}" alt="product photo"> <mat-card-content> <mat-card-title matTooltip="{{product.name}}">{{product.name |wordWrap:35}}</mat-card-title> <mat-card-subtitle>{{product.prices[0].compareAtAmountFloat | currency:'EUR'}}</mat-card-subtitle> </mat-card-content> <mat-card-actions> <button mat-flat-button color="primary" [routerLink]="['/product', product.id]"> View </button> </mat-card-actions> </mat-card> </mat-grid-tile> </mat-grid-list> <mat-paginator [length]="length" [pageIndex]="pageIndex" [pageSize]="pageSize" [pageSizeOptions]="pageSizeOptions" (page)="pageEvent = getNextPage($event)"> </mat-paginator>

La página se verá así.

Captura de pantalla de la página de lista de productos
Captura de pantalla de la página de lista de productos. (Vista previa grande)

Componente del producto

Una vez que se selecciona un producto de la página de lista de productos, este componente muestra sus detalles. Estos incluyen el nombre completo, el precio y la descripción del producto. También hay un botón para agregar el artículo al carrito de productos.

En la inicialización, obtenemos la identificación del producto de los parámetros de la ruta. Usando la identificación, obtenemos el producto de SkuService .

Cuando el usuario agrega un artículo al carrito, se llama al método addItemToCart . En él comprobamos si ya se ha creado un pedido para el carrito. Si no, se hace uno nuevo usando el OrderService . Después de lo cual, se crea una línea de pedido en el orden que corresponde al producto. Si ya existe un pedido para el carrito, solo se crea la línea de pedido. Dependiendo del estado de las solicitudes, se muestra un mensaje de snackbar al usuario.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-product', templateUrl: './product.component.html', styleUrls: ['./product.component.css'] }) export class ProductComponent implements OnInit { id: string = ''; product!: Sku; quantity: number = 0; constructor( private route: ActivatedRoute, private skus: SkuService, private location: Location, private router: Router, private header: HeaderService, private orders: OrderService, private lineItems: LineItemService, private cart: CartService, private snackBar: MatSnackBar ) { } ngOnInit() { this.route.paramMap .pipe( mergeMap(params => { const id = params.get('id') this.id = id ? id : ''; return this.skus.getSku(this.id); }), tap((sku) => { this.product = sku; }) ).subscribe({ error: (err) => this.router.navigateByUrl('/error') }); this.header.setHeaderButtonsVisibility(true); } addItemToCart() { if (this.quantity > 0) { if (this.cart.orderId == '') { this.orders.createOrder() .pipe( mergeMap((order: Order) => { this.cart.orderId = order.id || ''; return this.lineItems.createLineItem({ orderId: order.id, name: this.product.name, imageUrl: this.product.imageUrl, quantity: this.quantity, skuCode: this.product.code }); }) ) .subscribe( () => { this.cart.incrementItemCount(this.quantity); this.showSuccessSnackBar(); }, err => this.showErrorSnackBar() ); } else { this.lineItems.createLineItem({ orderId: this.cart.orderId, name: this.product.name, imageUrl: this.product.imageUrl, quantity: this.quantity, skuCode: this.product.code }).subscribe( () => { this.cart.incrementItemCount(this.quantity); this.showSuccessSnackBar(); }, err => this.showErrorSnackBar() ); } } else { this.snackBar.open('Select a quantity greater than 0.', 'Close', { duration: 8000 }); } } setQuantity(no: number) { this.quantity = no; } goBack() { this.location.back(); } private showSuccessSnackBar() { this.snackBar.open('Item successfully added to cart.', 'Close', { duration: 8000 }); } private showErrorSnackBar() { this.snackBar.open('Failed to add your item to the cart.', 'Close', { duration: 8000 }); } }

La plantilla ProductComponent es la siguiente y su estilo está vinculado aquí.

 <div> <mat-card *ngIf="product" class="product-card"> <img mat-card-image src="{{product.imageUrl}}" alt="Photo of a product"> <mat-card-content> <mat-card-title>{{product.name}}</mat-card-title> <mat-card-subtitle>{{product.prices[0].compareAtAmountFloat | currency:'EUR'}}</mat-card-subtitle> <p> {{product.description}} </p> </mat-card-content> <mat-card-actions> <app-item-quantity [quantity]="quantity" [maxValue]="10" (setQuantityEvent)="setQuantity($event)"></app-item-quantity> <button mat-raised-button color="accent" (click)="addItemToCart()"> <mat-icon>add_shopping_cart</mat-icon> Add to cart </button> <button mat-raised-button color="primary" (click)="goBack()"> <mat-icon>storefront</mat-icon> Continue shopping </button> </mat-card-actions> </mat-card> </div>

La página se verá así.

Captura de pantalla de la página del producto
Captura de pantalla de la página del producto. (Vista previa grande)

Módulo de autenticación

El módulo Auth contiene páginas responsables de la autenticación. Estos incluyen las páginas de inicio de sesión y registro. Está estructurado de la siguiente manera.

 src/app/features/auth/ ├── auth.module.ts └── pages ├── login │ ├── login.component.css │ ├── login.component.html │ └── login.component.ts └── signup ├── signup.component.css ├── signup.component.html └── signup.component.ts

Para generarlo y sus componentes:

 ng gm features/auth ng gc features/auth/pages/signup ng gc features/auth/pages/login

Este es su archivo de módulo.

 @NgModule({ declarations: [LoginComponent, SignupComponent], imports: [ RouterModule.forChild([ { path: 'login', component: LoginComponent }, { path: 'signup', component: SignupComponent } ]), MatFormFieldModule, MatInputModule, ReactiveFormsModule, SharedModule ] }) export class AuthModule { }

Componente de registro

Un usuario se registra para obtener una cuenta utilizando este componente. Se requiere un nombre, apellido, correo electrónico y contraseña para el proceso. El usuario también necesita confirmar su contraseña. Los campos de entrada se crearán con el servicio FormBuilder . Se agrega validación para requerir que todas las entradas tengan valores. Se agrega una validación adicional al campo de la contraseña para garantizar una longitud mínima de ocho caracteres. Un matchPasswords de coincidencia de contraseñas personalizado garantiza que la contraseña confirmada coincida con la contraseña inicial.

Cuando se inicializa el componente, los botones de carro, inicio de sesión y cierre de sesión en el encabezado están ocultos. Esto se comunica al encabezado mediante HeaderService .

Después de que todos los campos estén marcados como válidos, el usuario puede registrarse. En el método de signup , el método createCustomer de CustomerService recibe esta entrada. Si el registro es exitoso, se informa al usuario que su cuenta se creó con éxito utilizando una barra de bocadillos. Luego son redirigidos a la página de inicio.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-signup', templateUrl: './signup.component.html', styleUrls: ['./signup.component.css'] }) export class SignupComponent implements OnInit { signupForm = this.fb.group({ firstName: ['', Validators.required], lastName: ['', Validators.required], email: ['', [Validators.required, Validators.email]], password: ['', [Validators.required, Validators.minLength(8)]], confirmedPassword: ['', [Validators.required]] }, { validators: this.matchPasswords }); @ViewChild(FormGroupDirective) sufDirective: FormGroupDirective | undefined; constructor( private customer: CustomerService, private fb: FormBuilder, private snackBar: MatSnackBar, private router: Router, private header: HeaderService ) { } ngOnInit() { this.header.setHeaderButtonsVisibility(false); } matchPasswords(signupGroup: AbstractControl): ValidationErrors | null { const password = signupGroup.get('password')?.value; const confirmedPassword = signupGroup.get('confirmedPassword')?.value; return password == confirmedPassword ? null : { differentPasswords: true }; } get password() { return this.signupForm.get('password'); } get confirmedPassword() { return this.signupForm.get('confirmedPassword'); } signup() { const customer = this.signupForm.value; this.customer.createCustomer( customer.email, customer.password, customer.firstName, customer.lastName ).subscribe( () => { this.signupForm.reset(); this.sufDirective?.resetForm(); this.snackBar.open('Account successfully created. You will be redirected in 5 seconds.', 'Close', { duration: 5000 }); setTimeout(() => this.router.navigateByUrl('/'), 6000); }, err => this.snackBar.open('There was a problem creating your account.', 'Close', { duration: 5000 }) ); } }

A continuación se muestra la plantilla para SignupComponent .

 <form [formGroup]="signupForm" (ngSubmit)="signup()"> <h1 class="mat-display-3">Create Account</h1> <mat-form-field appearance="outline"> <mat-label>First Name</mat-label> <input matInput formControlName="firstName"> <mat-icon matPrefix>portrait</mat-icon> </mat-form-field> <mat-form-field appearance="outline"> <mat-label>Last Name</mat-label> <input matInput formControlName="lastName"> <mat-icon matPrefix>portrait</mat-icon> </mat-form-field> <mat-form-field appearance="outline"> <mat-label>Email</mat-label> <input matInput formControlName="email" type="email"> <mat-icon matPrefix>alternate_email</mat-icon> </mat-form-field> <mat-form-field appearance="outline"> <mat-label>Password</mat-label> <input matInput formControlName="password" type="password"> <mat-icon matPrefix>vpn_key</mat-icon> </mat-form-field> <mat-form-field appearance="outline"> <mat-label>Confirm Password</mat-label> <input matInput formControlName="confirmedPassword" type="password"> <mat-icon matPrefix>vpn_key</mat-icon> </mat-form-field> <div *ngIf="confirmedPassword?.invalid && (confirmedPassword?.dirty || confirmedPassword?.touched)"> <mat-error *ngIf="signupForm.hasError('differentPasswords')"> Your passwords do not match. </mat-error> </div> <div *ngIf="password?.invalid && (password?.dirty || password?.touched)"> <mat-error *ngIf="password?.hasError('minlength')"> Your password should be at least 8 characters. </mat-error> </div> <button mat-flat-button color="primary" [disabled]="!signupForm.valid">Sign Up</button> </form>

El componente resultará de la siguiente manera.

Captura de pantalla de la página de registro
Captura de pantalla de la página de registro. (Vista previa grande)

Componente de inicio de sesión

Un usuario registrado inicia sesión en su cuenta con este componente. Es necesario introducir un correo electrónico y una contraseña. Sus campos de entrada correspondientes tendrían una validación que los hace obligatorios.

Al igual que SignupComponent , los botones del carrito, inicio de sesión y cierre de sesión en el encabezado están ocultos. Su visibilidad se establece mediante HeaderService durante la inicialización del componente.

Para iniciar sesión, las credenciales se pasan al AuthenticationService . Si tiene éxito, el estado de inicio de sesión del usuario se establece mediante SessionService . Luego, el usuario es redirigido a la página en la que estaba. Si no tiene éxito, se muestra una barra de refrigerios con un error y se restablece el campo de contraseña.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-login', templateUrl: './login.component.html', styleUrls: ['./login.component.css'] }) export class LoginComponent implements OnInit { loginForm = this.fb.group({ email: ['', Validators.required], password: ['', Validators.required] }); constructor( private authService: AuthenticationService, private session: SessionService, private snackBar: MatSnackBar, private fb: FormBuilder, private header: HeaderService, private location: Location ) { } ngOnInit() { this.header.setHeaderButtonsVisibility(false); } login() { const credentials = this.loginForm.value; this.authService.login( credentials.email, credentials.password ).subscribe( () => { this.session.setLoggedInStatus(true); this.location.back(); }, err => { this.snackBar.open( 'Login failed. Check your login credentials.', 'Close', { duration: 6000 }); this.loginForm.patchValue({ password: '' }); } ); } }

A continuación se muestra la plantilla LoginComponent .

 <form [formGroup]="loginForm" (ngSubmit)="login()"> <h1 class="mat-display-3">Login</h1> <mat-form-field appearance="outline"> <mat-label>Email</mat-label> <input matInput type="email" formControlName="email" required> <mat-icon matPrefix>alternate_email</mat-icon> </mat-form-field> <mat-form-field appearance="outline"> <mat-label>Password</mat-label> <input matInput type="password" formControlName="password" required> <mat-icon matPrefix>vpn_key</mat-icon> </mat-form-field> <button mat-flat-button color="primary" [disabled]="!loginForm.valid">Login</button> <p class="mat-h3">Not registered yet? <a routerLink="/signup">Create an account.</a></p> </form>

Aquí hay una captura de pantalla de la página.

Captura de pantalla de la página de inicio de sesión
Captura de pantalla de la página de inicio de sesión. (Vista previa grande)

Módulo de carro

El módulo del carrito contiene todas las páginas relacionadas con el carrito. Estos incluyen la página de resumen del pedido, una página de códigos de cupones y tarjetas de regalo, y una página de carrito vacía. Está estructurado de la siguiente manera.

 src/app/features/cart/ ├── cart.module.ts └── pages ├── codes │ ├── codes.component.css │ ├── codes.component.html │ └── codes.component.ts ├── empty │ ├── empty.component.css │ ├── empty.component.html │ └── empty.component.ts └── summary ├── summary.component.css ├── summary.component.html └── summary.component.ts

Para generarlo, ejecuta:

 ng gm features/cart ng gc features/cart/codes ng gc features/cart/empty ng gc features/cart/summary

Este es el archivo del módulo.

 @NgModule({ declarations: [SummaryComponent, CodesComponent, EmptyComponent], imports: [ RouterModule.forChild([ { path: '', canActivate: [EmptyCartGuard], children: [ { path: 'cart', component: SummaryComponent }, { path: 'codes', component: CodesComponent } ] }, { path: 'empty', component: EmptyComponent } ]), MatDividerModule, MatFormFieldModule, MatInputModule, MatMenuModule, ReactiveFormsModule, SharedModule ] }) export class CartModule { }

Componente de códigos

Como se mencionó anteriormente, este componente se usa para agregar cualquier código de cupón o tarjeta de regalo a un pedido. Esto permite al usuario aplicar descuentos al total de su pedido antes de proceder al pago.

Habrá dos campos de entrada. Uno para cupones y otro para códigos de tarjetas de regalo.

Los códigos se añaden actualizando el pedido. El método updateOrder de OrderService actualiza el pedido con los códigos. Después de lo cual, ambos campos se reinician y se informa al usuario del éxito de la operación con una barra de bocadillos. También se muestra una barra de bocadillos cuando ocurre un error. Los métodos addCoupon y addGiftCard llaman al método updateOrder .

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-codes', templateUrl: './codes.component.html', styleUrls: ['./codes.component.css'] }) export class CodesComponent { couponCode = new FormControl(''); giftCardCode = new FormControl(''); @ViewChild(FormControlDirective) codesDirective: FormControlDirective | undefined; constructor( private cart: CartService, private order: OrderService, private snackBar: MatSnackBar ) { } private updateOrder(order: Order, params: UpdateOrderParams[], codeType: string) { this.order.updateOrder(order, params) .subscribe( () => { this.snackBar.open(`Successfully added ${codeType} code.`, 'Close', { duration: 8000 }); this.couponCode.reset(); this.giftCardCode.reset(); this.codesDirective?.reset(); }, err => this.snackBar.open(`There was a problem adding your ${codeType} code.`, 'Close', { duration: 8000 }) ); } addCoupon() { this.updateOrder({ id: this.cart.orderId, couponCode: this.couponCode.value }, [UpdateOrderParams.couponCode], 'coupon'); } addGiftCard() { this.updateOrder({ id: this.cart.orderId, giftCardCode: this.giftCardCode.value }, [UpdateOrderParams.giftCardCode], 'gift card'); } }

La plantilla se muestra a continuación y su estilo se puede encontrar en este enlace.

 <div> <app-title title="Redeem a code" subtitle="Enter a coupon code or gift card" [centerText]="true"></app-title> <div class="input-row"> <mat-form-field appearance="outline"> <mat-label>Coupon Code</mat-label> <input matInput [formControl]="couponCode" required> <mat-icon matPrefix>card_giftcard</mat-icon> </mat-form-field> <button class="redeem" mat-flat-button color="accent" [disabled]="couponCode.invalid" (click)="addCoupon()">Redeem</button> </div> <div class="input-row"> <mat-form-field appearance="outline"> <mat-label>Gift Card Code</mat-label> <input matInput [formControl]="giftCardCode" required> <mat-icon matPrefix>redeem</mat-icon> </mat-form-field> <button class="redeem" mat-flat-button color="accent" [disabled]="giftCardCode.invalid" (click)="addGiftCard()">Redeem</button> </div> <button color="primary" mat-flat-button routerLink="/cart"> <mat-icon>shopping_cart</mat-icon> CONTINUE TO CART </button> </div>

Aquí hay una captura de pantalla de la página.

Captura de pantalla de la página de códigos
Captura de pantalla de la página de códigos. (Vista previa grande)

Componente vacío

No debería ser posible pagar con un carrito vacío. Debe haber un protector que evite que los usuarios accedan a las páginas del módulo de pago con carritos vacíos. Esto ya se ha cubierto como parte del CoreModule . El guardia redirige las solicitudes a las páginas de pago con un carrito vacío al EmptyCartComponent .

Es un componente muy simple que tiene un texto que le indica al usuario que su carrito está vacío. También tiene un botón en el que el usuario puede hacer clic para ir a la página de inicio y agregar cosas a su carrito. Así que usaremos SimplePageComponent para mostrarlo. Aquí está la plantilla.

 <app-simple-page title="Your cart is empty" subtitle="There is currently nothing in your cart. Head to the home page to add items." buttonText="GO TO HOME PAGE" icon="shopping_basket" [centerText]="true" route="/"> </app-simple-page>

Aquí hay una captura de pantalla de la página.

Captura de pantalla de la página del carrito vacío
Captura de pantalla de la página del carrito vacío. (Vista previa grande)

Componente de resumen

Este componente resume el carrito/pedido. Enumera todos los artículos en el carrito, sus nombres, cantidades e imágenes. Además, desglosa el costo del pedido, incluidos impuestos, envío y descuentos. El usuario debería poder ver esto y decidir si está satisfecho con los artículos y el costo antes de proceder al pago.

En la inicialización, el pedido y sus elementos de línea se recuperan mediante OrderService . Un usuario debería poder modificar las líneas de pedido o incluso eliminarlas del pedido. Los elementos se eliminan cuando se llama al método deleteLineItem . En él, el método deleteLineItem de LineItemService recibe el id de la línea de pedido que se va a eliminar. Si una eliminación es exitosa, actualizamos el recuento de artículos en el carrito usando CartService .

Luego, el usuario es dirigido a la página del cliente donde comienza el proceso de pago. El método de checkout hace el enrutamiento.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-summary', templateUrl: './summary.component.html', styleUrls: ['./summary.component.css'] }) export class SummaryComponent implements OnInit { order: Order = {}; summary: { name: string, amount: string | undefined, id: string }[] = []; constructor( private orders: OrderService, private lineItems: LineItemService, private cart: CartService, private snackBar: MatSnackBar, private router: Router ) { } ngOnInit() { this.orders.getOrder(this.cart.orderId, GetOrderParams.cart) .subscribe( order => this.processOrder(order), err => this.showOrderError('retrieving your cart') ); } private processOrder(order: Order) { this.order = order; this.summary = [ { name: 'Subtotal', amount: order.formattedSubtotalAmount, id: 'subtotal' }, { name: 'Discount', amount: order.formattedDiscountAmount, id: 'discount' }, { name: 'Taxes (included)', amount: order.formattedTotalTaxAmount, id: 'taxes' }, { name: 'Shipping', amount: order.formattedShippingAmount, id: 'shipping' }, { name: 'Gift Card', amount: order.formattedGiftCardAmount, id: 'gift-card' } ]; } private showOrderError(msg: string) { this.snackBar.open(`There was a problem ${msg}.`, 'Close', { duration: 8000 }); } checkout() { this.router.navigateByUrl('/customer'); } deleteLineItem(id: string) { this.lineItems.deleteLineItem(id) .pipe( mergeMap(() => this.orders.getOrder(this.cart.orderId, GetOrderParams.cart)) ).subscribe( order => { this.processOrder(order); this.cart.itemCount = order.skusCount || this.cart.itemCount; this.snackBar.open(`Item successfully removed from cart.`, 'Close', { duration: 8000 }) }, err => this.showOrderError('deleting your order') ); } }

A continuación se muestra la plantilla y su estilo está vinculado aquí.

 <div class="container" *ngIf="order"> <h3>Order #{{order.number}} ({{order.skusCount}} items)</h3> <div class="line-item" *ngFor="let item of order.lineItems"> <div> <img *ngIf="item.imageUrl" class="image-xs" src="{{item.imageUrl}}" alt="product photo"> <div *ngIf="!item.imageUrl" class="image-xs no-image"></div> <div> <div>{{item.name}}</div> <div> {{item.formattedUnitAmount }} </div> </div> </div> <div> <app-item-quantity [quantity]="item.quantity || 0" [disabled]="true"></app-item-quantity> <div class="itemTotal"> {{item.formattedTotalAmount }} </div> <button mat-icon-button color="warn" (click)="deleteLineItem(item.id || '')"> <mat-icon>clear</mat-icon> </button> </div> </div> <mat-divider></mat-divider> <div class="costSummary"> <div class="costItem" *ngFor="let item of summary" [id]="item.id"> <h3 class="costLabel">{{item.name}}</h3> <p> {{item.amount }} </p> </div> </div> <mat-divider></mat-divider> <div class="costSummary"> <div class="costItem"> <h2>Total</h2> <h2> {{order.formattedTotalAmountWithTaxes}} </h2> </div> </div> <div> <button color="accent" mat-flat-button routerLink="/codes"> <mat-icon>redeem</mat-icon> ADD GIFT CARD/COUPON </button> <button color="primary" mat-flat-button (click)="checkout()"> <mat-icon>point_of_sale</mat-icon> CHECKOUT </button> </div> </div>

Aquí hay una captura de pantalla de la página.

Captura de pantalla de la página de resumen
Captura de pantalla de la página de resumen. (Vista previa grande)

Módulo de pago

Este módulo es responsable del proceso de pago. El pago implica proporcionar una dirección de facturación y envío, un correo electrónico del cliente y seleccionar un método de envío y pago. El último paso de este proceso es la colocación y confirmación del pedido. La estructura del módulo es la siguiente.

 src/app/features/checkout/ ├── components │ ├── address │ ├── address-list │ └── country-select └── pages ├── billing-address ├── cancel-payment ├── customer ├── payment ├── place-order ├── shipping-address └── shipping-methods

Este módulo es el más grande con diferencia y contiene 3 componentes y 7 páginas. Para generarlo y sus componentes ejecuta:

 ng gm features/checkout for comp in \ address address-list country-select; do \ ng gc "features/checkout/components/${comp}" \ ; done for page in \ billing-address cancel-payment customer \ payment place-order shipping-address \ shipping-methods; do \ ng gc "features/checkout/pages/${page}"; done

Este es el archivo del módulo.

 @NgModule({ declarations: [ CustomerComponent, AddressComponent, BillingAddressComponent, ShippingAddressComponent, ShippingMethodsComponent, PaymentComponent, PlaceOrderComponent, AddressListComponent, CountrySelectComponent, CancelPaymentComponent ], imports: [ RouterModule.forChild([ { path: '', canActivate: [EmptyCartGuard], children: [ { path: 'billing-address', component: BillingAddressComponent }, { path: 'cancel-payment', component: CancelPaymentComponent }, { path: 'customer', component: CustomerComponent }, { path: 'payment', component: PaymentComponent }, { path: 'place-order', component: PlaceOrderComponent }, { path: 'shipping-address', component: ShippingAddressComponent }, { path: 'shipping-methods', component: ShippingMethodsComponent } ] } ]), MatCardModule, MatCheckboxModule, MatDividerModule, MatInputModule, MatMenuModule, MatRadioModule, ReactiveFormsModule, SharedModule ] }) export class CheckoutModule { }

Componentes

Componente de selección de país

Este componente permite que un usuario seleccione un país como parte de una dirección. El componente de selección de material tiene una apariencia bastante diferente en comparación con los campos de entrada en el formulario de dirección. Entonces, en aras de la uniformidad, se usa un componente de menú de material en su lugar.

Cuando se inicializa el componente, los datos del código de país se obtienen mediante CountryService . La propiedad de los countries contiene los valores devueltos por el servicio. Estos valores se agregarán al menú en la plantilla.

El componente tiene una propiedad de salida, setCountryEvent . Cuando se selecciona un país, este evento emite el código alfa-2 del país.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-country-select', templateUrl: './country-select.component.html', styleUrls: ['./country-select.component.css'] }) export class CountrySelectComponent implements OnInit { country: string = 'Country'; countries: Country[] = []; @Output() setCountryEvent = new EventEmitter<string>(); constructor(private countries: CountryService) { } ngOnInit() { this.countries.getCountries() .subscribe( countries => { this.countries = countries; } ); } setCountry(value: Country) { this.country = value.name || ''; this.setCountryEvent.emit(value.code); }}

A continuación se muestra su plantilla y el enlace aquí es su estilo.

 <button mat-stroked-button [matMenuTriggerFor]="countryMenu"> {{country}} <mat-icon>expand_more</mat-icon> </button> <mat-menu #countryMenu="matMenu"> <button *ngFor="let cnt of countries" (click)="setCountry(cnt)" mat-menu-item>{{cnt.name}}</button> </mat-menu>

Componente de dirección

Este es un formulario para capturar direcciones. Lo utilizan las páginas de dirección de envío y de facturación. Una dirección válida de Commerce Layer debe contener un nombre y apellido, una línea de dirección, una ciudad, un código postal, un código de estado, un código de país y un número de teléfono.

El servicio FormBuilder creará el grupo de formularios. Dado que este componente es utilizado por varias páginas, tiene una serie de propiedades de entrada y salida. Las propiedades de entrada incluyen el texto del botón, el título que se muestra y el texto de una casilla de verificación. Las propiedades de salida serán emisores de eventos para cuando se haga clic en el botón para crear la dirección y otro para cuando cambie el valor de la casilla de verificación.

Cuando se hace clic en el botón, se llama al método addAddress y el evento createAddress emite la dirección completa. De manera similar, cuando la casilla de verificación está marcada, el evento isCheckboxChecked emite el valor de la casilla de verificación.

 @Component({ selector: 'app-address', templateUrl: './address.component.html', styleUrls: ['./address.component.css'] }) export class AddressComponent { @Input() buttonText: string = ''; @Input() showTitle?: boolean = false; @Output() createAddress = new EventEmitter<Address>(); @Input() checkboxText: string = ''; @Output() isCheckboxChecked = new EventEmitter<boolean>(); countryCode: string = ''; addressForm = this.fb.group({ firstName: [''], lastName: [''], line1: [''], city: [''], zipCode: [''], stateCode: [''], phone: [''] }); @ViewChild(FormGroupDirective) afDirective: FormGroupDirective | undefined; constructor(private fb: FormBuilder) { } setCountryCode(code: string) { this.countryCode = code; } addAddress() { this.createAddress.emit({ firstName: this.addressForm.get('firstName')?.value, lastName: this.addressForm.get('lastName')?.value, line1: this.addressForm.get('line1')?.value, city: this.addressForm.get('city')?.value, zipCode: this.addressForm.get('zipCode')?.value, stateCode: this.addressForm.get('stateCode')?.value || 'N/A', countryCode: this.countryCode, phone: this.addressForm.get('phone')?.value }); } setCheckboxValue(change: MatCheckboxChange) { if (this.isCheckboxChecked) { this.isCheckboxChecked.emit(change.checked); } } }

This is its template and its styling is linked here.

 <form [formGroup]="addressForm"> <p class="mat-headline" *ngIf="showTitle">Or add a new address</p> <div class="row"> <mat-form-field appearance="outline"> <mat-label>First Name</mat-label> <input matInput formControlName="firstName"> </mat-form-field> <mat-form-field appearance="outline"> <mat-label>Last Name</mat-label> <input matInput formControlName="lastName"> </mat-form-field> </div> <div class="row"> <mat-form-field appearance="outline"> <mat-label>Address</mat-label> <input matInput formControlName="line1"> </mat-form-field> <mat-form-field appearance="outline"> <mat-label>City</mat-label> <input matInput formControlName="city"> </mat-form-field> </div> <div class="row"> <mat-form-field appearance="outline"> <mat-label>State Code</mat-label> <input matInput formControlName="stateCode"> </mat-form-field> <mat-form-field appearance="outline"> <mat-label>Zip Code</mat-label> <input matInput formControlName="zipCode"> </mat-form-field> </div> <div class="row"> <mat-form-field appearance="outline"> <mat-label>Phone</mat-label> <input matInput formControlName="phone"> </mat-form-field> <app-country-select (setCountryEvent)="setCountryCode($event)"></app-country-select> </div> <mat-checkbox color="accent" (change)="setCheckboxValue($event)"> {{checkboxText}} </mat-checkbox> <button mat-flat-button color="primary" (click)="addAddress()"> {{buttonText}} </button> </form>

Address List Component

When a customer logs in, they can access their existing addresses. Instead of having them re-enter an address, they can pick from an address list. This is the purpose of this component. On initialization, all the customer's addresses are fetched using the CustomerAddressService if they are logged in. We will check their login status using the SessionService .

This component has a setAddressEvent output property. When an address is selected, setAddressEvent emits its id to the parent component.

@Component({ selector: 'app-address-list', templateUrl: './address-list.component.html', styleUrls: ['./address-list.component.css'] }) export class AddressListComponent implements OnInit { addresses: CustomerAddress[] = []; @Output() setAddressEvent = new EventEmitter<string>(); constructor( private session: SessionService, private customerAddresses: CustomerAddressService, private snackBar: MatSnackBar ) { } ngOnInit() { this.session.loggedInStatus .pipe( mergeMap( status => iif(() => status, this.customerAddresses.getCustomerAddresses()) )) .subscribe( addresses => { if (addresses.length) { this.addresses = addresses } }, err => this.snackBar.open('There was a problem getting your existing addresses.', 'Close', { duration: 8000 }) ); } setAddress(change: MatRadioChange) { this.setAddressEvent.emit(change.value); } }

Aquí está su plantilla. Puedes encontrar su estilo aquí.

 <div> <p class="mat-headline">Pick an existing address</p> <mat-error *ngIf="!addresses.length">You have no existing addresses</mat-error> <mat-radio-group *ngIf="addresses.length" class="addresses" (change)="setAddress($event)"> <mat-card class="address" *ngFor="let address of addresses"> <mat-radio-button [value]="address.address?.id" color="primary"> <p>{{address.address?.firstName}} {{address.address?.lastName}},</p> <p>{{address.address?.line1}},</p> <p>{{address.address?.city}},</p> <p>{{address.address?.zipCode}},</p> <p>{{address.address?.stateCode}}, {{address.address?.countryCode}}</p> <p>{{address.address?.phone}}</p> </mat-radio-button> </mat-card> </mat-radio-group> </div>

Paginas

Componente de cliente

Un pedido debe estar asociado a una dirección de correo electrónico. Este componente es un formulario que captura la dirección de correo electrónico del cliente. Cuando se inicializa el componente, se obtiene la dirección de correo electrónico del cliente actual si está conectado. Obtenemos el cliente de CustomerService . Si no desean cambiar su dirección de correo electrónico, este correo electrónico será el valor predeterminado.

Si se cambia el correo electrónico o un cliente no ha iniciado sesión, el pedido se actualiza con el correo electrónico ingresado. Utilizamos OrderService para actualizar el pedido con la nueva dirección de correo electrónico. Si tiene éxito, dirigimos al cliente a la página de dirección de facturación.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-customer', templateUrl: './customer.component.html', styleUrls: ['./customer.component.css'] }) export class CustomerComponent implements OnInit { email = new FormControl('', [Validators.required, Validators.email]); constructor( private orders: OrderService, private customers: CustomerService, private cart: CartService, private router: Router, private snackBar: MatSnackBar ) { } ngOnInit() { this.customers.getCurrentCustomer() .subscribe( customer => this.email.setValue(customer.email) ); } addCustomerEmail() { this.orders.updateOrder( { id: this.cart.orderId, customerEmail: this.email.value }, [UpdateOrderParams.customerEmail]) .subscribe( () => this.router.navigateByUrl('/billing-address'), err => this.snackBar.open('There was a problem adding your email to the order.', 'Close', { duration: 8000 }) ); } }

Aquí está la plantilla del componente y enlazada aquí está su estilo.

 <div> <app-title no="1" title="Customer" subtitle="Billing information and shipping address"></app-title> <mat-form-field appearance="outline"> <mat-label>Email</mat-label> <input matInput [formControl]="email" required> <mat-icon matPrefix>alternate_email</mat-icon> </mat-form-field> <button mat-flat-button color="primary" [disabled]="email.invalid" (click)="addCustomerEmail()"> PROCEED TO BILLING ADDRESS </button> </div>

Aquí hay una captura de pantalla de la página del cliente.

Captura de pantalla de la página del cliente
Captura de pantalla de la página del cliente. (Vista previa grande)

Componente de dirección de facturación

El componente de dirección de facturación le permite a un cliente agregar una nueva dirección de facturación o elegir entre sus direcciones existentes. Los usuarios que no hayan iniciado sesión deben ingresar una nueva dirección. Aquellos que han iniciado sesión tienen la opción de elegir entre direcciones nuevas o existentes.

La propiedad showAddress indica si las direcciones existentes deben mostrarse en el componente. sameShippingAddressAsBilling indica si la dirección de envío debe ser la misma que la dirección de facturación establecida. Cuando un cliente selecciona una dirección existente, su ID se asigna a selectedCustomerAddressId .

Cuando se inicializa el componente, usamos SessionService para verificar si el usuario actual está conectado. Si está conectado, mostraremos sus direcciones existentes, si las tienen.

Como se mencionó anteriormente, si un usuario ha iniciado sesión, puede elegir una dirección existente como su dirección de facturación. En el método updateBillingAddress , si están conectados, la dirección que seleccionan se clona y se establece como la dirección de facturación del pedido. Para ello, actualizamos el pedido mediante el método updateOrder de OrderService y proporcionamos el Id. de la dirección.

Si no han iniciado sesión, el usuario debe proporcionar una dirección. Una vez proporcionada, la dirección se crea mediante el método createAddress . En él, AddressService toma la entrada y crea la nueva dirección. Después de lo cual, el pedido se actualiza utilizando la identificación de la dirección recién creada. Si hay un error o cualquiera de las operaciones es exitosa, mostramos una barra de bocadillos.

Si se selecciona la misma dirección como dirección de envío, se dirige al usuario a la página de métodos de envío. Si desean proporcionar una dirección de envío alternativa, se les dirige a la página de dirección de envío.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-billing-address', templateUrl: './billing-address.component.html', styleUrls: ['./billing-address.component.css'] }) export class BillingAddressComponent implements OnInit { showAddresses: boolean = false; sameShippingAddressAsBilling: boolean = false; selectedCustomerAddressId: string = ''; constructor( private addresses: AddressService, private snackBar: MatSnackBar, private session: SessionService, private orders: OrderService, private cart: CartService, private router: Router, private customerAddresses: CustomerAddressService) { } ngOnInit() { this.session.loggedInStatus .subscribe( status => this.showAddresses = status ); } updateBillingAddress(address: Address) { if (this.showAddresses && this.selectedCustomerAddressId) { this.cloneAddress(); } else if (address.firstName && address.lastName && address.line1 && address.city && address.zipCode && address.stateCode && address.countryCode && address.phone) { this.createAddress(address); } else { this.snackBar.open('Check your address. Some fields are missing.', 'Close'); } } setCustomerAddress(customerAddressId: string) { this.selectedCustomerAddressId = customerAddressId; } setSameShippingAddressAsBilling(change: boolean) { this.sameShippingAddressAsBilling = change; } private createAddress(address: Address) { this.addresses.createAddress(address) .pipe( concatMap( address => { const update = this.updateOrderObservable({ id: this.cart.orderId, billingAddressId: address.id }, [UpdateOrderParams.billingAddress]); if (this.showAddresses) { return combineLatest([update, this.customerAddresses.createCustomerAddress(address.id || '', '')]); } else { return update; } })) .subscribe( () => this.showSuccessSnackBar(), err => this.showErrorSnackBar() ); } private cloneAddress() { this.updateOrderObservable({ id: this.cart.orderId, billingAddressCloneId: this.selectedCustomerAddressId }, [UpdateOrderParams.billingAddressClone]) .subscribe( () => this.showSuccessSnackBar(), err => this.showErrorSnackBar() ); } private updateOrderObservable(order: Order, updateParams: UpdateOrderParams[]): Observable<any> { return iif(() => this.sameShippingAddressAsBilling, concat([ this.orders.updateOrder(order, updateParams), this.orders.updateOrder(order, [UpdateOrderParams.shippingAddressSameAsBilling]) ]), this.orders.updateOrder(order, updateParams) ); } private showErrorSnackBar() { this.snackBar.open('There was a problem creating your address.', 'Close', { duration: 8000 }); } private navigateTo(path: string) { setTimeout(() => this.router.navigateByUrl(path), 4000); } private showSuccessSnackBar() { this.snackBar.open('Billing address successfully added. Redirecting...', 'Close', { duration: 3000 }); if (this.sameShippingAddressAsBilling) { this.navigateTo('/shipping-methods'); } else { this.navigateTo('/shipping-address'); } } }

Aquí está la plantilla. Este enlace apunta a su estilo.

 <app-title no="2" title="Billing Address" subtitle="Address to bill charges to"></app-title> <app-address-list *ngIf="showAddresses" (setAddressEvent)="setCustomerAddress($event)"></app-address-list> <mat-divider *ngIf="showAddresses"></mat-divider> <app-address [showTitle]="showAddresses" buttonText="PROCEED TO NEXT STEP" checkboxText="Ship to the same address" (isCheckboxChecked)="setSameShippingAddressAsBilling($event)" (createAddress)="updateBillingAddress($event)"></app-address>

Así es como se verá la página de la dirección de facturación.

Captura de pantalla de la página de dirección de facturación
Captura de pantalla de la página de dirección de facturación. (Vista previa grande)

Componente de dirección de envío

El componente de la dirección de envío se comporta de forma muy parecida al componente de la dirección de facturación. Sin embargo, hay un par de diferencias. Por un lado, el texto que se muestra en la plantilla es diferente. Las otras diferencias clave están en cómo se actualiza el pedido mediante OrderService una vez que se crea o selecciona una dirección. Los campos que actualiza el pedido son shippingAddressCloneId para las direcciones seleccionadas y shippingAddress para las nuevas direcciones. Si un usuario elige cambiar la dirección de facturación para que sea la misma que la dirección de envío, se actualiza el campo billingAddressSameAsShipping .

Después de seleccionar una dirección de envío y actualizar el pedido, se dirige al usuario a la página de métodos de envío.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-shipping-address', templateUrl: './shipping-address.component.html', styleUrls: ['./shipping-address.component.css'] }) export class ShippingAddressComponent implements OnInit { showAddresses: boolean = false; sameBillingAddressAsShipping: boolean = false; selectedCustomerAddressId: string = ''; constructor( private addresses: AddressService, private snackBar: MatSnackBar, private session: SessionService, private orders: OrderService, private cart: CartService, private router: Router, private customerAddresses: CustomerAddressService) { } ngOnInit() { this.session.loggedInStatus .subscribe( status => this.showAddresses = status ); } updateShippingAddress(address: Address) { if (this.showAddresses && this.selectedCustomerAddressId) { this.cloneAddress(); } else if (address.firstName && address.lastName && address.line1 && address.city && address.zipCode && address.stateCode && address.countryCode && address.phone) { this.createAddress(address); } else { this.snackBar.open('Check your address. Some fields are missing.', 'Close'); } } setCustomerAddress(customerAddressId: string) { this.selectedCustomerAddressId = customerAddressId; } setSameBillingAddressAsShipping(change: boolean) { this.sameBillingAddressAsShipping = change; } private createAddress(address: Address) { this.addresses.createAddress(address) .pipe( concatMap( address => { const update = this.updateOrderObservable({ id: this.cart.orderId, shippingAddressId: address.id }, [UpdateOrderParams.shippingAddress]); if (this.showAddresses) { return combineLatest([update, this.customerAddresses.createCustomerAddress(address.id || '', '')]); } else { return update; } })) .subscribe( () => this.showSuccessSnackBar(), err => this.showErrorSnackBar() ); } private cloneAddress() { this.updateOrderObservable({ id: this.cart.orderId, shippingAddressCloneId: this.selectedCustomerAddressId }, [UpdateOrderParams.shippingAddressClone]) .subscribe( () => this.showSuccessSnackBar(), err => this.showErrorSnackBar() ); } private updateOrderObservable(order: Order, updateParams: UpdateOrderParams[]): Observable<any> { return iif(() => this.sameBillingAddressAsShipping, concat([ this.orders.updateOrder(order, updateParams), this.orders.updateOrder(order, [UpdateOrderParams.billingAddressSameAsShipping]) ]), this.orders.updateOrder(order, updateParams) ); } private showErrorSnackBar() { this.snackBar.open('There was a problem creating your address.', 'Close', { duration: 8000 }); } private showSuccessSnackBar() { this.snackBar.open('Shipping address successfully added. Redirecting...', 'Close', { duration: 3000 }); setTimeout(() => this.router.navigateByUrl('/shipping-methods'), 4000); } }

Aquí está la plantilla y su estilo se puede encontrar aquí.

 <app-title no="3" title="Shipping Address" subtitle="Address to ship package to"></app-title> <app-address-list *ngIf="showAddresses" (setAddressEvent)="setCustomerAddress($event)"></app-address-list> <mat-divider *ngIf="showAddresses"></mat-divider> <app-address [showTitle]="showAddresses" buttonText="PROCEED TO SHIPPING METHODS" checkboxText="Bill to the same address" (isCheckboxChecked)="setSameBillingAddressAsShipping($event)" (createAddress)="updateShippingAddress($event)"></app-address>

La página de la dirección de envío se verá así.

Captura de pantalla de la página de dirección de envío
Captura de pantalla de la página de dirección de envío. (Vista previa grande)

Componente Métodos de envío

Este componente muestra la cantidad de envíos necesarios para que se complete un pedido, los métodos de envío disponibles y sus costos asociados. A continuación, el cliente puede seleccionar el método de envío que prefiera para cada envío.

La propiedad de shipments contiene todos los envíos del pedido. El formulario de shipmentsForm es el formulario dentro del cual se realizarán las selecciones del método de envío.

Cuando se inicializa el componente, se obtiene el pedido y contendrá tanto sus artículos de línea como sus envíos. Al mismo tiempo, obtenemos los plazos de entrega para los distintos métodos de envío. Usamos OrderService para obtener el pedido y DeliveryLeadTimeService para los plazos de entrega. Una vez que se devuelven ambos conjuntos de información, se combinan en una matriz de envíos y se asignan a la propiedad de shipments . Cada envío contendrá sus artículos, los métodos de envío disponibles y el costo correspondiente.

Después de que el usuario haya seleccionado un método de envío para cada envío, el método de envío seleccionado se actualiza para cada uno en setShipmentMethods . Si tiene éxito, se dirige al usuario a la página de pagos.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-shipping-methods', templateUrl: './shipping-methods.component.html', styleUrls: ['./shipping-methods.component.css'] }) export class ShippingMethodsComponent implements OnInit { shipments: Shipment[] | undefined = []; shipmentsForm: FormGroup = this.fb.group({}); constructor( private orders: OrderService, private dlts: DeliveryLeadTimeService, private cart: CartService, private router: Router, private fb: FormBuilder, private shipments: ShipmentService, private snackBar: MatSnackBar ) { } ngOnInit() { combineLatest([ this.orders.getOrder(this.cart.orderId, GetOrderParams.shipments), this.dlts.getDeliveryLeadTimes() ]).subscribe( ([lineItems, deliveryLeadTimes]) => { let li: LineItem; let lt: DeliveryLeadTime[]; this.shipments = lineItems.shipments?.map((shipment) => { if (shipment.id) { this.shipmentsForm.addControl(shipment.id, new FormControl('', Validators.required)); } if (shipment.lineItems) { shipment.lineItems = shipment.lineItems.map(item => { li = this.findItem(lineItems, item.skuCode || ''); item.imageUrl = li.imageUrl; item.name = li.name; return item; }); } if (shipment.availableShippingMethods) { lt = this.findLocationLeadTime(deliveryLeadTimes, shipment); shipment.availableShippingMethods = shipment.availableShippingMethods?.map( method => { method.deliveryLeadTime = this.findMethodLeadTime(lt, method); return method; }); } return shipment; }); }, err => this.router.navigateByUrl('/error') ); } setShipmentMethods() { const shipmentsFormValue = this.shipmentsForm.value; combineLatest(Object.keys(shipmentsFormValue).map( key => this.shipments.updateShipment(key, shipmentsFormValue[key]) )).subscribe( () => { this.snackBar.open('Your shipments have been updated with a shipping method.', 'Close', { duration: 3000 }); setTimeout(() => this.router.navigateByUrl('/payment'), 4000); }, err => this.snackBar.open('There was a problem adding shipping methods to your shipments.', 'Close', { duration: 5000 }) ); } private findItem(lineItems: LineItem[], skuCode: string): LineItem { return lineItems.filter((item) => item.skuCode == skuCode)[0]; } private findLocationLeadTime(times: DeliveryLeadTime[], shipment: Shipment): DeliveryLeadTime[] { return times.filter((dlTime) => dlTime?.stockLocation?.id == shipment?.stockLocation?.id); } private findMethodLeadTime(times: DeliveryLeadTime[], method: ShippingMethod): DeliveryLeadTime { return times.filter((dlTime) => dlTime?.shippingMethod?.id == method?.id)[0]; } }

Aquí está la plantilla y puede encontrar el estilo en este enlace.

 <form [formGroup]="shipmentsForm"> <app-title no="4" title="Shipping Methods" subtitle="How to ship your packages"></app-title> <div class="shipment-container" *ngFor="let shipment of shipments; let j = index; let isLast = last"> <h1>Shipment {{j+1}} of {{shipments?.length}}</h1> <div class="row" *ngFor="let item of shipment.lineItems"> <img class="image-xs" [src]="item.imageUrl" alt="product photo"> <div> <h4>{{item.name}}</h4> <p>{{item.skuCode}}</p> </div> <div> <p>Quantity: </p>{{item.quantity}} </div> </div> <mat-radio-group [formControlName]="shipment?.id || j"> <mat-radio-button *ngFor="let method of shipment.availableShippingMethods" [value]="method.id"> <div class="radio-button"> <p>{{method.name}}</p> <div> <p class="radio-label">Cost:</p> <p> {{method.formattedPriceAmount}}</p> </div> <div> <p class="radio-label">Timeline:</p> <p> Available in {{method.deliveryLeadTime?.minDays}}-{{method.deliveryLeadTime?.maxDays}} days</p> </div> </div> </mat-radio-button> </mat-radio-group> <mat-divider *ngIf="!isLast"></mat-divider> </div> <button mat-flat-button color="primary" [disabled]="shipmentsForm.invalid" (click)="setShipmentMethods()">PROCEED TO PAYMENT</button> </form>

Esta es una captura de pantalla de la página de métodos de envío.

Captura de pantalla de la página de métodos de envío
Captura de pantalla de la página de métodos de envío. (Vista previa grande)

Componente de pagos

En este componente, el usuario pulsa el botón de pago si desea proceder a pagar su pedido con Paypal. La URL de approvalUrl es el enlace de Paypal al que se dirige al usuario cuando hace clic en el botón.

Durante la inicialización, obtenemos el pedido con la fuente de pago incluida mediante OrderService . Si se establece una fuente de pago, obtenemos su id y recuperamos el pago de Paypal correspondiente de PaypalPaymentService . El pago de Paypal contendrá la URL de aprobación. Si no se ha establecido una fuente de pago, actualizamos el pedido con Paypal como método de pago preferido. Luego procedemos a crear un nuevo pago de Paypal para el pedido utilizando PaypalPaymentService . Desde aquí, podemos obtener la URL de aprobación del pedido recién creado.

Por último, cuando el usuario hace clic en el botón, se le redirige a Paypal, donde puede aprobar la compra.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-payment', templateUrl: './payment.component.html', styleUrls: ['./payment.component.css'] }) export class PaymentComponent implements OnInit { approvalUrl: string = ''; constructor( private orders: OrderService, private cart: CartService, private router: Router, private payments: PaypalPaymentService ) { } ngOnInit() { const orderId = this.cart.orderId; this.orders.getOrder(orderId, GetOrderParams.paymentSource) .pipe( concatMap((order: Order) => { const paymentSourceId = order.paymentSource?.id; const paymentMethod = order.availablePaymentMethods?.filter( (method) => method.paymentSourceType == 'paypal_payments' )[0]; return iif( () => paymentSourceId ? true : false, this.payments.getPaypalPayment(paymentSourceId || ''), this.orders.updateOrder({ id: orderId, paymentMethodId: paymentMethod?.id }, [UpdateOrderParams.paymentMethod]) .pipe(concatMap( order => this.payments.createPaypalPayment({ orderId: orderId, cancelUrl: `${environment.clientUrl}/cancel-payment`, returnUrl: `${environment.clientUrl}/place-order` }) )) ); })) .subscribe( paypalPayment => this.approvalUrl = paypalPayment?.approvalUrl || '', err => this.router.navigateByUrl('/error') ); } navigateToPaypal() { window.location.href = this.approvalUrl; } }

Aquí está su plantilla.

 <app-simple-page number="5" title="Payment" subtitle="Pay for your order" buttonText="PROCEED TO PAY WITH PAYPAL" icon="point_of_sale" (buttonEvent)="navigateToPaypal()" [buttonDisabled]="approvalUrl.length ? false : true"></app-simple-page>

Así es como se verá la página de pagos.

Captura de pantalla de la página de pago
Captura de pantalla de la página de pago. (Vista previa grande)

Cancelar componente de pago

Paypal requiere una página de cancelación de pago. Este componente sirve para este propósito. Esta es su plantilla.

 <app-simple-page title="Payment cancelled" subtitle="Your Paypal payment has been cancelled" icon="money_off" buttonText="GO TO HOME" [centerText]="true" route="/"></app-simple-page>

Aquí hay una captura de pantalla de la página.

Captura de pantalla de la página de cancelación de pago
Captura de pantalla de la página de cancelación de pago. (Vista previa grande)

Componente de orden de lugar

Este es el último paso en el proceso de pago. Aquí el usuario confirma que efectivamente quiere realizar el pedido y comenzar su tramitación. Cuando el usuario aprueba el pago de Paypal, esta es la página a la que es redirigido. Paypal agrega un parámetro de consulta de identificación del pagador a la URL. Esta es la identificación de Paypal del usuario.

Cuando se inicializa el componente, obtenemos el parámetro de consulta payerId de la URL. Luego, el pedido se recupera utilizando OrderService con la fuente de pago incluida. La identificación de la fuente de pago incluida se usa para actualizar el pago de Paypal con la identificación del pagador, utilizando el servicio PaypalPayment . Si alguno de estos falla, el usuario es redirigido a la página de error. Usamos la propiedad disableButton para evitar que el usuario realice el pedido hasta que se establezca la identificación del pagador.

Cuando hacen clic en el botón de realizar el pedido, el pedido se actualiza con un estado placed . Después de lo cual se vacía el carrito, se muestra una barra de bocadillos exitosa y se redirige al usuario a la página de inicio.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-place-order', templateUrl: './place-order.component.html', styleUrls: ['./place-order.component.css'] }) export class PlaceOrderComponent implements OnInit { disableButton = true; constructor( private route: ActivatedRoute, private router: Router, private payments: PaypalPaymentService, private orders: OrderService, private cart: CartService, private snackBar: MatSnackBar ) { } ngOnInit() { this.route.queryParams .pipe( concatMap(params => { const payerId = params['PayerID']; const orderId = this.cart.orderId; return iif( () => payerId.length > 0, this.orders.getOrder(orderId, GetOrderParams.paymentSource) .pipe( concatMap(order => { const paymentSourceId = order.paymentSource?.id || ''; return iif( () => paymentSourceId ? paymentSourceId.length > 0 : false, this.payments.updatePaypalPayment(paymentSourceId, payerId) ); }) ) ); })) .subscribe( () => this.disableButton = false, () => this.router.navigateByUrl('/error') ); } placeOrder() { this.disableButton = true; this.orders.updateOrder({ id: this.cart.orderId, place: true }, [UpdateOrderParams.place]) .subscribe( () => { this.snackBar.open('Your order has been successfully placed.', 'Close', { duration: 3000 }); this.cart.clearCart(); setTimeout(() => this.router.navigateByUrl('/'), 4000); }, () => { this.snackBar.open('There was a problem placing your order.', 'Close', { duration: 8000 }); this.disableButton = false; } ); } }

Aquí está la plantilla y su estilo asociado.

 <app-simple-page title="Finalize Order" subtitle="Complete your order" [number]="'6'" icon="shopping_bag" buttonText="PLACE YOUR ORDER" (buttonEvent)="placeOrder()" [buttonDisabled]="disableButton"></app-simple-page>

Aquí hay una captura de pantalla de la página.

Captura de pantalla de la página de colocación de pedidos
Captura de pantalla de la página de colocación de pedidos. (Vista previa grande)

Módulo de aplicación

Todas las solicitudes realizadas a Commerce Layer, que no sean para la autenticación, deben contener un token. Entonces, en el momento en que se inicializa la aplicación, se obtiene un token de la /oauth/token en el servidor y se inicializa una sesión. Usaremos el token APP_INITIALIZER para proporcionar una función de inicialización en la que se recupera el token. Además, usaremos el token HTTP_INTERCEPTORS para proporcionar el OptionsInterceptor que creamos anteriormente. Una vez que se agregan todos los módulos, el archivo del módulo de la aplicación debería verse así.

 @NgModule({ declarations: [ AppComponent ], imports: [ BrowserModule, AppRoutingModule, HttpClientModule, BrowserAnimationsModule, AuthModule, ProductsModule, CartModule, CheckoutModule, CoreModule ], providers: [ { provide: HTTP_INTERCEPTORS, useClass: OptionsInterceptor, multi: true }, { provide: APP_INITIALIZER, useFactory: (http: HttpClient) => () => http.post<object>( `${environment.apiUrl}/oauth/token`, { 'grantType': 'client_credentials' }, { withCredentials: true }), multi: true, deps: [HttpClient] } ], bootstrap: [AppComponent] }) export class AppModule { }

Componente de la aplicación

Modificaremos la plantilla del componente de la aplicación y su estilo, que puede encontrar aquí.

 <div> <app-header></app-header> <div> <router-outlet></router-outlet> </div> </div>

Conclusión

En este artículo, hemos cubierto cómo puede crear una aplicación Angular 11 de comercio electrónico con Commerce Layer y Paypal. También hemos abordado cómo estructurar la aplicación y cómo podría interactuar con una API de comercio electrónico.

Aunque esta aplicación permite que un cliente haga un pedido completo, de ninguna manera está terminada. Hay mucho que podrías agregar para mejorarlo. Por un lado, puede optar por habilitar los cambios en la cantidad de artículos en el carrito, vincular los artículos del carrito a sus páginas de productos, optimizar los componentes de la dirección, agregar protecciones adicionales para las páginas de pago como la página de pedido, etc. Este es solo el punto de partida.

Si desea obtener más información sobre el proceso de realizar un pedido de principio a fin, puede consultar las guías y la API de la capa de comercio. Puede ver el código de este proyecto en este repositorio.