如何使用 Angular 11、Commerce Layer 和 Paypal 構建電子商務網站

已發表: 2022-03-10
快速總結↬隨著越來越多的客戶轉向在線購物,擁有一家電子商務商店對任何店主來說都至關重要。 在本教程中,我們將介紹如何使用 Angular 11 創建電子商務網站。該網站將使用 Commerce Layer 作為無頭電子商務 API,並使用 Paypal 處理付款。

如今,在經營企業時擁有在線形象至關重要。 網上購物比往年多得多。 擁有一家電子商務商店可以讓店主開闢其他收入來源,他們無法通過實體店獲得優勢。 然而,其他店主完全在網上經營他們的業務,沒有實體存在。 這使得擁有在線商店至關重要。

Etsy、Shopify 和 Amazon 等網站可以讓您輕鬆快速地建立商店,而無需擔心開發網站。 但是,在某些情況下,店主可能想要個性化的體驗,或者可能節省在其中一些平台上開店的成本。

無頭電子商務 API 平台提供了商店網站可以與之交互的後端。 他們管理與商店相關的所有流程和數據,例如客戶、訂單、發貨、付款等。 所需要的只是一個與這些信息交互的前端。 在決定客戶如何體驗他們的在線商店以及他們如何選擇經營它時,這為所有者提供了很大的靈活性。

在本文中,我們將介紹如何使用 Angular 11 構建電子商務商店。我們將使用 Commerce Layer 作為我們的無頭電子商務 API。 儘管處理付款的方法可能有很多,但我們將演示如何使用其中一種,Paypal。

  • 在 GitHub 上查看源代碼 →

先決條件

在構建應用程序之前,您需要安裝 Angular CLI。 我們將使用它來初始化和搭建應用程序。 如果您還沒有安裝它,您可以通過 npm 獲取它。

 npm install -g @angular/cli

您還需要一個 Commerce Layer 開發人員帳戶。 使用開發者帳戶,您將需要創建一個測試組織並為其添加測試數據。 播種使首先開發應用程序變得更加容易,而不必擔心您必須使用哪些數據。 您可以在此鏈接上創建一個帳戶,並在此處創建一個組織。

Commerce Layer 開發人員帳戶組織儀表板
Commerce Layer 開發人員帳戶組織儀表板,您可以在其中添加組織。 (大預覽)
Commerce Layer 組織創建表單
創建新組織時,選中帶有測試數據的種子框。 (大預覽)

最後,您將需要一個 Paypal Sandbox 帳戶。 擁有這種類型的帳戶將使我們能夠測試企業和用戶之間的交易,而不會冒實際資金的風險。 您可以在這裡創建一個。 沙盒帳戶已為其創建了測試業務和測試個人帳戶。

跳躍後更多! 繼續往下看↓

商務層和貝寶配置

要使 Commerce Layer 上的 Paypal Sandbox 支付成為可能,您需要設置 API 密鑰。 前往您的 Paypal 開發者帳戶的帳戶概覽。 選擇一個企業帳戶,然後在帳戶詳細信息的 API 憑據選項卡下,您將在REST Apps下找到默認應用程序。

Paypal Sandbox 業務帳戶詳細信息彈出窗口上的 API 憑據選項卡
在 Paypal 企業帳戶詳細信息彈出窗口中的何處可以找到默認 REST 應用程序。 (大預覽)
Paypal 沙盒業務帳戶設置的默認應用程序概述
Paypal Sandbox 業務帳戶設置的默認應用程序概述,您可以在其中獲取 REST API 客戶端 ID 和密碼。 (大預覽)

要將您的 Paypal 企業帳戶與您的 Commerce Layer 組織相關聯,請轉到您組織的儀表板。 在這裡,您將為您的各個市場添加 Paypal 支付網關和 Paypal 支付方式。 在Settings > Payments下,選擇Payment Gateways > Paypal並添加您的 Paypal 客戶端 ID 和密碼。

商務層上的新支付網關儀表板
在 Commerce Layer 儀表板上創建 Paypal 支付網關的位置。 (大預覽)

創建網關後,您需要為每個目標市場創建一個 Paypal 支付方式,以使 Paypal 作為一個選項可用。 您將在Settings > Payments > Payment Methods > New Payment Method下執行此操作。

Commerce Layer 上的付款方式儀表板
在 Commerce Layer 儀表板上創建 Paypal 付款方式的位置。 (大預覽)

關於使用的路線的說明

Commerce Layer 為其 API 提供了一個身份驗證路由和另一組不同的路由。 他們的/oauth/token身份驗證路由交換令牌的憑據。 此令牌是訪問其 API 所必需的。 其餘的 API 路由採用模式/api/:resource

本文的範圍僅涵蓋此應用程序的前端部分。 我選擇在服務器端存儲令牌,使用會話來跟踪所有權,並向客戶端提供帶有會話 ID 的僅 http cookie。 由於超出了本文的範圍,因此此處不會對此進行介紹。 但是,路由保持不變,並且與 Commerce Layer API 完全對應。 雖然,我們將使用一些無法從 Commerce Layer API 獲得的自定義路由。 這些主要處理會話管理。 當我們談到它們時,我會指出這些,並描述如何獲得類似的結果。

您可能注意到的另一個不一致之處是請求正文與 Commerce Layer API 要求的不同。 由於請求被傳遞到另一台服務器以填充令牌,因此我以不同的方式構建了正文。 這是為了更容易發送請求。 每當請求正文中存在任何不一致時,將在服務中指出這些不一致。

由於這超出了範圍,您必須決定如何安全地存儲令牌。 您還需要稍微修改請求正文以完全匹配 Commerce Layer API 的要求。 當存在不一致時,我將鏈接到 API 參考和指南,詳細說明如何正確構建正文。

應用結構

為了組織應用程序,我們將其分為四個主要部分。 每個模塊的功能的更好描述在其相應的部分下給出:

  1. 核心模塊,
  2. 數據模塊,
  3. 共享模塊,
  4. 功能模塊。

功能模塊將相關的頁面和組件組合在一起。 將有四個功能模塊:

  1. 身份驗證模塊,
  2. 產品模塊,
  3. 購物車模塊,
  4. 結帳模塊。

當我們進入每個模塊時,我將解釋其用途並分解其內容。

下面是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/json/country-codes.jsonassets文件夾中。 您可以在此處找到此文件的鏈接。

風格

我們將創建的組件共享一些全局樣式。 我們將把它們放在可以在這個鏈接中找到的styles.css

環境

我們的配置將包含兩個字段。 應該指向 Commerce Layer API 的apiUrl 。 我們將創建的服務使用apiUrl來獲取數據。 clientUrl應該是應用程序運行所在的域。 我們在為 Paypal 設置重定向 URL 時使用它。 您可以在此鏈接中找到此文件。

共享模塊

共享模塊將包含在其他模塊之間共享的服務、管道和組件。

 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 、一個 number ( 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); } }

本地存儲服務

我們將使用本地存儲來跟踪購物車中的物品數量。 在此處存儲訂單的 ID 也很有用。 一個訂單對應 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

下錶鍊接到每​​個文件並描述了每個接口是什麼。

界面描述
地址表示一般地址。
大車訂單的客戶端版本,用於跟踪客戶打算購買的產品數量。
國家Alpha-2 國家代碼。
客戶地址與客戶關聯的地址。
顧客註冊用戶。
交貨時間表示交付貨件所需的時間。
訂單項添加到購物車的逐項產品。
命令購物車或訂單項的集合。
付款方法可用於訂單的付款類型。
付款來源與訂單相關的付款。
貝寶付款通過 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 。 如果您要直接使用 Commerce Layer API,請確保按照本示例中的示例構建數據。

 @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)); } }

購物車服務

購物車負責維護添加的商品數量和訂單 ID。 每次創建新訂單項時調用 API 以獲取訂單中的項目數量可能會很昂貴。 相反,我們可以只使用本地存儲來維護客戶端的計數。 這消除了每次將商品添加到購物車時進行不必要的訂單提取的需要。

我們還使用此服務來存儲訂單 ID。 購物車對應於 Commerce Layer 上的訂單。 將第一個項目添加到購物車後,就會創建一個訂單。 我們需要保留此訂單 ID,以便我們可以在結帳過程中獲取它。

此外,我們需要一種方法來告知標題已將商品添加到購物車。 標題包含購物車按鈕並顯示其中的項目數量。 我們將使用帶有購物車當前值的BehaviorSubject的 observable。 標頭可以訂閱它並跟踪購物車值的變化。

最後,一旦訂單完成,購物車價值需要被清除。 這可確保在創建後續新訂單時不會出現混淆。 一旦當前訂單被標記為已放置,存儲的值將被清除。

我們將使用之前創建的本地存儲服務來完成所有這些工作。

 @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返回與特定 ID 關聯的客戶。 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 付款。 此外,我們可以根據其 ID 獲得 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)); } }

發貨服務

該服務根據其 ID 獲取貨件或更新貨件。 貨件更新的請求正文應類似於此示例。

 @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服務

SKU 服務從商店獲取產品。 如果要檢索多個產品,則可以對它們進行分頁並設置頁面大小。 如果您向 API 發出直接請求,則應將頁面大小和頁碼設置為本示例中的查詢參數。 給定其 id 也可以檢索單個產品。

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

核心模塊

核心模塊包含應用程序的中心和通用的所有內容。 這些包括像標題這樣的組件和像 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

核心模塊文件應該是這樣的。 請注意,已為NotFoundComponentErrorComponent註冊了路由。

 @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.

Screenshot of error page
Screenshot of error page. (大預覽)

Not Found Component

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

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

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指定是否應使用傳出憑據發送請求,例如我們使用的僅 http cookie。 我們使用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); } }

功能模塊

本節包含應用程序的主要功能。 如前所述,這些功能分為四個模塊:auth、product、cart 和 checkout 模塊。

產品模塊

產品模塊包含顯示銷售產品的頁面。 其中包括產品頁面和產品列表頁面。 它的結構如下圖所示。

 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服務允許我們確定屏幕的大小並在初始化期間分配列。

要獲取產品,我們調用SkuServicegetProducts方法。 如果成功,它會返回產品並將它們分配給網格。 如果不是,我們將用戶路由到錯誤頁面。

由於顯示的產品是分頁的,我們將有一個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>

該頁面將如下所示。

產品列表頁面截圖
產品列表頁面的屏幕截圖。 (大預覽)

產品組件

從產品列表頁面中選擇產品後,此組件將顯示其詳細信息。 其中包括產品的全名、價格和描述。 還有一個按鈕可以將商品添加到產品購物車。

在初始化時,我們從路由參數中獲取產品的 id。 使用 id,我們從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驗證器可確保確認的密碼與初始密碼匹配。

當組件初始化時,header 中的購物車、登錄和註銷按鈕被隱藏。這是使用HeaderService與 header 通信的。

在所有字段都標記為有效後,用戶可以註冊。 在signup方法中, CustomerServicecreateCustomer方法接收此輸入。 如果註冊成功,用戶會被告知他們的帳戶是使用快餐欄成功創建的。 然後將它們重新路由到主頁。

 @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 { }

代碼組件

如前所述,此組件用於將任何優惠券或禮品卡代碼添加到訂單中。 這允許用戶在進行結賬之前對他們的訂單總額應用折扣。

將有兩個輸入字段。 一個用於優惠券,另一個用於禮品卡代碼。

通過更新訂單添加代碼。 OrderServiceupdateOrder方法使用代碼更新訂單。 之後,這兩個字段都將被重置,並且用戶會被告知操作成功與一個快餐欄。 發生錯誤時還會顯示一個快餐欄。 addCouponaddGiftCard方法都調用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的一部分進行了介紹。 守衛將帶有空購物車的結帳頁面的請求重定向到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方法時會刪除項目。 LineItemServicedeleteLineItem方法在其中接收要刪除的行項目的 id。 如果刪除成功,我們會使用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 。 選擇國家/地區時,此事件會發出該國家/地區的 alpha-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指示送貨地址是否應與設置的帳單地址相同。 當客戶選擇現有地址時,其 id 將分配給selectedCustomerAddressId

當組件初始化時,我們使用SessionService來檢查當前用戶是否登錄。如果他們已經登錄,我們將顯示他們現有的地址,如果他們有的話。

如前所述,如果用戶登錄,他們可以選擇現有地址作為他們的帳單地址。 在updateBillingAddress方法中,如果他們已登錄,則會克隆他們選擇的地址並將其設置為訂單的帳單地址。 為此,我們使用OrderServiceupdateOrder方法更新訂單並提供地址 ID。

如果他們沒有登錄,用戶必須提供一個地址。 提供後,使用createAddress方法創建地址。 在其中, AddressService接受輸入並生成新地址。 之後,使用新創建地址的 id 更新訂單。 如果有錯誤或任何一個操作成功,我們會顯示一個snackbar。

如果選擇相同的地址作為送貨地址,則將用戶路由到送貨方式頁面。 如果他們想提供備用送貨地址,他們會被定向到送貨地址頁面。

 @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>

送貨地址頁面將如下所示。

收貨地址頁面截圖
收貨地址頁面截圖。 (大預覽)

運輸方式組件

此組件顯示要履行的訂單所需的裝運數量、可用的運輸方式及其相關成本。 然後,客戶可以為每批貨物選擇他們喜歡的運輸方式。

shipping 屬性包含訂單的所有shipments 。 shippingForm 是用於選擇shipmentsForm方式的表格。

初始化組件時,將獲取訂單並將包含其訂單項和發貨。 同時,我們獲得各種運輸方式的交貨提前期。 我們使用OrderService獲取訂單,使用DeliveryLeadTimeService獲取提前期。 一旦返回了這兩組信息,它們就會組合成一個發貨數組並分配給shipments屬性。 每批貨物將包含其物品、可用的運輸方式和相應的成本。

在用戶為每個貨件選擇運輸方式後,在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獲取包含付款來源的訂單。 如果設置了付款來源,我們將獲取其 id 並從PaypalPaymentService檢索相應的 Paypal 付款。 Paypal 付款將包含批准 URL。 如果未設置付款來源,我們將使用 Paypal 作為首選付款方式更新訂單。 然後,我們繼續使用PaypalPaymentService為訂單創建新的 Paypal 付款。 從這裡,我們可以從新創建的訂單中獲取批准 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 將付款人 ID 查詢參數添加到 url。 這是用戶的 Paypal ID。

當組件初始化時,我們從 url 中獲取payerId查詢參數。 然後使用包含付款來源的OrderService檢索訂單。 包含的付款來源的 ID 用於使用PaypalPayment服務使用付款人 ID 更新 Paypal 付款。 如果其中任何一個失敗,用戶將被重定向到錯誤頁面。 我們使用disableButton屬性來防止用戶在設置付款人 ID 之前下訂單。

當他們單擊下訂單按鈕時,訂單將更新為下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>

結論

在本文中,我們介紹瞭如何使用 Commerce Layer 和 Paypal 創建電子商務 Angular 11 應用程序。 我們還談到瞭如何構建應用程序以及如何與電子商務 API 交互。

儘管此應用程序允許客戶完成訂單,但它無論如何都沒有完成。 您可以添加很多內容來改進它。 一方面,您可以選擇在購物車中啟用商品數量更改、將購物車商品鏈接到其產品頁面、優化地址組件、為結帳頁面(如下單頁面)添加額外的保護等等。 這只是起點。

如果您想了解更多關於從頭到尾下訂單的過程,您可以查看 Commerce Layer 指南和 API。 您可以在此存儲庫中查看此項目的代碼。