Cómo construir un sitio de comercio electrónico con Angular 11, Commerce Layer y Paypal
Publicado: 2022-03-10Hoy 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í.
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.
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 .
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.
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 .
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:
- el módulo central,
- el módulo de datos,
- el módulo compartido,
- los módulos de características.
Los módulos de funciones agruparán páginas y componentes relacionados. Habrá cuatro módulos de funciones:
- el módulo de autenticación,
- el módulo de producto,
- el módulo del carro,
- 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.
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>
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í.
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í.
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.
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.
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.
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.
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.
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.
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.
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í.
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.
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.
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.
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.
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.