如何使用 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 帐户。 拥有这种类型的帐户将使我们能够测试企业和用户之间的交易,而不会冒实际资金的风险。 您可以在这里创建一个。 沙盒帐户已为其创建了测试业务和测试个人帐户。
商务层和贝宝配置
要使 Commerce Layer 上的 Paypal Sandbox 支付成为可能,您需要设置 API 密钥。 前往您的 Paypal 开发者帐户的帐户概览。 选择一个企业帐户,然后在帐户详细信息的 API 凭据选项卡下,您将在REST Apps下找到默认应用程序。
要将您的 Paypal 企业帐户与您的 Commerce Layer 组织相关联,请转到您组织的仪表板。 在这里,您将为您的各个市场添加 Paypal 支付网关和 Paypal 支付方式。 在Settings > Payments下,选择Payment Gateways > Paypal并添加您的 Paypal 客户端 ID 和密码。
创建网关后,您需要为每个目标市场创建一个 Paypal 支付方式,以使 Paypal 作为一个选项可用。 您将在Settings > Payments > Payment Methods > New Payment Method下执行此操作。
关于使用的路线的说明
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 参考和指南,详细说明如何正确构建正文。
应用结构
为了组织应用程序,我们将其分为四个主要部分。 每个模块的功能的更好描述在其相应的部分下给出:
- 核心模块,
- 数据模块,
- 共享模块,
- 功能模块。
功能模块将相关的页面和组件组合在一起。 将有四个功能模块:
- 身份验证模块,
- 产品模块,
- 购物车模块,
- 结帐模块。
当我们进入每个模块时,我将解释其用途并分解其内容。
下面是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.json
的assets
文件夹中。 您可以在此处找到此文件的链接。
风格
我们将创建的组件共享一些全局样式。 我们将把它们放在可以在这个链接中找到的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
核心模块文件应该是这样的。 请注意,已为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
指定是否应使用传出凭据发送请求,例如我们使用的仅 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
服务允许我们确定屏幕的大小并在初始化期间分配列。
要获取产品,我们调用SkuService
的getProducts
方法。 如果成功,它会返回产品并将它们分配给网格。 如果不是,我们将用户路由到错误页面。
由于显示的产品是分页的,我们将有一个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
验证器可确保确认的密码与初始密码匹配。
初始化组件时,标题中的购物车、登录和注销按钮被隐藏。这是使用HeaderService
与标题通信的。
在所有字段都标记为有效后,用户可以注册。 在signup
方法中, CustomerService
的createCustomer
方法接收此输入。 如果注册成功,用户会被告知他们的帐户是使用快餐栏成功创建的。 然后将它们重新路由到主页。
@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 { }
代码组件
如前所述,此组件用于将任何优惠券或礼品卡代码添加到订单中。 这允许用户在进行结账之前对他们的订单总额应用折扣。
将有两个输入字段。 一个用于优惠券,另一个用于礼品卡代码。
通过更新订单添加代码。 OrderService
的updateOrder
方法使用代码更新订单。 之后,这两个字段都将被重置,并且用户会被告知操作成功与一个快餐栏。 发生错误时还会显示一个快餐栏。 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
的一部分进行了介绍。 守卫将带有空购物车的结帐页面的请求重定向到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
方法时会删除项目。 LineItemService
的deleteLineItem
方法在其中接收要删除的行项目的 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
方法中,如果他们已登录,则会克隆他们选择的地址并将其设置为订单的帐单地址。 为此,我们使用OrderService
的updateOrder
方法更新订单并提供地址 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。 您可以在此存储库中查看此项目的代码。