Как создать сайт электронной коммерции с помощью Angular 11, Commerce Layer и Paypal
Опубликовано: 2022-03-10В настоящее время важно иметь присутствие в Интернете при ведении бизнеса. В Интернете совершается гораздо больше покупок, чем в предыдущие годы. Наличие магазина электронной коммерции позволяет владельцам магазинов открывать другие источники дохода, которыми они не могли бы воспользоваться, используя обычный магазин. Однако другие владельцы магазинов ведут свой бизнес в Интернете без физического присутствия. Это делает наличие интернет-магазина крайне важным.
Такие сайты, как Etsy, Shopify и Amazon, позволяют довольно быстро настроить магазин, не беспокоясь о разработке сайта. Однако могут быть случаи, когда владельцам магазинов может потребоваться персонализированный опыт или, возможно, сэкономить на стоимости владения магазином на некоторых из этих платформ.
Платформы API безголовой электронной коммерции предоставляют серверные части, с которыми могут взаимодействовать сайты магазинов. Они управляют всеми процессами и данными, связанными с магазином, такими как клиенты, заказы, отгрузки, платежи и так далее. Все, что нужно, это внешний интерфейс для взаимодействия с этой информацией. Это дает владельцам большую гибкость, когда дело доходит до принятия решения о том, как их клиенты будут работать с их интернет-магазином и как они решат его запустить.
В этой статье мы расскажем, как создать магазин электронной коммерции с использованием Angular 11. Мы будем использовать Commerce Layer в качестве нашего безголового API для электронной коммерции. Хотя может быть множество способов обработки платежей, мы продемонстрируем, как использовать только один, Paypal.
- Посмотреть исходный код на GitHub →
Предпосылки
Перед созданием приложения вам необходимо установить Angular CLI. Мы будем использовать его для инициализации и создания шаблона приложения. Если он у вас еще не установлен, вы можете получить его через npm.
npm install -g @angular/cli
Вам также потребуется учетная запись разработчика Commerce Layer. Используя учетную запись разработчика, вам нужно будет создать тестовую организацию и заполнить ее тестовыми данными. Заполнение упрощает сначала разработку приложения, не беспокоясь о том, какие данные вам придется использовать. Вы можете создать учетную запись по этой ссылке и организацию здесь.
Наконец, вам понадобится учетная запись Paypal Sandbox. Наличие этого типа учетной записи позволит нам тестировать транзакции между предприятиями и пользователями, не рискуя реальными деньгами. Вы можете создать его здесь. Для учетной записи песочницы уже созданы тестовая бизнес- и тестовая личная учетная запись.
Уровень коммерции и конфигурация Paypal
Чтобы сделать платежи в песочнице Paypal возможными на Commerce Layer, вам необходимо настроить ключи API. Перейдите к обзору учетных записей вашей учетной записи разработчика Paypal. Выберите бизнес-аккаунт, и на вкладке «Учетные данные API» сведений об учетной записи вы найдете приложение по умолчанию в разделе «Приложения REST ».
Чтобы связать свой бизнес-аккаунт Paypal с вашей организацией Commerce Layer, перейдите на панель инструментов вашей организации. Здесь вы добавите платежный шлюз Paypal и способ оплаты Paypal для различных рынков. В разделе «Настройки» > «Платежи » выберите «Платежные шлюзы» > «Paypal» и добавьте идентификатор и секрет клиента Paypal.
После создания шлюза вам нужно будет создать способ оплаты Paypal для каждого рынка, на который вы ориентируетесь, чтобы сделать PayPal доступным в качестве опции. Это можно сделать в разделе «Настройки» > «Платежи» > «Способы оплаты» > «Новый способ оплаты» .
Примечание об используемых маршрутах
Commerce Layer предоставляет маршрут для аутентификации и другой набор маршрутов для своего API. Их маршрут аутентификации /oauth/token
обменивает учетные данные на токен. Этот токен необходим для доступа к их API. Остальные маршруты API используют шаблон /api/:resource
.
Объем этой статьи охватывает только интерфейсную часть этого приложения. Я решил хранить токены на стороне сервера, использовать сеансы для отслеживания владения и предоставлять клиенту файлы cookie только для http с идентификатором сеанса. Это не будет рассматриваться здесь, так как это выходит за рамки данной статьи. Однако маршруты остаются прежними и в точности соответствуют API Commerce Layer. Хотя есть несколько настраиваемых маршрутов, недоступных в API Commerce Layer, которые мы будем использовать. В основном они касаются управления сессиями. Я укажу на них, когда мы доберемся до них, и опишу, как можно добиться аналогичного результата.
Еще одно несоответствие, которое вы можете заметить, заключается в том, что тело запроса отличается от того, что требует API Commerce Layer. Поскольку запросы передаются на другой сервер для заполнения токеном, я по-другому структурировал тела. Это было сделано для того, чтобы упростить отправку запросов. Всякий раз, когда в теле запроса есть какие-либо несоответствия, они будут указаны в службах.
Поскольку это выходит за рамки, вам придется решить, как безопасно хранить токены. Вам также потребуется немного изменить тела запросов, чтобы они точно соответствовали требованиям Commerce Layer API. В случае несоответствия я буду ссылаться на справку по API и руководства, подробно описывающие, как правильно структурировать тело.
Структура приложения
Чтобы организовать приложение, мы разобьем его на четыре основные части. Лучшее описание того, что делает каждый из модулей, дано в соответствующих разделах:
- основной модуль,
- модуль данных,
- общий модуль,
- функциональные модули.
Функциональные модули будут группировать связанные страницы и компоненты вместе. Будет четыре функциональных модуля:
- модуль авторизации,
- модуль продукта,
- модуль тележки,
- кассовый модуль.
По мере того, как мы переходим к каждому модулю, я объясню, какова его цель, и разберу его содержание.
Ниже показано дерево папки src/app
и местонахождение каждого модуля.
src ├── app │ ├── core │ ├── data │ ├── features │ │ ├── auth │ │ ├── cart │ │ ├── checkout │ │ └── products └── shared
Создание приложения и добавление зависимостей
Мы начнем с создания приложения. Наша организация будет называться The LIme Brand и будет иметь тестовые данные, уже заполненные Commerce Layer.
ng new lime-app
Нам понадобится пара зависимостей. В основном угловой материал и до уничтожения. Angular Material предоставит компоненты и стиль. Пока Destroy автоматически отменяет подписку на observables, когда компоненты уничтожаются. Для их установки запустите:
npm install @ngneat/until-destroy ng add @angular/material
Ресурсы
При добавлении адресов в Commerce Layer необходимо использовать код страны alpha-2. Мы добавим файл json, содержащий эти коды, в папку ресурсов в assets
assets/json/country-codes.json
. Вы можете найти этот файл по ссылке здесь.
Стили
Компоненты, которые мы создадим, имеют общие стили. Разместим их в styles.css
, который можно найти по этой ссылке.
Окружающая обстановка
Наша конфигурация будет состоять из двух полей. apiUrl
, который должен указывать на API Commerce Layer. apiUrl
используется службами, которые мы создадим для получения данных. clientUrl
должен быть доменом, в котором работает приложение. Мы используем это при настройке URL-адресов перенаправления для Paypal. Вы можете найти этот файл по этой ссылке.
Общий модуль
Общий модуль будет содержать службы, каналы и компоненты, общие для других модулей.
ng gm shared
Он состоит из трех компонентов, одной трубы и двух сервисов. Вот как это будет выглядеть.
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
Мы также будем использовать общий модуль для экспорта некоторых часто используемых компонентов Angular Material. Это упрощает их использование «из коробки» вместо того, чтобы импортировать каждый компонент в различные модули. Вот что будет содержать shared.module.ts
.
@NgModule({ declarations: [SimplePageComponent, TitleComponent, WordWrapPipe, ItemQuantityComponent], imports: [CommonModule, MatIconModule, MatButtonModule, MatTooltipModule, MatMenuModule, RouterModule], exports: [ CommonModule, ItemQuantityComponent, MatButtonModule, MatIconModule, MatSnackBarModule, MatTooltipModule, SimplePageComponent, TitleComponent, WordWrapPipe ] }) export class SharedModule { }
Компоненты
Деталь Количество Компонент
Этот компонент задает количество товаров при добавлении их в корзину. Он будет использоваться в модулях корзины и товаров. Селектор материала был бы легким выбором для этой цели. Однако стиль выбора материала не соответствовал материалам, используемым во всех других формах. Меню материалов выглядело очень похоже на используемые материалы. Поэтому я решил вместо этого создать компонент выбора.
ng gc shared/components/item-quantity
Компонент будет иметь три входных свойства и одно выходное свойство. quantity
устанавливает начальное количество элементов, maxValue
указывает максимальное количество элементов, которые можно выбрать за один раз, а disabled
указывает, следует ли отключать компонент или нет. setQuantityEvent
запускается при выборе количества.
Когда компонент будет инициализирован, мы установим значения, которые появятся в меню материалов. Также существует метод setQuantity
, который генерирует события setQuantityEvent
.
Это файл компонента.
@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); } }
Это его шаблон.
<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>
Вот его стиль.
button { margin: 3px; }
Компонент заголовка
Этот компонент используется как пошаговый заголовок, а также как простой заголовок на некоторых более простых страницах. Хотя Angular Material предоставляет степперный компонент, он не подходил для довольно длительного процесса оформления заказа, не был таким отзывчивым на небольших дисплеях и требовал гораздо больше времени для реализации. Однако более простой заголовок может быть перепрофилирован в качестве индикатора шагового двигателя и может быть полезен на нескольких страницах.
ng gc shared/components/title
Компонент имеет четыре входных свойства: title
, subtitle
, номер ( no
) и centerText
, чтобы указать, следует ли центрировать текст компонента.
@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; }
Ниже представлен его шаблон. Вы можете найти его стиль, связанный здесь.
<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>
Простой компонент страницы
Есть несколько случаев, когда заголовок, значок и кнопка были всем, что было необходимо для страницы. К ним относятся страница 404, страница пустой корзины, страница ошибки, страница оплаты и страница размещения заказа. Этой цели будет служить простой компонент страницы. Когда кнопка на странице нажата, она либо перенаправит на маршрут, либо выполнит какое-то действие в ответ на buttonEvent
.
Чтобы сделать это:
ng gc shared/components/simple-page
Это его компонентный файл.
@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(); } } }
И его шаблон содержит:
<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>
Его стиль можно найти здесь.
Трубы
Труба переноса слов
Названия некоторых продуктов и другая информация, отображаемая на сайте, очень длинная. В некоторых случаях заставить эти длинные предложения обернуть материальные компоненты сложно. Таким образом, мы будем использовать эту вертикальную черту, чтобы сократить предложения до заданной длины и добавить многоточие в конец результата.
Чтобы создать его, выполните:
ng g pipe shared/pipes/word-wrap
Он будет содержать:
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)}...`; } }
Услуги
Служба обработчика ошибок HTTP
В этом проекте довольно много http-сервисов. Создание обработчика ошибок для каждого метода повторяется. Поэтому имеет смысл создать один единственный обработчик, который может использоваться всеми методами. Обработчик ошибок можно использовать для форматирования ошибки, а также для передачи ошибок на другие внешние платформы ведения журналов.
Сгенерируйте его, запустив:
ng gs shared/services/http-error-handler
Этот сервис будет содержать только один метод. Метод отформатирует отображаемое сообщение об ошибке в зависимости от того, является ли это ошибкой клиента или сервера. Тем не менее, есть возможности для его дальнейшего улучшения.
@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); } }
Служба локального хранилища
Мы будем использовать локальное хранилище для отслеживания количества товаров в корзине. Также полезно хранить здесь идентификатор заказа. Заказ соответствует корзине на Commerce Layer.
Чтобы сгенерировать службу локального хранилища, запустите:
ng gs shared/services/local-storage
Сервис будет содержать четыре метода для добавления, удаления и получения элементов из локального хранилища и еще один для его очистки.
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(); } }
Модуль данных
Этот модуль отвечает за поиск и управление данными. Это то, что мы будем использовать для получения данных, потребляемых нашим приложением. Ниже приведена его структура:
src/app/data ├── data.module.ts ├── models └── services
Чтобы сгенерировать модуль, выполните:
ng gm data
Модели
Модели определяют, как структурированы данные, которые мы получаем из API. У нас будет 16 объявлений интерфейса. Для их создания запустите:
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
Следующая таблица содержит ссылки на каждый файл и дает описание каждого интерфейса.
Интерфейс | Описание |
---|---|
Адрес | Представляет общий адрес. |
Корзина | Клиентская версия заказа, отслеживающая количество продуктов, которые клиент намеревается приобрести. |
Страна | Код страны Альфа-2. |
Адрес клиента | Адрес, связанный с клиентом. |
Клиент | Зарегистрированный пользователь. |
Время выполнения поставки | Представляет собой количество времени, которое потребуется для доставки груза. |
Позиция | Товар добавлен в корзину. |
Заказ | Корзина или набор позиций. |
Способ оплаты | Тип оплаты, доступный для заказа. |
Источник платежа | Платеж, связанный с заказом. |
Paypal Оплата | Оплата через Paypal |
Цена | Цена, связанная с SKU. |
Отгрузка | Сбор предметов, отправленных вместе. |
способ доставки | Метод, с помощью которого отправляется пакет. |
Артикул | Уникальная складская единица. |
Расположение склада | Местоположение, содержащее запасы SKU. |
Услуги
Эта папка содержит службы, которые создают, извлекают и управляют данными приложения. Здесь мы создадим 11 сервисов.
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
Адресная служба
Эта служба создает и извлекает адреса. Это важно при создании и назначении адресов доставки и выставления счетов заказам. Он имеет два метода. Один для создания адреса, а другой для его извлечения.
Здесь используется маршрут /api/addresses
. Если вы собираетесь использовать API Commerce Layer напрямую, обязательно структурируйте данные, как показано в этом примере.
@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)); } }
Корзина Сервис
Корзина отвечает за сохранение количества добавленных товаров и идентификатора заказа. Выполнение вызовов API для получения количества товаров в заказе каждый раз, когда создается новая позиция, может быть дорогостоящим. Вместо этого мы могли бы просто использовать локальное хранилище для ведения подсчета на клиенте. Это избавляет от необходимости делать ненужные выборки заказов каждый раз, когда товар добавляется в корзину.
Мы также используем этот сервис для хранения идентификатора заказа. Корзина соответствует заказу на Commerce Layer. После добавления первого товара в корзину создается заказ. Нам нужно сохранить этот идентификатор заказа, чтобы мы могли получить его в процессе оформления заказа.
Кроме того, нам нужен способ сообщить заголовку, что товар добавлен в корзину. Заголовок содержит кнопку корзины и отображает количество товаров в ней. Мы будем использовать наблюдаемую BehaviorSubject
с текущим значением корзины. Шапка может подписаться на это и отслеживать изменения стоимости корзины.
Наконец, после завершения заказа необходимо очистить стоимость корзины. Это гарантирует отсутствие путаницы при создании последующих новых заказов. Сохраненные значения удаляются после того, как текущий заказ помечен как размещенный.
Все это мы выполним с помощью созданной ранее службы локального хранилища.
@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 }); } }
Страна службы
При добавлении адресов на Commerce Layer код страны должен быть кодом alpha 2. Этот сервис считывает файл json, содержащий эти коды для каждой страны, и возвращает его в своем методе 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'); } }
Служба адресации клиентов
Этот сервис используется для связывания адресов с клиентами. Он также извлекает определенный или все адреса, связанные с клиентом. Он используется, когда клиент добавляет свой адрес доставки и платежный адрес в свой заказ. Метод createCustomer
создает клиента, getCustomerAddresses
получает все адреса клиента, а getCustomerAddress
получает конкретный адрес.
При создании адреса клиента обязательно структурируйте текст сообщения в соответствии с этим примером.
@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)); } }
Обслуживание клиентов
С помощью этой службы создаются клиенты и извлекается их информация. Когда пользователь регистрируется, он становится клиентом и создается с помощью createCustomerMethod
. getCustomer
возвращает клиента, связанного с определенным идентификатором. getCurrentCustomer
возвращает клиента, вошедшего в систему в данный момент.
При создании клиента структурируйте данные следующим образом. Вы можете добавить их имена и фамилии в метаданные, как показано в их атрибутах.
Маршрут /api/customers/current
недоступен в Commerce Layer. Поэтому вам нужно выяснить, как получить текущего зарегистрированного клиента.
@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)); } }
Служба сроков доставки
Эта служба возвращает информацию о сроках доставки из различных складских мест.
@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)); } }
Служба позиции
Товары, добавленные в корзину, управляются этой службой. С его помощью вы можете создать товар в момент его добавления в корзину. Информация об элементе также может быть извлечена. Товар также может обновляться при изменении его количества или удаляться при удалении из корзины.
При создании элементов или их обновлении структурируйте текст запроса, как показано в этом примере.
@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)); } }
Заказать услугу
Подобно службе позиций, служба заказов позволяет создавать, обновлять, удалять или получать заказы. Кроме того, вы можете получить поставки, связанные с заказом, отдельно, используя метод getOrderShipments
. Эта услуга активно используется на протяжении всего процесса оформления заказа.
Существуют различные виды информации о заказе, которые требуются при оформлении заказа. Поскольку получение всего заказа и его отношений может быть дорогостоящим, мы указываем, что мы хотим получить от заказа, используя GetOrderParams
. Эквивалентом этого в CL API является параметр запроса включения, в котором вы указываете отношения порядка, которые необходимо включить. Вы можете проверить, какие поля необходимо включить для сводки корзины здесь и для различных этапов оформления заказа здесь.
Точно так же при обновлении заказа мы используем UpdateOrderParams
для указания полей обновления. Это связано с тем, что на сервере, который заполняет токен, выполняются некоторые дополнительные операции в зависимости от того, какое поле обновляется. Однако если вы отправляете прямые запросы к CL API, указывать это не нужно. Вы можете покончить с этим, поскольку CL API не требует их указания. Хотя тело запроса должно напоминать этот пример.
@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)); } }
Платежный сервис PayPal
Эта служба отвечает за создание и обновление платежей Paypal для заказов. Кроме того, мы можем получить платеж Paypal, учитывая его идентификатор. Тело сообщения должно иметь структуру, аналогичную этому примеру при создании платежа 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)); } }
Служба доставки
Этот сервис получает отправление или обновляет его с учетом его идентификатора. Текст запроса обновления доставки должен выглядеть примерно так.
@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)); } }
Артикул Сервис
Сервис SKU получает товары из магазина. Если извлекается несколько продуктов, они могут быть разбиты на страницы и иметь установленный размер страницы. Размер страницы и номер страницы должны быть установлены как параметры запроса, как в этом примере, если вы делаете прямые запросы к API. Отдельный продукт также может быть получен по его идентификатору.
@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)); } }
Основной модуль
Основной модуль содержит все самое важное и общее для приложения. К ним относятся такие компоненты, как заголовок и страницы, такие как страница 404. Сюда же попадают сервисы, отвечающие за аутентификацию и управление сессиями, а также перехватчики и гвардейцы всего приложения.
Дерево основных модулей будет выглядеть так.
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
Чтобы сгенерировать модуль и его содержимое, запустите:
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
Файл основного модуля должен выглядеть так. Обратите внимание, что маршруты были зарегистрированы для NotFoundComponent
и 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 { }
Услуги
Папка services содержит службы проверки подлинности, сеанса и заголовков.
Служба аутентификации
AuthenticationService
позволяет вам получать токены клиентов и клиентов. Эти токены используются для доступа к остальным маршрутам API. Токены клиента возвращаются, когда пользователь обменивается адресом электронной почты и паролем для него и имеет более широкий диапазон разрешений. Клиентские токены выдаются без учетных данных и имеют более узкие разрешения.
getClientSession
получает токен клиента. login
получает токен клиента. Оба метода также создают сеанс. Тело запроса токена клиента должно выглядеть так, а тело токена клиента — вот так.
@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); } }
Компоненты
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.
Существует метод logout
из системы, который уничтожает сеанс пользователя и присваивает ему токен клиента. Токен клиента назначается, потому что сеанс, поддерживающий токен клиента, уничтожен, а токен по-прежнему требуется для каждого запроса API. Материальная закусочная сообщает пользователю, был ли его сеанс успешно уничтожен или нет.
Мы используем @UntilDestroy({ checkProperties: true })
, чтобы указать, что все подписки должны автоматически отменяться при уничтожении компонента.
@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 }) ); } }
Ниже приведен шаблон заголовка, а здесь приведен его стиль.
<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>
Охранники
Охранник пустой тележки
Эта защита не позволяет пользователям получить доступ к маршрутам, связанным с оформлением заказа и выставлением счетов, если их корзина пуста. Это связано с тем, что для продолжения оформления заказа должен быть действительный заказ. Заказ соответствует корзине с товарами в ней. Если в корзине есть товары, пользователь может перейти на защищенную страницу. Однако, если корзина пуста, пользователь перенаправляется на страницу с пустой корзиной.
@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'); } }
Перехватчики
Опции Перехватчик
Этот перехватчик перехватывает все исходящие HTTP-запросы и добавляет к запросу две опции. Это заголовок Content-Type
и свойство withCredentials
. withCredentials
указывает, следует ли отправлять запрос с исходящими учетными данными, такими как файлы cookie только для http, которые мы используем. Мы используем Content-Type
, чтобы указать, что мы отправляем ресурсы json на сервер.
@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); } }
Модули функций
В этом разделе собраны основные функции приложения. Как упоминалось ранее, функции сгруппированы в четыре модуля: модули авторизации, продукта, корзины и оформления заказа.
Модуль продуктов
Модуль продуктов содержит страницы, на которых отображаются товары со скидкой. К ним относятся страница продукта и страница списка продуктов. Он структурирован, как показано ниже.
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
Чтобы сгенерировать его и его компоненты:
ng gm features/products ng gc features/products/pages/product ng gc features/products/pages/product-list
Это файл модуля:
@NgModule({ declarations: [ProductListComponent, ProductComponent], imports: [ RouterModule.forChild([ { path: 'product/:id', component: ProductComponent }, { path: '', component: ProductListComponent } ]), LayoutModule, MatCardModule, MatGridListModule, MatPaginatorModule, SharedModule ] }) export class ProductsModule { }
Компонент списка продуктов
Этот компонент отображает список доступных для продажи продуктов с разбивкой на страницы. Это первая страница, которая загружается при запуске приложения.
Товары отображаются в виде сетки. Список материалов сетки является лучшим компонентом для этого. Чтобы сделать сетку отзывчивой, количество столбцов сетки будет меняться в зависимости от размера экрана. Сервис BreakpointObserver
позволяет нам определять размер экрана и назначать столбцы во время инициализации.
Чтобы получить продукты, мы вызываем метод getProducts
SkuService
. Он возвращает продукты в случае успеха и присваивает их сетке. Если нет, мы направляем пользователя на страницу ошибки.
Поскольку отображаемые продукты разбиты на страницы, у нас будет метод getNextPage
для получения дополнительных продуктов.
@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}`; } }
Шаблон показан ниже, а его стиль можно найти здесь.
<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>
Страница будет выглядеть так.
Компонент продукта
Когда продукт выбран на странице со списком продуктов, этот компонент отображает его детали. К ним относятся полное название продукта, цена и описание. Также есть кнопка добавления товара в корзину.
При инициализации мы получаем идентификатор продукта из параметров маршрута. Используя идентификатор, мы получаем продукт из SkuService
.
Когда пользователь добавляет товар в корзину, вызывается метод addItemToCart
. В нем проверяем, создан ли уже заказ для корзины. Если нет, создается новый с помощью OrderService
. После этого создается позиция в том порядке, который соответствует товару. Если для корзины уже существует заказ, создается только позиция. В зависимости от статуса запросов пользователю отображается сообщение панели закусок.
@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 }); } }
Шаблон ProductComponent
выглядит следующим образом, и его стиль связан здесь.
<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>
Страница будет выглядеть так.
Модуль авторизации
Модуль Auth содержит страницы, отвечающие за аутентификацию. К ним относятся страницы входа и регистрации. Он устроен следующим образом.
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
Чтобы сгенерировать его и его компоненты:
ng gm features/auth ng gc features/auth/pages/signup ng gc features/auth/pages/login
Это его модульный файл.
@NgModule({ declarations: [LoginComponent, SignupComponent], imports: [ RouterModule.forChild([ { path: 'login', component: LoginComponent }, { path: 'signup', component: SignupComponent } ]), MatFormFieldModule, MatInputModule, ReactiveFormsModule, SharedModule ] }) export class AuthModule { }
Компонент регистрации
Пользователь регистрирует учетную запись с помощью этого компонента. Для процесса требуются имя, фамилия, адрес электронной почты и пароль. Пользователю также необходимо подтвердить свой пароль. Поля ввода будут созданы с помощью сервиса FormBuilder
. Добавлена проверка, требующая, чтобы все входные данные имели значения. В поле пароля добавлена дополнительная проверка, чтобы гарантировать минимальную длину в восемь символов. Пользовательский валидатор matchPasswords
гарантирует, что подтвержденный пароль совпадает с исходным паролем.
Когда компонент инициализируется, кнопки корзины, входа и выхода в заголовке скрыты. Это передается в заголовок с помощью HeaderService
.
После того, как все поля отмечены как действительные, пользователь может зарегистрироваться. В методе signup
метод createCustomer
службы CustomerService
получает эти входные данные. Если регистрация прошла успешно, пользователю сообщается, что его учетная запись была успешно создана с помощью закусочной. Затем они перенаправляются на главную страницу.
@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 }) ); } }
Ниже приведен шаблон для 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>
Компонент получится следующим.
Компонент входа
Зарегистрированный пользователь входит в свою учетную запись с помощью этого компонента. Электронная почта и пароль должны быть введены. Соответствующие им поля ввода будут иметь проверку, которая делает их обязательными.
Подобно SignupComponent
, кнопки корзины, входа и выхода в заголовке скрыты. Их видимость устанавливается с помощью HeaderService
при инициализации компонента.
Для входа учетные данные передаются AuthenticationService
. В случае успеха статус входа пользователя устанавливается с помощью SessionService
. Затем пользователь перенаправляется обратно на страницу, на которой он находился. В случае неудачи отображается закусочная с ошибкой и сбрасывается поле пароля.
@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: '' }); } ); } }
Ниже приведен шаблон 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>
Вот скриншот страницы.
Модуль корзины
Модуль корзины содержит все страницы, связанные с корзиной. К ним относятся сводная страница заказа, кодовая страница купона и подарочной карты, а также пустая страница корзины. Он устроен следующим образом.
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
Чтобы сгенерировать его, запустите:
ng gm features/cart ng gc features/cart/codes ng gc features/cart/empty ng gc features/cart/summary
Это файл модуля.
@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 { }
Коды Компонент
Как упоминалось ранее, этот компонент используется для добавления в заказ любых кодов купонов или подарочных карт. Это позволяет пользователю применять скидки к общей сумме заказа, прежде чем приступить к оформлению заказа.
Будет два поля ввода. Один для купонов, а другой для кодов подарочных карт.
Коды добавляются при обновлении заказа. Метод updateOrder
службы OrderService
обновляет заказ с помощью кодов. После чего оба поля сбрасываются и пользователю сообщается об успешности операции с закусочной. Закусочная также отображается при возникновении ошибки. Оба addCoupon
и addGiftCard
вызывают метод 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'); } }
Шаблон показан ниже, а его стиль можно найти по этой ссылке.
<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>
Вот скриншот страницы.
Пустой компонент
Выезд с пустой корзиной невозможен. Должен быть охранник, который не позволит пользователям получить доступ к страницам модуля проверки с пустыми корзинами. Это уже было рассмотрено как часть CoreModule
. Guard перенаправляет запросы на страницы оформления заказа с пустой корзиной на EmptyCartComponent
.
Это очень простой компонент, в котором есть текст, указывающий пользователю, что его корзина пуста. У него также есть кнопка, которую пользователь может нажать, чтобы перейти на домашнюю страницу, чтобы добавить вещи в свою корзину. Поэтому мы будем использовать SimplePageComponent
для его отображения. Вот шаблон.
<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>
Вот скриншот страницы.
Сводный компонент
Этот компонент суммирует корзину/заказ. В нем перечислены все товары в корзине, их названия, количество и изображения. Он дополнительно разбивает стоимость заказа, включая налоги, доставку и скидки. Пользователь должен иметь возможность просмотреть это и решить, удовлетворены ли они товарами и стоимостью, прежде чем приступить к оформлению заказа.
При инициализации заказ и его позиции извлекаются с помощью OrderService
. Пользователь должен иметь возможность изменять позиции или даже удалять их из заказа. Элементы удаляются при вызове метода deleteLineItem
. В нем метод deleteLineItem
службы LineItemService
получает идентификатор удаляемой позиции. Если удаление прошло успешно, мы обновляем количество товаров в корзине с помощью CartService
.
Затем пользователь перенаправляется на страницу клиента, где он начинает процесс оформления заказа. Метод checkout
выполняет маршрутизацию.
@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') ); } }
Ниже приведен шаблон, и его стиль связан здесь.
<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>
Вот скриншот страницы.
Кассовый модуль
Этот модуль отвечает за процесс оформления заказа. Оформление заказа включает в себя предоставление адреса для выставления счетов и доставки, адрес электронной почты клиента и выбор способа доставки и оплаты. Последним шагом этого процесса является размещение и подтверждение заказа. Структура модуля следующая.
src/app/features/checkout/ ├── components │ ├── address │ ├── address-list │ └── country-select └── pages ├── billing-address ├── cancel-payment ├── customer ├── payment ├── place-order ├── shipping-address └── shipping-methods
Этот модуль на сегодняшний день является самым большим и содержит 3 компонента и 7 страниц. Чтобы сгенерировать его и его компоненты, запустите:
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
Это файл модуля.
@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 { }
Компоненты
Компонент выбора страны
Этот компонент позволяет пользователю выбрать страну как часть адреса. Компонент выбора материала имеет совершенно другой вид по сравнению с полями ввода в адресной форме. Поэтому для единообразия вместо этого используется компонент материального меню.
Когда компонент инициализируется, данные кода страны извлекаются с помощью CountryService
. Свойство countries
содержит значения, возвращаемые службой. Эти значения будут добавлены в меню в шаблоне.
Компонент имеет одно выходное свойство, setCountryEvent
. Когда страна выбрана, это событие выдает альфа-2 код страны.
@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); }}
Ниже приведен его шаблон, а здесь приведен его стиль.
<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>
Компонент адреса
Это форма для захвата адресов. Он используется как на странице адреса доставки, так и на странице адреса выставления счета. Действительный адрес Commerce Layer должен содержать имя и фамилию, адресную строку, город, почтовый индекс, код штата, код страны и номер телефона.
Служба FormBuilder
создаст группу форм. Поскольку этот компонент используется несколькими страницами, он имеет ряд входных и выходных свойств. Свойства ввода включают текст кнопки, отображаемый заголовок и текст флажка. Выходными свойствами будут эмиттеры событий при нажатии кнопки для создания адреса и другие при изменении значения флажка.
При нажатии кнопки вызывается метод addAddress
, и событие createAddress
выдает полный адрес. Точно так же, когда флажок установлен, событие isCheckboxChecked
выдает значение флажка.
@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); } }
Вот его шаблон. Вы можете найти его стиль здесь.
<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>
Страницы
Компонент клиента
Заказ должен быть связан с адресом электронной почты. Этот компонент представляет собой форму, которая фиксирует адрес электронной почты клиента. Когда компонент инициализируется, текущий адрес электронной почты клиента извлекается, если он вошел в систему. Мы получаем клиента из CustomerService
. Если они не хотят менять свой адрес электронной почты, этот адрес электронной почты будет значением по умолчанию.
Если адрес электронной почты изменен или клиент не вошел в систему, заказ обновляется с введенным адресом электронной почты. Мы используем OrderService
для обновления заказа с новым адресом электронной почты. В случае успеха мы направляем клиента на страницу платежного адреса.
@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 }) ); } }
Вот шаблон компонента, а вот его стиль.
<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>
Вот скриншот страницы клиента.
Компонент платежного адреса
Компонент платежного адреса позволяет покупателю либо добавить новый платежный адрес, либо выбрать один из существующих адресов. Пользователи, которые не вошли в систему, должны ввести новый адрес. Те, кто вошел в систему, получают возможность выбирать между новыми или существующими адресами.
Свойство showAddress
указывает, должны ли существующие адреса отображаться в компоненте. sameShippingAddressAsBilling
указывает, должен ли адрес доставки совпадать с установленным адресом для выставления счетов. Когда клиент выбирает существующий адрес, его идентификатор назначается selectedCustomerAddressId
.
Когда компонент инициализируется, мы используем SessionService
, чтобы проверить, вошел ли текущий пользователь в систему. Если они вошли в систему, мы отобразим их существующие адреса, если они есть.
Как упоминалось ранее, если пользователь вошел в систему, он может выбрать существующий адрес в качестве своего платежного адреса. В методе updateBillingAddress
, если они вошли в систему, выбранный ими адрес клонируется и устанавливается в качестве платежного адреса заказа. Мы делаем это, обновляя заказ, используя метод updateOrder
службы OrderService
и предоставляя идентификатор адреса.
Если они не вошли в систему, пользователь должен указать адрес. После предоставления адрес создается с помощью метода createAddress
. В нем AddressService
принимает ввод и создает новый адрес. После чего заказ обновляется по id только что созданного адреса. Если есть ошибка или какая-либо операция прошла успешно, мы показываем закусочную.
Если тот же адрес выбран в качестве адреса доставки, пользователь перенаправляется на страницу способов доставки. Если они хотят указать альтернативный адрес доставки, они перенаправляются на страницу адреса доставки.
@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'); } } }
Вот шаблон. Эта ссылка указывает на его стиль.
<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>
Вот так будет выглядеть страница платежного адреса.
Компонент адреса доставки
Компонент адреса доставки во многом похож на компонент адреса выставления счета. Однако есть несколько отличий. Во-первых, текст, отображаемый в шаблоне, отличается. Другие ключевые отличия заключаются в том, как заказ обновляется с помощью OrderService
после создания или выбора адреса. Поля, которые обновляет заказ, — это shippingAddressCloneId
для выбранных адресов и shippingAddress
для новых адресов. Если пользователь решает изменить адрес выставления счетов, чтобы он совпадал с адресом доставки, поле billingAddressSameAsShipping
обновляется.
После выбора адреса доставки и обновления заказа пользователь перенаправляется на страницу способов доставки.
@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); } }
Вот шаблон и его стиль можно найти здесь.
<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>
Страница адреса доставки будет выглядеть так.
Компонент «Способы доставки»
Этот компонент отображает количество отгрузок, необходимых для выполнения заказа, доступные способы доставки и связанные с ними расходы. Затем клиент может выбрать способ доставки, который он предпочитает для каждой партии.
Свойство shippings содержит все shipments
заказа. ShippingsForm — это форма, в которой будет сделан выбор способа shipmentsForm
.
Когда компонент инициализируется, заказ извлекается и будет содержать как позиции, так и отгрузки. В то же время мы получаем сроки доставки для различных способов доставки. Мы используем OrderService
для получения заказа и DeliveryLeadTimeService
для времени выполнения заказа. Когда оба набора информации возвращены, они объединяются в массив shipments
и назначаются свойству shippings. Каждая поставка будет содержать свои товары, доступные способы доставки и соответствующую стоимость.
После того, как пользователь выбрал способ доставки для каждого отправления, выбранный способ доставки обновляется для каждого в setShipmentMethods
. В случае успеха пользователь перенаправляется на страницу платежей.
@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]; } }
Вот шаблон, и вы можете найти стиль по этой ссылке.
<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>
Это скриншот страницы способов доставки.
Платежный компонент
В этом компоненте пользователь нажимает кнопку оплаты, если он хочет продолжить оплату своего заказа через Paypal. approvalUrl
— это ссылка Paypal, на которую перенаправляется пользователь, когда он нажимает кнопку.
При инициализации мы получаем заказ с включенным источником оплаты с помощью OrderService
. Если источник платежа установлен, мы получаем его идентификатор и получаем соответствующий платеж Paypal из PaypalPaymentService
. Платеж Paypal будет содержать URL-адрес подтверждения. Если источник оплаты не указан, мы обновляем заказ, указав Paypal в качестве предпочтительного способа оплаты. Затем мы приступаем к созданию нового платежа Paypal для заказа, используя PaypalPaymentService
. Отсюда мы можем получить URL-адрес утверждения из вновь созданного заказа.
Наконец, когда пользователь нажимает кнопку, он перенаправляется в Paypal, где может подтвердить покупку.
@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; } }
Вот его шаблон.
<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>
Вот как будет выглядеть страница платежей.
Компонент отмены платежа
Paypal требует страницу отмены платежа. Этот компонент служит этой цели. Это его шаблон.
<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>
Вот скриншот страницы.
Компонент «Разместить заказ»
Это последний шаг в процессе оформления заказа. Здесь пользователь подтверждает, что действительно хочет разместить заказ и начать его обработку. Когда пользователь подтверждает платеж Paypal, он перенаправляется на эту страницу. Paypal добавляет к URL-адресу параметр запроса идентификатора плательщика. Это идентификатор пользователя Paypal.
Когда компонент инициализируется, мы получаем параметр запроса payerId
из URL. Затем заказ извлекается с помощью OrderService
с включенным источником оплаты. Идентификатор включенного источника платежа используется для обновления платежа Paypal с помощью идентификатора плательщика с помощью службы PaypalPayment
. Если какой-либо из них не работает, пользователь перенаправляется на страницу с ошибкой. Мы используем свойство disableButton
, чтобы запретить пользователю размещать заказ до тех пор, пока не будет установлен идентификатор плательщика.
Когда они нажимают кнопку размещения заказа, заказ обновляется со статусом placed
. После чего корзина очищается, отображается успешная закусочная, а пользователь перенаправляется на домашнюю страницу.
@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; } ); } }
Вот шаблон и связанный с ним стиль.
<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>
Вот скриншот страницы.
Модуль приложения
Все запросы к Commerce Layer, кроме аутентификации, должны содержать токен. Таким образом, в момент инициализации приложения токен извлекается из маршрута /oauth/token
на сервере, и сеанс инициализируется. Мы будем использовать токен APP_INITIALIZER
, чтобы предоставить функцию инициализации, в которой токен извлекается. Кроме того, мы будем использовать токен HTTP_INTERCEPTORS
для предоставления созданного ранее OptionsInterceptor
. После добавления всех модулей файл модуля приложения должен выглядеть примерно так.
@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 { }
Компонент приложения
Мы изменим шаблон компонента приложения и его стиль, который вы можете найти здесь.
<div> <app-header></app-header> <div> <router-outlet></router-outlet> </div> </div>
Заключение
В этой статье мы рассмотрели, как создать приложение электронной коммерции Angular 11 с помощью Commerce Layer и Paypal. Мы также коснулись того, как структурировать приложение и как вы можете взаимодействовать с API электронной коммерции.
Хотя это приложение позволяет покупателю сделать полный заказ, оно ни в коем случае не закончено. Есть так много, что вы могли бы добавить, чтобы улучшить его. Во-первых, вы можете включить изменение количества товаров в корзине, связать элементы корзины со страницами их продуктов, оптимизировать компоненты адреса, добавить дополнительные средства защиты для страниц оформления заказа, таких как страница размещения заказа, и так далее. Это только отправная точка.
Если вы хотите узнать больше о процессе оформления заказа от начала до конца, вы можете ознакомиться с руководствами и API Commerce Layer. Вы можете просмотреть код этого проекта в этом репозитории.