Angular 11、Commerce Layer、PaypalでEコマースサイトを構築する方法
公開: 2022-03-10今日では、ビジネスを運営する際にオンラインでの存在感が不可欠です。 昨年よりもはるかに多くの買い物がオンラインで行われています。 eコマースストアを持つことで、店のオーナーは、実店舗だけでは利用できなかった他の収益源を開くことができます。 ただし、他のショップオーナーは、物理的な存在なしに完全にオンラインでビジネスを運営しています。 これにより、オンラインストアを持つことが重要になります。
Etsy、Shopify、Amazonなどのサイトでは、サイトの開発について心配することなく、非常に迅速にストアを簡単にセットアップできます。 ただし、ショップの所有者がパーソナライズされたエクスペリエンスを求めたり、これらのプラットフォームの一部でストアを所有するコストを節約したりする場合があります。
ヘッドレスeコマースAPIプラットフォームは、ストアサイトがインターフェイスできるバックエンドを提供します。 顧客、注文、出荷、支払いなど、ストアに関連するすべてのプロセスとデータを管理します。 必要なのは、この情報とやり取りするためのフロントエンドだけです。 これにより、顧客がオンラインストアをどのように体験し、どのように運営するかを決定する際に、所有者は多くの柔軟性を得ることができます。
この記事では、Angular11を使用してeコマースストアを構築する方法について説明します。ヘッドレスeコマースAPIとしてCommerceLayerを使用します。 支払いを処理する方法はたくさんあるかもしれませんが、Paypalの1つだけを使用する方法を示します。
- GitHubでソースコードを表示→
前提条件
アプリをビルドする前に、AngularCLIをインストールする必要があります。 これを使用して、アプリの初期化とスキャフォールディングを行います。 まだインストールしていない場合は、npmから入手できます。
npm install -g @angular/cli
CommerceLayer開発者アカウントも必要です。 開発者アカウントを使用して、テスト組織を作成し、テストデータをシードする必要があります。 シードを使用すると、使用する必要のあるデータを気にすることなく、最初にアプリを簡単に開発できます。 このリンクでアカウントを作成し、ここで組織を作成できます。
最後に、Paypalサンドボックスアカウントが必要になります。 このタイプのアカウントを持っていると、実際のお金を危険にさらすことなく、企業とユーザーの間のトランザクションをテストできます。 ここで作成できます。 サンドボックスアカウントには、テストビジネスとテスト個人アカウントがすでに作成されています。
コマースレイヤーとPaypal構成
CommerceLayerでPaypalSandboxによる支払いを可能にするには、APIキーを設定する必要があります。 Paypal開発者アカウントのアカウントの概要に進んでください。 ビジネスアカウントを選択すると、アカウントの詳細の[APIクレデンシャル]タブで、[ RESTアプリ]の下にデフォルトのアプリケーションが表示されます。
PaypalビジネスアカウントをCommerceLayer組織に関連付けるには、組織のダッシュボードに移動します。 ここでは、さまざまな市場向けのPaypal支払いゲートウェイとPaypal支払い方法を追加します。 [設定]>[支払い]で、[支払いゲートウェイ]> [Paypal ]を選択し、PaypalクライアントIDとシークレットを追加します。
ゲートウェイを作成した後、Paypalをオプションとして利用できるようにするために、ターゲットとする市場ごとにPaypal支払い方法を作成する必要があります。 これは、 [設定]>[支払い]>[支払い方法]>[新しい支払い方法]で行います。
使用ルートに関する注意
Commerce Layerは、認証用のルートと、API用の別の異なるルートセットを提供します。 それらの/oauth/token
認証ルートは、トークンのクレデンシャルを交換します。 このトークンは、APIにアクセスするために必要です。 残りのAPIルートは、パターン/api/:resource
を取ります。
この記事の範囲は、このアプリのフロントエンド部分のみを対象としています。 トークンをサーバー側に保存し、セッションを使用して所有権を追跡し、セッションIDを持つhttpのみのCookieをクライアントに提供することを選択しました。 これはこの記事の範囲外であるため、ここでは取り上げません。 ただし、ルートは同じままであり、CommerceLayerAPIに正確に対応しています。 ただし、使用するCommerceLayerAPIからは利用できないカスタムルートがいくつかあります。 これらは主にセッション管理を扱います。 それらに到達したときにこれらを指摘し、同様の結果を達成する方法を説明します。
気付くかもしれないもう1つの矛盾は、リクエストの本文がCommerceLayerAPIが必要とするものと異なることです。 リクエストは別のサーバーに渡されてトークンが入力されるため、本体の構造を変えました。 これは、リクエストの送信を簡単にするためでした。 リクエストボディに不一致がある場合は常に、サービスで指摘されます。
これは範囲外であるため、トークンを安全に保存する方法を決定する必要があります。 また、Commerce Layer APIが必要とするものと正確に一致するように、リクエスト本文をわずかに変更する必要があります。 不整合がある場合は、APIリファレンスにリンクし、本体を正しく構成する方法を詳しく説明します。
アプリの構造
アプリを整理するために、4つの主要な部分に分けます。 各モジュールの機能のより良い説明は、対応するセクションに記載されています。
- コアモジュール、
- データモジュール、
- 共有モジュール、
- 機能モジュール。
機能モジュールは、関連するページとコンポーネントをグループ化します。 4つの機能モジュールがあります。
- 認証モジュール、
- 製品モジュール、
- カートモジュール、
- チェックアウトモジュール。
各モジュールに到達したら、その目的を説明し、その内容を分析します。
以下は、 src/app
フォルダーのツリーであり、各モジュールが存在する場所です。
src ├── app │ ├── core │ ├── data │ ├── features │ │ ├── auth │ │ ├── cart │ │ ├── checkout │ │ └── products └── shared
アプリの生成と依存関係の追加
まず、アプリを生成します。 私たちの組織はTheLImeBrandと呼ばれ、CommerceLayerによってすでにシードされたテストデータがあります。
ng new lime-app
いくつかの依存関係が必要になります。 主に角のある素材で、破壊されるまで。 AngularMaterialはコンポーネントとスタイリングを提供します。 コンポーネントが破棄されたときに、Destroyが監視対象から自動的にサブスクライブを解除するまで。 それらをインストールするには、以下を実行します。
npm install @ngneat/until-destroy ng add @angular/material
資産
Commerce Layerにアドレスを追加するときは、alpha-2国コードを使用する必要があります。 これらのコードを含むjsonファイルをassets/json/country-codes.json
のassets
フォルダーに追加します。 このファイルはここにリンクされています。
スタイル
作成するコンポーネントは、グローバルなスタイルを共有しています。 このリンクにあるstyles.css
に配置します。
環境
構成は2つのフィールドで構成されます。 CommerceLayerAPIを指す必要があるapiUrl
。 apiUrl
は、データをフェッチするために作成するサービスによって使用されます。 clientUrl
は、アプリが実行されているドメインである必要があります。 PaypalのリダイレクトURLを設定するときにこれを使用します。 このファイルはこのリンクにあります。
共有モジュール
共有モジュールには、他のモジュール間で共有されるサービス、パイプ、およびコンポーネントが含まれます。
ng gm shared
これは、3つのコンポーネント、1つのパイプ、および2つのサービスで構成されています。 これがどのようになるかです。
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
また、共有モジュールを使用して、一般的に使用されるいくつかのAngularMaterialコンポーネントをエクスポートします。 これにより、さまざまなモジュールに各コンポーネントをインポートする代わりに、箱から出してすぐに使用できるようになります。 これが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 { }
コンポーネント
アイテム数量コンポーネント
このコンポーネントは、アイテムをカートに追加するときにアイテムの数量を設定します。 カートおよび製品モジュールで使用されます。 この目的のためには、材料セレクターが簡単に選択できたでしょう。 ただし、マテリアル選択のスタイルは、他のすべてのフォームで使用されるマテリアル入力と一致しませんでした。 マテリアルメニューは、使用されたマテリアル入力と非常によく似ていました。 そこで、代わりにそれを使用してselectコンポーネントを作成することにしました。
ng gc shared/components/item-quantity
コンポーネントには、3つの入力プロパティと1つの出力プロパティがあります。 quantity
はアイテムの初期数量を設定し、 maxValue
は一度に選択できるアイテムの最大数を示し、 disabled
はコンポーネントを無効にするかどうかを示します。 setQuantityEvent
は、数量が選択されたときにトリガーされます。
コンポーネントが初期化されると、マテリアルメニューに表示される値を設定します。 setQuantityEvent
イベントを発行するsetQuantity
というメソッドもあります。
これはコンポーネントファイルです。
@Component({ selector: 'app-item-quantity', templateUrl: './item-quantity.component.html', styleUrls: ['./item-quantity.component.css'] }) export class ItemQuantityComponent implements OnInit { @Input() quantity: number = 0; @Input() maxValue?: number = 0; @Input() disabled?: boolean = false; @Output() setQuantityEvent = new EventEmitter<number>(); values: number[] = []; constructor() { } ngOnInit() { if (this.maxValue) { for (let i = 1; i <= this.maxValue; i++) { this.values.push(i); } } } setQuantity(value: number) { this.setQuantityEvent.emit(value); } }
これがそのテンプレートです。
<button mat-stroked-button [matMenuTriggerFor]="menu" [disabled]="disabled"> {{quantity}} <mat-icon *ngIf="!disabled">expand_more</mat-icon> </button> <mat-menu #menu="matMenu"> <button *ngFor="let no of values" (click)="setQuantity(no)" mat-menu-item>{{no}}</button> </mat-menu>
これがそのスタイリングです。
button { margin: 3px; }
タイトルコンポーネント
このコンポーネントは、一部の単純なページでは、ステッパータイトルとプレーンタイトルを兼ねています。 Angular Materialはステッパーコンポーネントを提供しますが、かなり長いチェックアウトプロセスには最適ではなく、小さなディスプレイでは応答性が低く、実装に多くの時間が必要でした。 ただし、より単純なタイトルは、ステッパーインジケーターとして再利用でき、複数のページで役立ちます。
ng gc shared/components/title
コンポーネントには、 title
、 subtitle
、数字( no
)、およびcenterText
の4つの入力プロパティがあり、コンポーネントのテキストを中央に配置するかどうかを示します。
@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
このサービスには、メソッドが1つだけ含まれます。 このメソッドは、クライアントエラーかサーバーエラーかに応じて、表示されるエラーメッセージをフォーマットします。 ただし、さらに改善する余地があります。
@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をここに保存しておくと便利です。 注文はコマースレイヤーのカートに対応します。
ローカルストレージサービスを生成するには、次の手順を実行します。
ng gs shared/services/local-storage
このサービスには、ローカルストレージからアイテムを追加、削除、取得するための4つのメソッドと、アイテムをクリアするための別のメソッドが含まれます。
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 | ユニークな在庫管理ユニット。 |
在庫場所 | 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
アドレスサービス
このサービスは、アドレスを作成および取得します。 配送先住所と請求先住所を作成して注文に割り当てる場合は重要です。 2つの方法があります。 1つはアドレスを作成し、もう1つはアドレスを取得します。
ここで使用されるルートは/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を保存します。 カートはコマースレイヤーの注文に対応します。 最初のアイテムがカートに追加されると、注文が作成されます。 チェックアウトプロセス中に取得できるように、この注文IDを保持する必要があります。
さらに、アイテムがカートに追加されたことをヘッダーに伝える方法が必要です。 ヘッダーにはカートボタンが含まれており、その中のアイテムの量が表示されます。 カートの現在の値でBehaviorSubject
のオブザーバブルを使用します。 ヘッダーはこれをサブスクライブして、カート値の変更を追跡できます。
最後に、注文が完了したら、カートの値をクリアする必要があります。 これにより、後続の新しい注文を作成するときに混乱が生じることはありません。 現在の注文が発注済みとしてマークされると、保存された値はクリアされます。
これはすべて、前に作成したローカルストレージサービスを使用して実行します。
@Injectable({ providedIn: 'root' }) export class CartService { private cart = new BehaviorSubject({ orderId: this.orderId, itemCount: this.itemCount }); cartValue$ = this.cart.asObservable(); constructor(private storage: LocalStorageService) { } get orderId(): string { const id = this.storage.getItem('order-id'); return id ? id : ''; } set orderId(id: string) { this.storage.addItem('order-id', id); this.cart.next({ orderId: id, itemCount: this.itemCount }); } get itemCount(): number { const itemCount = this.storage.getItem('item-count'); return itemCount ? parseInt(itemCount) : 0; } set itemCount(amount: number) { this.storage.addItem('item-count', amount.toString()); this.cart.next({ orderId: this.orderId, itemCount: amount }); } incrementItemCount(amount: number) { this.itemCount = this.itemCount + amount; } decrementItemCount(amount: number) { this.itemCount = this.itemCount - amount; } clearCart() { this.storage.deleteItem('item-count'); this.cart.next({ orderId: '', itemCount: 0 }); } }
カントリーサービス
Commerce Layerに住所を追加する場合、国コードはアルファ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
はコマースレイヤーでは利用できません。 したがって、現在ログインしている顧客を取得する方法を理解する必要があります。
@Injectable({ providedIn: 'root' }) export class CustomerService { private url: string = `${environment.apiUrl}/api/customers`; constructor(private http: HttpClient, private eh: HttpErrorHandler) { } createCustomer(email: string, password: string, firstName: string, lastName: string): Observable<Customer> { return this.http.post<Customer>(this.url, { email: email, password: password, firstName: firstName, lastName: lastName }) .pipe(catchError(this.eh.handleError)); } getCurrentCustomer(): Observable<Customer> { return this.http.get<Customer>(`${this.url}/current`) .pipe(catchError(this.eh.handleError)); } getCustomer(id: string): Observable<Customer> { return this.http.get<Customer>(`${this.url}/${id}`) .pipe(catchError(this.eh.handleError)); } }
配達リードタイムサービス
このサービスは、さまざまな在庫場所からの出荷スケジュールに関する情報を返します。
@Injectable({ providedIn: 'root' }) export class DeliveryLeadTimeService { private url: string = `${environment.apiUrl}/api/delivery_lead_times`; constructor(private http: HttpClient, private eh: HttpErrorHandler) { } getDeliveryLeadTimes(): Observable<DeliveryLeadTime[]> { return this.http.get<DeliveryLeadTime[]>(this.url) .pipe(catchError(this.eh.handleError)); } }
ラインアイテムサービス
カートに追加されたアイテムは、このサービスによって管理されます。 これを使用すると、カートに追加された瞬間にアイテムを作成できます。 アイテムの情報も取得できます。 アイテムは、数量が変更されたときに更新されたり、カートから削除されたときに削除されたりする場合もあります。
アイテムを作成または更新するときは、この例に示すようにリクエスト本文を構成します。
@Injectable({ providedIn: 'root' }) export class LineItemService { private url: string = `${environment.apiUrl}/api/line_items`; constructor(private http: HttpClient, private eh: HttpErrorHandler) { } createLineItem(lineItem: LineItem): Observable<LineItem> { return this.http.post<LineItem>(this.url, lineItem) .pipe(catchError(this.eh.handleError)); } getLineItem(id: string): Observable<LineItem> { return this.http.get<LineItem>(`${this.url}/${id}`) .pipe(catchError(this.eh.handleError)); } updateLineItem(id: string, quantity: number): Observable<LineItem> { return this.http.patch<LineItem>(`${this.url}/${id}`, { quantity: quantity }) .pipe(catchError(this.eh.handleError)); } deleteLineItem(id: string): Observable<LineItem> { return this.http.delete<LineItem>(`${this.url}/${id}`) .pipe(catchError(this.eh.handleError)); } }
注文サービス
広告申込情報サービスと同様に、注文サービスでは、注文を作成、更新、削除、または取得できます。 さらに、 getOrderShipments
メソッドを使用して、注文に関連付けられた出荷を個別に取得することを選択できます。 このサービスは、チェックアウトプロセス全体で頻繁に使用されます。
チェックアウト中に必要な注文に関するさまざまな種類の情報があります。 注文全体とその関係を取得するにはコストがかかる可能性があるため、 GetOrderParams
を使用して注文から取得するものを指定します。 CL APIでこれに相当するのは、インクルードする順序関係をリストするインクルードクエリパラメーターです。 カートの概要とさまざまなチェックアウト段階に含める必要のあるフィールドは、ここで確認できます。
同様に、注文を更新するときは、 UpdateOrderParams
を使用して更新フィールドを指定します。 これは、トークンを設定するサーバーで、更新されるフィールドに応じていくつかの追加操作が実行されるためです。 ただし、CL APIに直接リクエストする場合は、これを指定する必要はありません。 CL APIでは指定する必要がないため、これを廃止できます。 ただし、リクエストの本文はこの例のようになります。
@Injectable({ providedIn: 'root' }) export class OrderService { private url: string = `${environment.apiUrl}/api/orders`; constructor( private http: HttpClient, private eh: HttpErrorHandler) { } createOrder(): Observable<Order> { return this.http.post<Order>(this.url, {}) .pipe(catchError(this.eh.handleError)); } getOrder(id: string, orderParam: GetOrderParams): Observable<Order> { let params = {}; if (orderParam != GetOrderParams.none) { params = { [orderParam]: 'true' }; } return this.http.get<Order>(`${this.url}/${id}`, { params: params }) .pipe(catchError(this.eh.handleError)); } updateOrder(order: Order, params: UpdateOrderParams[]): Observable<Order> { let updateParams = []; for (const param of params) { updateParams.push(param.toString()); } return this.http.patch<Order>( `${this.url}/${order.id}`, order, { params: { 'field': updateParams } } ) .pipe(catchError(this.eh.handleError)); } getOrderShipments(id: string): Observable<Shipment[]> { return this.http.get<Shipment[]>(`${this.url}/${id}/shipments`) .pipe(catchError(this.eh.handleError)); } }
Paypal決済サービス
このサービスは、注文のPaypal支払いを作成および更新する責任があります。 さらに、その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>
ヘッダーコンポーネント
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リクエストをインターセプトし、リクエストに2つのオプションを追加します。 これらは、 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); } }
機能モジュール
このセクションには、アプリの主な機能が含まれています。 前述のように、機能は、認証、製品、カート、およびチェックアウトモジュールの4つのモジュールにグループ化されています。
製品モジュール
製品モジュールには、販売中の製品を表示するページが含まれています。 これには、製品ページと製品リストページが含まれます。 以下のような構造になっています。
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
サービスで作成されます。 すべての入力に値があることを要求するために検証が追加されました。 パスワードフィールドに追加の検証が追加され、8文字以上の長さが保証されます。 カスタム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 { }
コードコンポーネント
前述のように、このコンポーネントは、クーポンまたはギフトカードのコードを注文に追加するために使用されます。 これにより、ユーザーはチェックアウトに進む前に、注文の合計に割引を適用できます。
2つの入力フィールドがあります。 1つはクーポン用、もう1つはギフトカードコード用です。
コードは、順序を更新することによって追加されます。 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
という1つの出力プロパティがあります。 国が選択されると、このイベントはその国の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>
アドレスコンポーネント
これは、アドレスをキャプチャするためのフォームです。 配送先住所ページと請求先住所ページの両方で使用されます。 有効なCommerceLayerアドレスには、姓名、住所行、市区町村、郵便番号、州コード、国コード、および電話番号が含まれている必要があります。
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を使用して順序が更新されます。 エラーが発生した場合、またはいずれかの操作が成功した場合は、スナックバーが表示されます。
配送先住所として同じ住所が選択されている場合、ユーザーは配送方法ページにルーティングされます。 別の配送先住所を提供したい場合は、配送先住所ページに移動します。
@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>
請求先住所のページは次のようになります。
配送先住所コンポーネント
配送先住所コンポーネントは、請求先住所コンポーネントとよく似た動作をします。 ただし、いくつかの違いがあります。 1つは、テンプレートに表示されるテキストが異なります。 その他の主な違いは、住所が作成または選択された後、 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>
配送先住所のページは次のようになります。
配送方法コンポーネント
このコンポーネントは、注文が履行されるために必要な出荷数、利用可能な出荷方法、およびそれらに関連するコストを表示します。 その後、顧客は各配送に希望する配送方法を選択できます。
shipments
プロパティには、注文のすべての出荷が含まれます。 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に追加します。 これはユーザーのPaypalIDです。
コンポーネントが初期化されると、URLからpayerId
クエリパラメータを取得します。 次に、支払い元が含まれているOrderService
を使用して注文が取得されます。 含まれている支払い元のIDは、 PaypalPayment
サービスを使用して、Paypal支払いを支払人IDで更新するために使用されます。 これらのいずれかが失敗した場合、ユーザーはエラーページにリダイレクトされます。 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>
これがページのスクリーンショットです。
アプリモジュール
認証以外のCommerceLayerに対して行われるすべての要求には、トークンが含まれている必要があります。 そのため、アプリが初期化されるとすぐに、サーバー上の/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>
結論
この記事では、CommerceLayerとPaypalを使用してeコマースAngular11アプリを作成する方法について説明しました。 また、アプリを構成する方法と、eコマースAPIとのインターフェース方法についても触れました。
このアプリは顧客が完全な注文をすることを可能にしますが、それは決して終了していません。 それを改善するために追加できることはたくさんあります。 1つは、カート内の商品数量の変更を有効にする、カート商品を商品ページにリンクする、住所コンポーネントを最適化する、発注ページなどのチェックアウトページにガードを追加するなどを選択できます。 これは出発点にすぎません。
注文を最初から最後まで行うプロセスについて詳しく知りたい場合は、CommerceLayerガイドとAPIを確認してください。 このリポジトリでこのプロジェクトのコードを表示できます。