Como construir um site de comércio eletrônico com Angular 11, Commerce Layer e Paypal
Publicados: 2022-03-10Hoje em dia é essencial ter uma presença online na gestão de um negócio. Muito mais compras são feitas online do que nos anos anteriores. Ter uma loja de comércio eletrônico permite que os donos de lojas abram outros fluxos de receita que não poderiam aproveitar com apenas uma loja física. Outros proprietários de lojas, no entanto, administram seus negócios totalmente on-line sem presença física. Isso torna crucial ter uma loja online.
Sites como Etsy, Shopify e Amazon facilitam a configuração de uma loja rapidamente, sem ter que se preocupar em desenvolver um site. No entanto, pode haver casos em que os donos de lojas possam querer uma experiência personalizada ou talvez economizar no custo de possuir uma loja em algumas dessas plataformas.
As plataformas de API de comércio eletrônico sem cabeça fornecem back-ends com os quais os sites da loja podem interagir. Eles gerenciam todos os processos e dados relacionados à loja, como clientes, pedidos, remessas, pagamentos e assim por diante. Tudo o que é necessário é um frontend para interagir com essas informações. Isso dá aos proprietários muita flexibilidade quando se trata de decidir como seus clientes experimentarão sua loja online e como escolherão executá-la.
Neste artigo, abordaremos como construir uma loja de comércio eletrônico usando o Angular 11. Usaremos o Commerce Layer como nossa API de comércio eletrônico headless. Embora possa haver várias maneiras de processar pagamentos, demonstraremos como usar apenas uma, o Paypal.
- Veja o código-fonte no GitHub →
Pré-requisitos
Antes de construir o aplicativo, você precisa ter o Angular CLI instalado. Devemos usá-lo para inicializar e estruturar o aplicativo. Se você ainda não o instalou, pode obtê-lo através do npm.
npm install -g @angular/cli
Você também precisará de uma conta de desenvolvedor do Commerce Layer. Usando a conta de desenvolvedor, você precisará criar uma organização de teste e semeá-la com dados de teste. A propagação facilita o desenvolvimento do aplicativo primeiro sem se preocupar com os dados que você terá que usar. Você pode criar uma conta neste link e uma organização aqui.
Por fim, você precisará de uma conta Paypal Sandbox. Ter esse tipo de conta nos permitirá testar transações entre empresas e usuários sem arriscar dinheiro real. Você pode criar um aqui. Uma conta de sandbox tem uma conta comercial de teste e uma conta pessoal de teste já criada para ela.
Camada de comércio e configuração do Paypal
Para possibilitar os pagamentos do Paypal Sandbox no Commerce Layer, você precisará configurar as chaves de API. Vá para a visão geral das contas da sua conta de desenvolvedor do Paypal. Selecione uma conta comercial e, na guia de credenciais da API dos detalhes da conta, você encontrará o Aplicativo Padrão em Aplicativos REST .
Para associar sua conta comercial do Paypal à sua organização Commerce Layer, acesse o painel da sua organização. Aqui você adicionará um gateway de pagamento Paypal e um método de pagamento Paypal para seus vários mercados. Em Configurações > Pagamentos , selecione Gateways de pagamento > Paypal e adicione seu ID de cliente e segredo do Paypal.
Depois de criar o gateway, você precisará criar um método de pagamento Paypal para cada mercado que você está direcionando para disponibilizar o Paypal como uma opção. Você fará isso em Configurações > Pagamentos > Formas de pagamento > Nova forma de pagamento .
Uma nota sobre as rotas usadas
O Commerce Layer fornece uma rota para autenticação e outro conjunto diferente de rotas para sua API. Sua rota de autenticação /oauth/token
troca credenciais por um token. Esse token é necessário para acessar sua API. As demais rotas da API seguem o padrão /api/:resource
.
O escopo deste artigo abrange apenas a parte de front-end deste aplicativo. Optei por armazenar o lado do servidor de tokens, usar sessões para rastrear a propriedade e fornecer cookies somente http com um ID de sessão para o cliente. Isso não será abordado aqui, pois está fora do escopo deste artigo. No entanto, as rotas permanecem as mesmas e correspondem exatamente à API Commerce Layer. No entanto, existem algumas rotas personalizadas não disponíveis na Commerce Layer API que usaremos. Estes lidam principalmente com o gerenciamento de sessões. Vou apontá-los à medida que chegarmos a eles e descrever como você pode obter um resultado semelhante.
Outra inconsistência que você pode notar é que os corpos da solicitação diferem do que a Commerce Layer API exige. Como as solicitações são passadas para outro servidor para serem preenchidas com um token, estruturei os corpos de maneira diferente. Isso foi para facilitar o envio de solicitações. Sempre que existam inconsistências nos órgãos solicitantes, estas serão apontadas nos serviços.
Como isso está fora do escopo, você terá que decidir como armazenar tokens com segurança. Você também precisará modificar ligeiramente os corpos da solicitação para corresponder exatamente ao que a Commerce Layer API exige. Quando houver uma inconsistência, vou linkar para a referência da API e guias detalhando como estruturar corretamente o corpo.
Estrutura do aplicativo
Para organizar o aplicativo, vamos dividi-lo em quatro partes principais. Uma descrição melhor do que cada um dos módulos faz é fornecida em suas seções correspondentes:
- o módulo principal,
- o módulo de dados,
- o módulo compartilhado,
- os módulos de recursos.
Os módulos de recursos agruparão páginas e componentes relacionados. Haverá quatro módulos de recursos:
- o módulo de autenticação,
- o módulo do produto,
- o módulo do carrinho,
- o módulo de checkout.
À medida que chegarmos a cada módulo, explicarei qual é sua finalidade e detalharei seu conteúdo.
Abaixo está uma árvore da pasta src/app
e onde cada módulo reside.
src ├── app │ ├── core │ ├── data │ ├── features │ │ ├── auth │ │ ├── cart │ │ ├── checkout │ │ └── products └── shared
Gerando o aplicativo e adicionando dependências
Começaremos gerando o app. Nossa organização se chamará The Lime Brand e terá dados de teste já semeados pelo Commerce Layer.
ng new lime-app
Vamos precisar de algumas dependências. Principalmente Material Angular e Até Destruir. Angular Material fornecerá componentes e estilo. Até que Destroy cancela automaticamente a assinatura de observáveis quando os componentes são destruídos. Para instalá-los execute:
npm install @ngneat/until-destroy ng add @angular/material
Bens
Ao adicionar endereços ao Commerce Layer, um código de país alfa-2 precisa ser usado. Adicionaremos um arquivo json contendo esses códigos à pasta assets
em assets/json/country-codes.json
. Você pode encontrar este arquivo linkado aqui.
Estilos
Os componentes que criaremos compartilham alguns estilos globais. Vamos colocá-los em styles.css
que pode ser encontrado neste link.
Meio Ambiente
Nossa configuração será composta por dois campos. O apiUrl
que deve apontar para a Commerce Layer API. apiUrl
é usado pelos serviços que criaremos para buscar dados. O clientUrl
deve ser o domínio no qual o aplicativo está sendo executado. Usamos isso ao definir URLs de redirecionamento para o Paypal. Você pode encontrar este arquivo neste link.
Módulo compartilhado
O módulo compartilhado conterá serviços, pipes e componentes compartilhados entre os outros módulos.
ng gm shared
Consiste em três componentes, um tubo e dois serviços. Aqui está o que isso vai parecer.
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
Também usaremos o módulo compartilhado para exportar alguns componentes do Angular Material comumente usados. Isso torna mais fácil usá-los imediatamente em vez de importar cada componente em vários módulos. Aqui está o que shared.module.ts
conterá.
@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
Componente de quantidade de itens
Este componente define a quantidade de itens ao adicioná-los ao carrinho. Será utilizado nos módulos carrinho e produtos. Um seletor de material teria sido uma escolha fácil para esta finalidade. No entanto, o estilo da seleção de material não correspondeu às entradas de material usadas em todos os outros formulários. Um menu de materiais parecia muito semelhante aos insumos de materiais usados. Então decidi criar um componente select com ele.
ng gc shared/components/item-quantity
O componente terá três propriedades de entrada e uma propriedade de saída. quantity
define a quantidade inicial de itens, maxValue
indica o número máximo de itens que podem ser selecionados de uma só vez e disabled
indica se o componente deve ser desabilitado ou não. O setQuantityEvent
é acionado quando uma quantidade é selecionada.
Quando o componente for inicializado, definiremos os valores que aparecem no menu de materiais. Existe também um método chamado setQuantity
que emitirá eventos setQuantityEvent
.
Este é o arquivo 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); } }
Este é o seu modelo.
<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>
Aqui está o seu estilo.
button { margin: 3px; }
Componente de título
Este componente funciona como um título de passo, bem como um título simples em algumas páginas mais simples. Embora o Angular Material forneça um componente de passo, ele não era o melhor ajuste para um processo de checkout bastante longo, não era tão responsivo em telas menores e exigia muito mais tempo para implementar. Um título mais simples, no entanto, pode ser reaproveitado como um indicador de passo e ser útil em várias páginas.
ng gc shared/components/title
O componente tem quatro propriedades de entrada: a title
, a subtitle
, a number ( no
) e centerText
, para indicar se o texto do componente deve ser centralizado.
@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; }
Abaixo está o seu modelo. Você pode encontrar seu estilo linkado aqui.
<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 simples
Existem várias instâncias em que um título, um ícone e um botão eram tudo o que era necessário para uma página. Isso inclui uma página 404, uma página de carrinho vazia, uma página de erro, uma página de pagamento e uma página de colocação de pedido. Esse é o propósito que o componente de página simples servirá. Quando o botão na página é clicado, ele redireciona para uma rota ou executa alguma ação em resposta a um buttonEvent
.
Para fazer:
ng gc shared/components/simple-page
Este é seu arquivo 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(); } } }
E seu modelo contém:
<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>
Seu estilo pode ser encontrado aqui.
Tubos
Pipe Wrap Word
Os nomes de alguns produtos e outros tipos de informações exibidos no site são muito longos. Em alguns casos, fazer com que essas longas sentenças envolvam componentes materiais é um desafio. Então, usaremos este pipe para reduzir as frases a um comprimento especificado e adicionar elipses ao final do resultado.
Para criar execute:
ng g pipe shared/pipes/word-wrap
Ele conterá:
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)}...`; } }
Serviços
Serviço de tratamento de erros HTTP
Há um grande número de serviços http neste projeto. Criar um manipulador de erros para cada método é repetitivo. Portanto, criar um único manipulador que possa ser usado por todos os métodos faz sentido. O manipulador de erros pode ser usado para formatar um erro e também passar os erros para outras plataformas de log externas.
Gere-o executando:
ng gs shared/services/http-error-handler
Este serviço conterá apenas um método. O método formatará a mensagem de erro a ser exibida dependendo se for um erro do cliente ou do servidor. No entanto, há espaço para melhorá-lo ainda mais.
@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); } }
Serviço de armazenamento local
Usaremos o armazenamento local para acompanhar o número de itens em um carrinho. Também é útil armazenar o Id de um pedido aqui. Um pedido corresponde a um carrinho no Commerce Layer.
Para gerar o serviço de armazenamento local, execute:
ng gs shared/services/local-storage
O serviço conterá quatro métodos para adicionar, excluir e obter itens do armazenamento local e outro para limpá-lo.
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 dados
Este módulo é responsável pela recuperação e gerenciamento de dados. É o que usaremos para obter os dados que nosso aplicativo consome. Abaixo segue sua estrutura:
src/app/data ├── data.module.ts ├── models └── services
Para gerar o módulo execute:
ng gm data
Modelos
Os modelos definem como os dados que consumimos da API são estruturados. Teremos 16 declarações de interface. Para criá-los execute:
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
A tabela a seguir vincula cada arquivo e fornece uma descrição do que é cada interface.
Interface | Descrição |
---|---|
Endereço | Representa um endereço geral. |
Carrinho | Versão do lado do cliente de um pedido que rastreia o número de produtos que um cliente pretende comprar. |
País | Código de país alfa-2. |
Endereço do cliente | Um endereço associado a um cliente. |
Cliente | Um usuário registrado. |
Prazo de entrega | Representa a quantidade de tempo que levará para entregar uma remessa. |
Item da linha | Um produto detalhado adicionado ao carrinho. |
Ordem | Um carrinho de compras ou uma coleção de itens de linha. |
Forma de pagamento | Um tipo de pagamento disponibilizado para um pedido. |
Fonte de pagamento | Um pagamento associado a um pedido. |
Pagamento Paypal | Um pagamento feito através do Paypal |
Preço | Preço associado a um SKU. |
envio | Coleção de itens enviados juntos. |
método de envio | Método através do qual um pacote é enviado. |
SKU | Uma unidade única de manutenção de estoque. |
Localização do estoque | Local que contém o inventário de SKU. |
Serviços
Essa pasta contém os serviços que criam, recuperam e manipulam dados do aplicativo. Vamos criar 11 serviços aqui.
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
Serviço de endereço
Este serviço cria e recupera endereços. É importante ao criar e atribuir endereços de entrega e cobrança aos pedidos. Tem dois métodos. Um para criar um endereço e outro para recuperar um.
A rota usada aqui é /api/addresses
. Se você for usar a Commerce Layer API diretamente, certifique-se de estruturar os dados conforme demonstrado neste exemplo.
@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)); } }
Serviço de carrinho
O carrinho é responsável por manter a quantidade de itens adicionados e o ID do pedido. Fazer chamadas de API para obter o número de itens em um pedido sempre que um novo item de linha é criado pode ser caro. Em vez disso, poderíamos usar apenas o armazenamento local para manter a contagem no cliente. Isso elimina a necessidade de fazer buscas desnecessárias de pedidos sempre que um item é adicionado ao carrinho.
Também usamos este serviço para armazenar o ID do pedido. Um carrinho corresponde a um pedido no Commerce Layer. Uma vez que o primeiro item é adicionado ao carrinho, um pedido é criado. Precisamos preservar esse ID do pedido para que possamos buscá-lo durante o processo de checkout.
Além disso, precisamos de uma maneira de comunicar ao cabeçalho que um item foi adicionado ao carrinho. O cabeçalho contém o botão do carrinho e exibe a quantidade de itens nele. Usaremos um observável de um BehaviorSubject
com o valor atual do carrinho. O cabeçalho pode assinar isso e acompanhar as alterações no valor do carrinho.
Por fim, uma vez que um pedido tenha sido concluído, o valor do carrinho precisa ser compensado. Isso garante que não haja confusão ao criar novos pedidos subsequentes. Os valores que foram armazenados são apagados assim que o pedido atual é marcado como feito.
Faremos tudo isso usando o serviço de armazenamento local criado 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 }); } }
Serviço de país
Ao adicionar endereços no Commerce Layer, o código do país deve ser um código alfa 2. Este serviço lê um arquivo json contendo esses códigos para cada país e o retorna em seu 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'); } }
Serviço de endereço do cliente
Este serviço é usado para associar endereços a clientes. Ele também busca um endereço específico ou todos os endereços relacionados a um cliente. É usado quando o cliente adiciona seus endereços de envio e cobrança ao pedido. O método createCustomer
cria um cliente, getCustomerAddresses
obtém todos os endereços de um cliente e getCustomerAddress
obtém um específico.
Ao criar um endereço de cliente, certifique-se de estruturar o corpo da postagem de acordo com este exemplo.
@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)); } }
Atendimento ao Cliente
Os clientes são criados e suas informações recuperadas usando este serviço. Quando um usuário se inscreve, ele se torna um cliente e é criado usando o createCustomerMethod
. getCustomer
retorna o cliente associado a um ID específico. getCurrentCustomer
retorna o cliente conectado no momento.
Ao criar um cliente, estruture os dados assim. Você pode adicionar seus nomes e sobrenomes aos metadados, conforme mostrado em seus atributos.
A rota /api/customers/current
não está disponível no Commerce Layer. Portanto, você precisará descobrir como obter o cliente conectado no momento.
@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)); } }
Serviço de prazo de entrega
Este serviço retorna informações sobre prazos de envio de vários locais de estoque.
@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)); } }
Serviço de item de linha
Os itens adicionados ao carrinho são gerenciados por este serviço. Com ele, você pode criar um item no momento em que é adicionado ao carrinho. As informações de um item também podem ser buscadas. O item também pode ser atualizado quando sua quantidade mudar ou excluído quando retirado do carrinho.
Ao criar itens ou atualizá-los, estruture o corpo da solicitação conforme mostrado neste exemplo.
@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)); } }
Serviço de pedidos
Semelhante ao serviço de item de linha, o serviço de pedido permite que você crie, atualize, exclua ou obtenha um pedido. Além disso, você pode optar por obter as remessas associadas a um pedido separadamente usando o método getOrderShipments
. Este serviço é muito utilizado durante todo o processo de checkout.
Existem diferentes tipos de informações sobre um pedido que são necessários durante a finalização da compra. Como pode ser caro buscar um pedido inteiro e suas relações, especificamos o que queremos obter de um pedido usando GetOrderParams
. O equivalente a isso na API de CL é o parâmetro de consulta include onde você lista os relacionamentos de pedidos a serem incluídos. Você pode verificar quais campos precisam ser incluídos para o resumo do carrinho aqui e para as várias etapas de checkout aqui.
Da mesma forma, ao atualizar um pedido, usamos UpdateOrderParams
para especificar campos de atualização. Isso porque no servidor que preenche o token, algumas operações extras são realizadas dependendo de qual campo está sendo atualizado. No entanto, se você estiver fazendo solicitações diretas à API CL, não precisará especificar isso. Você pode eliminá-lo, pois a API CL não exige que você os especifique. No entanto, o corpo da solicitação deve ser semelhante a este exemplo.
@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)); } }
Serviço de Pagamento Paypal
Este serviço é responsável pela criação e atualização de pagamentos Paypal para pedidos. Além disso, podemos obter um pagamento Paypal dado o seu id. O corpo do post deve ter uma estrutura semelhante a este exemplo ao criar um pagamento 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)); } }
Serviço de Remessa
Este serviço recebe um envio ou atualiza-o com base no seu id. O corpo da solicitação de uma atualização de remessa deve ser semelhante a este exemplo.
@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)); } }
Serviço de SKU
O serviço de SKU obtém produtos da loja. Se vários produtos estiverem sendo recuperados, eles poderão ser paginados e ter um tamanho de página definido. O tamanho da página e o número da página devem ser definidos como parâmetros de consulta, como neste exemplo, se você estiver fazendo solicitações diretas à API. Um único produto também pode ser recuperado com base em seu 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
O módulo principal contém tudo o que é central e comum em todo o aplicativo. Isso inclui componentes como o cabeçalho e páginas como a página 404. Os serviços responsáveis pela autenticação e gerenciamento de sessão também se enquadram aqui, assim como interceptores e guardas em todo o aplicativo.
A árvore do módulo principal ficará assim.
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 gerar o módulo e seu conteúdo execute:
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
O arquivo do módulo principal deve ser assim. Observe que as rotas foram registradas para NotFoundComponent
e 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 { }
Serviços
A pasta de serviços contém os serviços de autenticação, sessão e cabeçalho.
Serviço de autenticação
O AuthenticationService
permite adquirir tokens de cliente e cliente. Esses tokens são usados para acessar o restante das rotas da API. Os tokens do cliente são retornados quando um usuário troca um email e uma senha por ele e tem uma gama mais ampla de permissões. Os tokens de cliente são emitidos sem a necessidade de credenciais e têm permissões mais restritas.
getClientSession
obtém um token de cliente. login
obtém um token de cliente. Ambos os métodos também criam uma sessão. O corpo de uma solicitação de token de cliente deve ter esta aparência e o de um token de cliente como este.
@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 um método de logout
que destrói a sessão de um usuário e atribui a ele um token de cliente. Um token de cliente é atribuído porque a sessão que mantém seu token de cliente é destruída e um token ainda é necessário para cada solicitação de API. Um snackbar de material comunica ao usuário se sua sessão foi destruída com sucesso ou não.
Usamos o @UntilDestroy({ checkProperties: true })
para indicar que todas as assinaturas devem ser automaticamente canceladas quando o componente for destruído.
@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 }) ); } }
Abaixo está o modelo de cabeçalho e aqui está o seu 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>
Guardas
Guarda Carrinho Vazio
Essa proteção impede que os usuários acessem as rotas relacionadas ao checkout e cobrança se o carrinho estiver vazio. Isso ocorre porque para prosseguir com o checkout, é necessário que haja um pedido válido. Um pedido corresponde a um carrinho com itens nele. Se houver itens no carrinho, o usuário pode prosseguir para uma página protegida. No entanto, se o carrinho estiver vazio, o usuário será redirecionado para uma página de carrinho vazio.
@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 Opções
Esse interceptor intercepta todas as solicitações HTTP de saída e adiciona duas opções à solicitação. Estes são um cabeçalho Content-Type
e uma propriedade withCredentials
. withCredentials
especifica se uma solicitação deve ser enviada com credenciais de saída, como os cookies somente http que usamos. Usamos Content-Type
para indicar que estamos enviando recursos json para o 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 recursos
Esta seção contém os principais recursos do aplicativo. Como mencionado anteriormente, os recursos são agrupados em quatro módulos: módulos de autenticação, produto, carrinho e checkout.
Módulo de produtos
O módulo de produtos contém páginas que exibem produtos em promoção. Isso inclui a página do produto e a página da lista de produtos. Está estruturado como mostrado abaixo.
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 gerá-lo e seus componentes:
ng gm features/products ng gc features/products/pages/product ng gc features/products/pages/product-list
Este é o arquivo do 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 da lista de produtos
Este componente exibe uma lista paginada de produtos disponíveis para venda. É a primeira página que é carregada quando o aplicativo é iniciado.
Os produtos são exibidos em uma grade. A lista de grade de materiais é o melhor componente para isso. Para tornar a grade responsiva, o número de colunas da grade mudará dependendo do tamanho da tela. O serviço BreakpointObserver
nos permite determinar o tamanho da tela e atribuir as colunas durante a inicialização.
Para obter os produtos, chamamos o método SkuService
getProducts
Ele retorna os produtos se for bem-sucedido e os atribui à grade. Caso contrário, roteamos o usuário para a página de erro.
Como os produtos exibidos são paginados, teremos um método getNextPage
para obter os produtos adicionais.
@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}`; } }
O modelo é mostrado abaixo e seu estilo pode ser encontrado aqui.
<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>
A página ficará assim.
Componente do produto
Depois que um produto é selecionado na página da lista de produtos, esse componente exibe seus detalhes. Estes incluem o nome completo do produto, preço e descrição. Há também um botão para adicionar o item ao carrinho de produtos.
Na inicialização, obtemos o id do produto dos parâmetros de rota. Usando o id, buscamos o produto do SkuService
.
Quando o usuário adiciona um item ao carrinho, o método addItemToCart
é chamado. Nele, verificamos se já foi criado um pedido para o carrinho. Caso contrário, um novo é feito usando o OrderService
. Depois disso, um item de linha é criado no pedido que corresponde ao produto. Se já existir um pedido para o carrinho, apenas o item de linha será criado. Dependendo do status das solicitações, uma mensagem de snackbar é exibida ao usuário.
@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 }); } }
O modelo ProductComponent
é o seguinte e seu estilo está vinculado aqui.
<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>
A página ficará assim.
Módulo de autenticação
O módulo Auth contém páginas responsáveis pela autenticação. Isso inclui as páginas de login e inscrição. Está estruturado da seguinte forma.
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 gerá-lo e seus componentes:
ng gm features/auth ng gc features/auth/pages/signup ng gc features/auth/pages/login
Este é o seu arquivo 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 inscrição
Um usuário se inscreve em uma conta usando este componente. Um nome, sobrenome, e-mail e senha são necessários para o processo. O usuário também precisa confirmar sua senha. Os campos de entrada serão criados com o serviço FormBuilder
. A validação é adicionada para exigir que todas as entradas tenham valores. A validação adicional é adicionada ao campo de senha para garantir um comprimento mínimo de oito caracteres. Um validador de matchPasswords
personalizado garante que a senha confirmada corresponda à senha inicial.
Quando o componente é inicializado, os botões carrinho, login e logout no cabeçalho ficam ocultos. Isso é comunicado ao cabeçalho usando o HeaderService
.
Depois que todos os campos forem marcados como válidos, o usuário poderá se inscrever. No método signup
, o método createCustomer
do CustomerService
recebe essa entrada. Se a inscrição for bem-sucedida, o usuário é informado de que sua conta foi criada com sucesso usando uma lanchonete. Eles são então redirecionados para a página inicial.
@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 }) ); } }
Abaixo está o modelo para o 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>
O componente ficará da seguinte forma.
Componente de login
Um usuário registrado faz login em sua conta com este componente. Um e-mail e senha precisam ser inseridos. Seus campos de entrada correspondentes teriam validação que os torna obrigatórios.
Semelhante ao SignupComponent
, os botões carrinho, login e logout no cabeçalho estão ocultos. Sua visibilidade é definida usando o HeaderService
durante a inicialização do componente.
Para fazer login, as credenciais são passadas para o AuthenticationService
. Se for bem-sucedido, o status de login do usuário será definido usando o SessionService
. O usuário é então roteado de volta para a página em que estava. Se não tiver sucesso, uma barra de lanches é exibida com um erro e o campo de senha é redefinido.
@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: '' }); } ); } }
Abaixo está o modelo 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>
Aqui está uma captura de tela da página.
Módulo Carrinho
O módulo carrinho contém todas as páginas relacionadas ao carrinho. Isso inclui a página de resumo do pedido, uma página de código de cupom e cartão-presente e uma página de carrinho vazia. Está estruturado da seguinte forma.
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 gerá-lo, execute:
ng gm features/cart ng gc features/cart/codes ng gc features/cart/empty ng gc features/cart/summary
Este é o arquivo do 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
Conforme mencionado anteriormente, este componente é usado para adicionar qualquer código de cupom ou cartão-presente a um pedido. Isso permite que o usuário aplique descontos ao total de seu pedido antes de prosseguir para a finalização da compra.
Haverá dois campos de entrada. Um para cupons e outro para códigos de cartão-presente.
Os códigos são adicionados atualizando o pedido. O método updateOrder
do OrderService
atualiza o pedido com os códigos. Depois disso, ambos os campos são redefinidos e o usuário é informado do sucesso da operação com uma lanchonete. Um snackbar também é mostrado quando ocorre um erro. Os métodos addCoupon
e addGiftCard
chamam o 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'); } }
O modelo é mostrado abaixo e seu estilo pode ser encontrado neste link.
<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>
Aqui está uma captura de tela da página.
Componente vazio
Não deve ser possível fazer o check-out com um carrinho vazio. É preciso haver um guarda que impeça que os usuários acessem as páginas do módulo de checkout com carrinhos vazios. Isso já foi abordado como parte do CoreModule
. O guarda redireciona as solicitações para páginas de checkout com um carrinho vazio para EmptyCartComponent
.
É um componente muito simples que possui algum texto indicando ao usuário que seu carrinho está vazio. Ele também tem um botão que o usuário pode clicar para ir para a página inicial para adicionar coisas ao carrinho. Então, usaremos o SimplePageComponent
para exibi-lo. Aqui está o modelo.
<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>
Aqui está uma captura de tela da página.
Componente de resumo
Este componente resume o carrinho/pedido. Ele lista todos os itens no carrinho, seus nomes, quantidades e fotos. Além disso, detalha o custo do pedido, incluindo impostos, frete e descontos. O usuário deve ser capaz de visualizar isso e decidir se está satisfeito com os itens e o custo antes de prosseguir para a finalização da compra.
Na inicialização, o pedido e seus itens de linha são buscados usando o OrderService
. Um usuário deve poder modificar os itens de linha ou até mesmo removê-los do pedido. Os itens são removidos quando o método deleteLineItem
é chamado. Nele o método deleteLineItem
do LineItemService
recebe o id do item de linha a ser deletado. Se a exclusão for bem-sucedida, atualizamos a contagem de itens no carrinho usando o CartService
.
O usuário é então encaminhado para a página do cliente, onde inicia o processo de check-out. O método de checkout
faz o roteamento.
@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') ); } }
Abaixo está o modelo e seu estilo está vinculado aqui.
<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>
Aqui está uma captura de tela da página.
Módulo de checkout
Este módulo é responsável pelo processo de checkout. O checkout envolve fornecer um endereço de cobrança e envio, um e-mail do cliente e selecionar um método de envio e pagamento. A última etapa deste processo é a colocação e confirmação do pedido. A estrutura do módulo é a seguinte.
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 é de longe o maior e contém 3 componentes e 7 páginas. Para gerá-lo e seus componentes execute:
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 é o arquivo do 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 seleção de país
Este componente permite que um usuário selecione um país como parte de um endereço. O componente de seleção de material tem uma aparência bem diferente quando comparado aos campos de entrada no formulário de endereço. Portanto, por uma questão de uniformidade, um componente de menu de material é usado.
Quando o componente é inicializado, os dados do código do país são buscados usando o CountryService
. A propriedade de countries
contém os valores retornados pelo serviço. Esses valores serão adicionados ao menu no modelo.
O componente tem uma propriedade de saída, setCountryEvent
. Quando um país é selecionado, este evento emite o código alfa-2 do 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); }}
Abaixo está o seu modelo e aqui está o seu 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 endereço
Este é um formulário para capturar endereços. Ele é usado pelas páginas de endereço de entrega e cobrança. Um endereço de camada de comércio válido deve conter um nome e sobrenome, uma linha de endereço, uma cidade, CEP, código do estado, código do país e número de telefone.
O serviço FormBuilder
criará o grupo de formulários. Como esse componente é usado por várias páginas, ele possui várias propriedades de entrada e saída. As propriedades de entrada incluem o texto do botão, o título exibido e o texto de uma caixa de seleção. As propriedades de saída serão emissores de eventos para quando o botão for clicado para criar o endereço e outro para quando o valor do checkbox for alterado.
Quando o botão é clicado, o método addAddress
é chamado e o evento createAddress
emite o endereço completo. Da mesma forma, quando a caixa de seleção está marcada, o evento isCheckboxChecked
emite o valor da caixa de seleção.
@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); } }
Aqui está o seu modelo. Você pode encontrar seu estilo aqui.
<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>
Páginas
Componente do cliente
Um pedido precisa estar associado a um endereço de e-mail. Este componente é um formulário que captura o endereço de e-mail do cliente. Quando o componente é inicializado, o endereço de e-mail do cliente atual é obtido se ele estiver logado. Obtemos o cliente do CustomerService
. Se eles não quiserem alterar seu endereço de e-mail, este e-mail será o valor padrão.
Se o e-mail for alterado ou um cliente não estiver logado, o pedido será atualizado com o e-mail inserido. Usamos o OrderService
para atualizar o pedido com o novo endereço de e-mail. Se for bem-sucedido, encaminhamos o cliente para a página de endereço de cobrança.
@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 }) ); } }
Aqui está o modelo do componente e aqui está o seu 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>
Aqui está uma captura de tela da página do cliente.
Componente de endereço de cobrança
O componente de endereço de cobrança permite que um cliente adicione um novo endereço de cobrança ou escolha entre seus endereços existentes. Os usuários que não estão logados precisam inserir um novo endereço. Aqueles que fizeram login têm a opção de escolher entre endereços novos ou existentes.
A propriedade showAddress
indica se os endereços existentes devem ser mostrados no componente. sameShippingAddressAsBilling
indica se o endereço de entrega deve ser o mesmo que o endereço de cobrança definido. Quando um cliente seleciona um endereço existente, seu id é atribuído a selectedCustomerAddressId
.
Quando o componente é inicializado, usamos o SessionService
para verificar se o usuário atual está logado. Se ele estiver logado, exibiremos seus endereços existentes, se houver algum.
Conforme mencionado anteriormente, se um usuário estiver logado, ele poderá escolher um endereço existente como seu endereço de cobrança. No método updateBillingAddress
, se estiverem logados, o endereço que selecionarem será clonado e definido como endereço de cobrança do pedido. Fazemos isso atualizando o pedido usando o método updateOrder
do OrderService
e fornecendo o ID do endereço.
Se eles não estiverem logados, o usuário deve fornecer um endereço. Uma vez fornecido, o endereço é criado usando o método createAddress
. Nele, o AddressService
pega a entrada e faz o novo endereço. Depois disso, o pedido é atualizado usando o id do endereço recém-criado. Se houver um erro ou qualquer operação for bem-sucedida, mostramos uma lanchonete.
Se o mesmo endereço for selecionado como endereço de entrega, o usuário será direcionado para a página de métodos de entrega. Se eles quiserem fornecer um endereço de entrega alternativo, eles serão direcionados para a página do endereço de entrega.
@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'); } } }
Aqui está o modelo. Este link aponta para seu 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>
Esta é a aparência da página de endereço de cobrança.
Componente de endereço de entrega
O componente de endereço de entrega se comporta muito como o componente de endereço de cobrança. No entanto, existem algumas diferenças. Por um lado, o texto exibido no modelo é diferente. As outras diferenças importantes estão em como o pedido é atualizado usando o OrderService
depois que um endereço é criado ou selecionado. Os campos que o pedido atualiza são shippingAddressCloneId
para endereços selecionados e shippingAddress
para novos endereços. Se um usuário optar por alterar o endereço de cobrança para que seja igual ao endereço de entrega, o campo billingAddressSameAsShipping
será atualizado.
Depois que um endereço de entrega é selecionado e o pedido é atualizado, o usuário é direcionado para a página de métodos de envio.
@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); } }
Aqui está o modelo e seu estilo pode ser encontrado aqui.
<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>
A página do endereço de entrega ficará assim.
Componente de métodos de envio
Este componente exibe o número de remessas necessárias para que um pedido seja atendido, os métodos de remessa disponíveis e seus custos associados. O cliente pode então selecionar um método de envio que preferir para cada envio.
A propriedade de shipments
contém todas as remessas do pedido. O shipmentsForm
é o formulário no qual serão feitas as seleções do método de envio.
Quando o componente é inicializado, o pedido é buscado e conterá seus itens de linha e remessas. Ao mesmo tempo, obtemos os prazos de entrega para os vários métodos de envio. Usamos o OrderService
para obter o pedido e o DeliveryLeadTimeService
para os prazos de entrega. Depois que os dois conjuntos de informações são retornados, eles são combinados em uma matriz de remessas e atribuídos à propriedade de shipments
. Cada remessa conterá seus itens, os métodos de envio disponíveis e o custo correspondente.
Após o usuário selecionar um método de envio para cada envio, o método de envio selecionado é atualizado para cada um em setShipmentMethods
. Se for bem-sucedido, o usuário será direcionado para a página de pagamentos.
@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]; } }
Aqui está o modelo e você pode encontrar o estilo neste link.
<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 é uma captura de tela da página de métodos de envio.
Componente de pagamentos
Nesta componente, o utilizador clica no botão de pagamento caso pretenda proceder ao pagamento da sua encomenda com Paypal. A approvalUrl
é o link do Paypal para o qual o usuário é direcionado quando clica no botão.
Durante a inicialização, obtemos o pedido com a fonte de pagamento incluída usando o OrderService
. Se uma fonte de pagamento for definida, obtemos seu id e recuperamos o pagamento Paypal correspondente do PaypalPaymentService
. O pagamento Paypal conterá a url de aprovação. Se nenhuma fonte de pagamento tiver sido definida, atualizamos o pedido com o Paypal como método de pagamento preferencial. Em seguida, criamos um novo pagamento Paypal para o pedido usando o PaypalPaymentService
. A partir daqui, podemos obter o URL de aprovação do pedido recém-criado.
Por fim, quando o usuário clica no botão, ele é redirecionado para o Paypal, onde pode aprovar a 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; } }
Aqui está o seu modelo.
<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>
Veja como será a página de pagamentos.
Cancelar componente de pagamento
O Paypal requer uma página de cancelamento de pagamento. Este componente atende a esse propósito. Este é o seu modelo.
<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>
Aqui está uma captura de tela da página.
Componente Fazer Pedido
Esta é a última etapa do processo de checkout. Aqui o usuário confirma que realmente deseja fazer o pedido e iniciar seu processamento. Quando o usuário aprova o pagamento do Paypal, esta é a página para a qual ele é redirecionado. O Paypal adiciona um parâmetro de consulta de ID do pagador ao URL. Este é o ID do Paypal do usuário.
Quando o componente é inicializado, obtemos o parâmetro de consulta payerId
da url. O pedido é então recuperado usando o OrderService
com a fonte de pagamento incluída. O id da fonte de pagamento incluída é usado para atualizar o pagamento Paypal com o id do pagador, usando o serviço PaypalPayment
. Se algum deles falhar, o usuário será redirecionado para a página de erro. Usamos a propriedade disableButton
para impedir que o usuário faça o pedido até que o ID do pagador seja definido.
Quando eles clicam no botão fazer pedido, o pedido é atualizado com um status de placed
. Depois disso, o carrinho é limpo, uma lanchonete bem-sucedida é exibida e o usuário é redirecionado para a página inicial.
@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; } ); } }
Aqui está o modelo e seu estilo associado.
<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>
Aqui está uma captura de tela da página.
Módulo de aplicativo
Todas as solicitações feitas ao Commerce Layer, exceto para autenticação, precisam conter um token. Assim, no momento em que o aplicativo é inicializado, um token é obtido da rota /oauth/token
no servidor e uma sessão é inicializada. Usaremos o token APP_INITIALIZER
para fornecer uma função de inicialização na qual o token é recuperado. Além disso, usaremos o token HTTP_INTERCEPTORS
para fornecer o OptionsInterceptor
que criamos anteriormente. Depois que todos os módulos forem adicionados, o arquivo do módulo do aplicativo deve se parecer com isso.
@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 do aplicativo
Modificaremos o modelo de componente do aplicativo e seu estilo, que você pode encontrar aqui.
<div> <app-header></app-header> <div> <router-outlet></router-outlet> </div> </div>
Conclusão
Neste artigo, abordamos como você pode criar um aplicativo de comércio eletrônico Angular 11 com Commerce Layer e Paypal. Também abordamos como estruturar o aplicativo e como você pode interagir com uma API de comércio eletrônico.
Embora este aplicativo permita que um cliente faça um pedido completo, ele não está de forma alguma concluído. Há muito que você pode adicionar para melhorá-lo. Por um lado, você pode optar por ativar alterações na quantidade de itens no carrinho, vincular itens do carrinho às suas páginas de produtos, otimizar os componentes de endereço, adicionar proteções adicionais para páginas de checkout, como a página de pedidos e assim por diante. Este é apenas o ponto de partida.
Se você quiser entender mais sobre o processo de fazer um pedido do início ao fim, confira os guias e a API da Camada de Comércio. Você pode visualizar o código para este projeto neste repositório.