Angular 11, Commerce Layer 및 Paypal을 사용하여 전자 상거래 사이트를 구축하는 방법

게시 됨: 2022-03-10
요약 요약 ↬ 점점 더 많은 고객이 온라인 쇼핑으로 전환함에 따라 전자 상거래 상점을 갖는 것은 상점 소유자에게 매우 중요합니다. 이 튜토리얼에서는 Angular 11을 사용하여 전자 상거래 사이트를 만드는 방법을 살펴보겠습니다. 사이트는 Commerce Layer를 헤드리스 전자 상거래 API로 사용하고 Paypal을 사용하여 지불을 처리합니다.

요즘은 비즈니스를 운영할 때 온라인에서 존재하는 것이 필수적입니다. 작년보다 훨씬 더 많은 쇼핑이 온라인으로 이루어집니다. 전자 상거래 상점을 보유하면 상점 소유자는 오프라인 상점만으로는 활용할 수 없는 다른 수익원을 열 수 있습니다. 그러나 다른 상점 소유자는 물리적 존재 없이 완전히 온라인으로 비즈니스를 운영합니다. 이것은 온라인 상점을 갖는 것이 중요합니다.

Etsy, Shopify 및 Amazon과 같은 사이트를 사용하면 사이트 개발에 대해 걱정할 필요 없이 매우 빠르게 상점을 쉽게 설정할 수 있습니다. 그러나 상점 소유자가 개인화된 경험을 원하거나 이러한 플랫폼 중 일부에서 상점 소유 비용을 절약할 수 있는 경우가 있을 수 있습니다.

헤드리스 전자 상거래 API 플랫폼은 상점 사이트가 인터페이스할 수 있는 백엔드를 제공합니다. 그들은 고객, 주문, 배송, 지불 등과 같은 상점과 관련된 모든 프로세스와 데이터를 관리합니다. 이 정보와 상호 작용하는 프런트엔드만 있으면 됩니다. 이는 고객이 온라인 상점을 경험하는 방식과 운영 방식을 결정할 때 소유자에게 많은 유연성을 제공합니다.

이 기사에서는 Angular 11을 사용하여 전자 상거래 상점을 구축하는 방법을 다룰 것입니다. 헤드리스 전자 상거래 API로 Commerce Layer를 사용할 것입니다. 지불을 처리하는 방법이 수없이 많을 수 있지만 Paypal 하나만 사용하는 방법을 보여드리겠습니다.

  • GitHub에서 소스 코드 보기 →

전제 조건

앱을 빌드하기 전에 Angular CLI가 설치되어 있어야 합니다. 앱을 초기화하고 스캐폴딩하는 데 사용할 것입니다. 아직 설치하지 않았다면 npm을 통해 다운로드할 수 있습니다.

 npm install -g @angular/cli

Commerce Layer 개발자 계정도 필요합니다. 개발자 계정을 사용하여 테스트 조직을 만들고 테스트 데이터로 시드해야 합니다. 시딩을 사용하면 어떤 데이터를 사용해야 할지 걱정할 필요 없이 앱을 먼저 개발하기가 더 쉽습니다. 이 링크에서 계정을 만들고 여기에서 조직을 만들 수 있습니다.

Commerce Layer 개발자 계정 조직 대시보드
조직을 추가하는 Commerce Layer 개발자 계정 조직 대시보드. (큰 미리보기)
상업 계층 조직 생성 양식
새 조직을 만들 때 테스트 데이터가 포함된 시드 확인란을 선택합니다. (큰 미리보기)

마지막으로 Paypal Sandbox 계정이 필요합니다. 이러한 유형의 계정이 있으면 실제 돈을 위험에 빠뜨리지 않고 비즈니스와 사용자 간의 거래를 테스트할 수 있습니다. 여기에서 만들 수 있습니다. 샌드박스 계정에는 이미 생성된 테스트 비즈니스 및 테스트 개인 계정이 있습니다.

점프 후 더! 아래에서 계속 읽기 ↓

상거래 계층 및 페이팔 구성

Commerce Layer에서 Paypal Sandbox 결제를 가능하게 하려면 API 키를 설정해야 합니다. Paypal 개발자 계정의 계정 개요로 이동하십시오. 비즈니스 계정을 선택하고 계정 세부 정보의 API 자격 증명 탭에서 REST 앱 아래에 기본 애플리케이션 을 찾을 수 있습니다.

Paypal Sandbox 비즈니스 계정 세부 정보 팝업의 API 자격 증명 탭
Paypal 비즈니스 계정 세부 정보 팝업에서 기본 REST 앱을 찾을 수 있는 위치입니다. (큰 미리보기)
Paypal Sandbox 비즈니스 계정 설정에 대한 기본 애플리케이션 개요
REST API 클라이언트 ID 및 비밀을 가져올 수 있는 Paypal Sandbox 비즈니스 계정 설정에 대한 기본 애플리케이션 개요. (큰 미리보기)

Paypal 비즈니스 계정을 Commerce Layer 조직과 연결하려면 조직의 대시보드로 이동하십시오. 여기에서 다양한 시장에 대한 Paypal 결제 게이트웨이와 Paypal 결제 방법을 추가합니다. 설정 > 결제 아래에서 결제 게이트웨이 > Paypal 을 선택하고 Paypal 클라이언트 ID와 암호를 추가합니다.

Commerce Layer의 새로운 Payments Gateway 대시보드
Commerce Layer 대시보드에서 Paypal 결제 게이트웨이를 생성하는 위치. (큰 미리보기)

게이트웨이를 만든 후 Paypal을 옵션으로 사용할 수 있도록 하려면 대상으로 하는 각 시장에 대해 Paypal 결제 방법을 만들어야 합니다. 설정 > 결제 > 결제 수단 > 새 결제 수단 에서 이 작업을 수행합니다.

Commerce Layer의 결제 수단 대시보드
Commerce Layer 대시보드에서 Paypal 결제 방법을 생성하는 위치. (큰 미리보기)

사용 경로에 대한 참고 사항

Commerce Layer는 인증을 위한 경로와 API에 대한 다른 경로 세트를 제공합니다. 그들의 /oauth/token 인증 경로는 토큰에 대한 자격 증명을 교환합니다. 이 토큰은 API에 액세스하는 데 필요합니다. 나머지 API 경로는 /api/:resource 패턴을 사용합니다.

이 문서의 범위는 이 앱의 프론트엔드 부분만 다룹니다. 토큰 서버 측을 저장하고 세션을 사용하여 소유권을 추적하고 클라이언트에 세션 ID가 있는 http 전용 쿠키를 제공하기로 선택했습니다. 이것은 이 문서의 범위를 벗어나므로 여기에서 다루지 않습니다. 그러나 경로는 동일하게 유지되며 Commerce Layer API와 정확히 일치합니다. 하지만 우리가 사용할 Commerce Layer API에서 사용할 수 없는 몇 가지 사용자 지정 경로가 있습니다. 이들은 주로 세션 관리를 다룹니다. 나는 우리가 그것들에 도달할 때 이것을 지적하고 유사한 결과를 얻을 수 있는 방법을 설명할 것입니다.

또 다른 불일치는 요청 본문이 Commerce Layer API가 요구하는 것과 다르다는 것입니다. 요청이 토큰으로 채워지기 위해 다른 서버로 전달되기 때문에 본문을 다르게 구성했습니다. 이것은 요청을 더 쉽게 보낼 수 있도록 하기 위한 것입니다. 요청 본문에 불일치가 있을 때마다 서비스에서 이를 지적합니다.

이것은 범위를 벗어나므로 토큰을 안전하게 저장하는 방법을 결정해야 합니다. 또한 Commerce Layer API가 요구하는 것과 정확히 일치하도록 요청 본문을 약간 수정해야 합니다. 불일치하는 부분이 있으면 API 참조로 연결하여 본문을 올바르게 구성하는 방법을 자세히 안내합니다.

앱 구조

앱을 구성하기 위해 4개의 주요 부분으로 나눌 것입니다. 각 모듈이 하는 일에 대한 더 나은 설명은 해당 섹션에 나와 있습니다.

  1. 핵심 모듈,
  2. 데이터 모듈,
  3. 공유 모듈,
  4. 기능 모듈.

기능 모듈은 관련 페이지와 구성 요소를 함께 그룹화합니다. 네 가지 기능 모듈이 있습니다.

  1. 인증 모듈,
  2. 제품 모듈,
  3. 카트 모듈,
  4. 체크아웃 모듈.

각 모듈에 도달하면 그 목적이 무엇인지 설명하고 내용을 분해하겠습니다.

아래는 src/app 폴더의 트리와 각 모듈이 있는 위치입니다.

 src ├── app │ ├── core │ ├── data │ ├── features │ │ ├── auth │ │ ├── cart │ │ ├── checkout │ │ └── products └── shared

앱 생성 및 종속성 추가

앱 생성부터 시작하겠습니다. 우리 조직은 The LIme Brand 라고 불리며 이미 Commerce Layer에 의해 시드된 테스트 데이터를 갖게 될 것입니다.

 ng new lime-app

몇 가지 종속성이 필요합니다. 주로 앵귤러 머티리얼과 파괴될 때까지. Angular Material은 구성 요소와 스타일을 제공합니다. 구성 요소가 소멸될 때까지 Destroy는 Observable에서 자동으로 구독을 취소합니다. 설치하려면 다음을 실행하십시오.

 npm install @ngneat/until-destroy ng add @angular/material

자산

Commerce Layer에 주소를 추가할 때 alpha-2 국가 코드를 사용해야 합니다. 이 코드가 포함된 json 파일을 assets/json/country-codes.jsonassets 폴더에 추가합니다. 여기에서 링크된 이 파일을 찾을 수 있습니다.

스타일

우리가 만들 구성 요소는 일부 전역 스타일을 공유합니다. 이 링크에서 찾을 수 있는 styles.css 에 배치할 것입니다.

환경

우리의 구성은 두 개의 필드로 구성됩니다. Commerce Layer API를 가리켜야 하는 apiUrl 입니다. apiUrl 은 데이터를 가져오기 위해 생성할 서비스에서 사용됩니다. clientUrl 은 앱이 실행 중인 도메인이어야 합니다. Paypal에 대한 리디렉션 URL을 설정할 때 이것을 사용합니다. 이 링크에서 이 파일을 찾을 수 있습니다.

공유 모듈

공유 모듈에는 다른 모듈에서 공유되는 서비스, 파이프 및 구성 요소가 포함됩니다.

 ng gm shared

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

또한 공유 모듈을 사용하여 일반적으로 사용되는 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

구성 요소에는 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 의 네 가지 입력 속성이 있습니다.

 @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 국가 코드.
고객 주소 고객과 연결된 주소입니다.
고객 등록된 사용자입니다.
배달 리드 타임 배송을 배송하는 데 걸리는 시간을 나타냅니다.
광고 항목 장바구니에 추가된 품목별 제품.
주문하다 장바구니 또는 광고 항목 모음입니다.
지불 방법 주문에 사용할 수 있는 결제 유형입니다.
지불 출처 주문과 관련된 지불입니다.
페이팔 결제 페이팔을 통해 결제
가격 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

주소 서비스

이 서비스는 주소를 생성하고 검색합니다. 배송 및 청구 주소를 생성하고 주문에 할당할 때 중요합니다. 두 가지 방법이 있습니다. 하나는 주소를 만들고 다른 하나는 검색합니다.

여기에 사용된 경로는 /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 의 관찰 가능 항목을 사용합니다. 헤더는 이것을 구독하고 장바구니 값의 변경 사항을 추적할 수 있습니다.

마지막으로 주문이 완료되면 장바구니 값을 지워야 합니다. 이렇게 하면 후속 새 주문을 생성할 때 혼동이 발생하지 않습니다. 저장된 값은 현재 주문이 배치된 것으로 표시되면 지워집니다.

앞에서 만든 로컬 저장소 서비스를 사용하여 이 모든 작업을 수행합니다.

 @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 지불을 받을 수 있습니다. 포스트 본문은 페이팔 결제를 생성할 때 이 예와 유사한 구조를 가져야 합니다.

 @Injectable({ providedIn: 'root' }) export class PaypalPaymentService { private url: string = `${environment.apiUrl}/api/paypal_payments`; constructor(private http: HttpClient, private eh: HttpErrorHandler) { } createPaypalPayment(payment: PaypalPayment): Observable<PaypalPayment> { return this.http.post<PaypalPayment>(this.url, payment) .pipe(catchError(this.eh.handleError)); } getPaypalPayment(id: string): Observable<PaypalPayment> { return this.http.get<PaypalPayment>(`${this.url}/${id}`) .pipe(catchError(this.eh.handleError)); } updatePaypalPayment(id: string, paypalPayerId: string): Observable<PaypalPayment> { return this.http.patch<PaypalPayment>( `${this.url}/${id}`, { paypalPayerId: paypalPayerId } ) .pipe(catchError(this.eh.handleError)); } }

배송 서비스

이 서비스는 배송을 받거나 ID를 제공하여 업데이트합니다. 배송 업데이트의 요청 본문은 이 예와 유사해야 합니다.

 @Injectable({ providedIn: 'root' }) export class ShipmentService { private url: string = `${environment.apiUrl}/api/shipments`; constructor(private http: HttpClient, private eh: HttpErrorHandler) { } getShipment(id: string): Observable<Shipment> { return this.http.get<Shipment>(`${this.url}/${id}`) .pipe(catchError(this.eh.handleError)); } updateShipment(id: string, shippingMethodId: string): Observable<Shipment> { return this.http.patch<Shipment>( `${this.url}/${id}`, { shippingMethodId: shippingMethodId } ) .pipe(catchError(this.eh.handleError)); } }

SKU 서비스

SKU 서비스는 상점에서 제품을 가져옵니다. 여러 제품을 검색하는 경우 페이지를 매길 수 있고 페이지 크기를 설정할 수 있습니다. API에 직접 요청하는 경우 페이지 크기와 페이지 번호는 이 예제와 같이 쿼리 매개변수로 설정해야 합니다. 단일 제품은 해당 ID를 지정하여 검색할 수도 있습니다.

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

코어 모듈

핵심 모듈에는 응용 프로그램 전반에 걸쳐 핵심적이고 공통적인 모든 것이 포함됩니다. 여기에는 헤더와 같은 구성요소와 404 페이지와 같은 페이지가 포함됩니다. 인증 및 세션 관리를 담당하는 서비스와 앱 전체의 인터셉터 및 가드도 여기에 속합니다.

핵심 모듈 트리는 다음과 같습니다.

 src/app/core ├── components │ ├── error │ │ ├── error.component.css │ │ ├── error.component.html │ │ └── error.component.ts │ ├── header │ │ ├── header.component.css │ │ ├── header.component.html │ │ └── header.component.ts │ └── not-found │ ├── not-found.component.css │ ├── not-found.component.html │ └── not-found.component.ts ├── core.module.ts ├── guards │ └── empty-cart.guard.ts ├── interceptors │ └── options.interceptor.ts └── services ├── authentication.service.ts ├── header.service.ts └── session.service.ts

모듈과 그 내용을 생성하려면 다음을 실행하십시오.

 ng gm core ng gg core/guards/empty-cart ng gs core/header/header ng g interceptor core/interceptors/options for comp in header error not-found; do ng gc "core/${comp}"; done for serv in authentication session; do ng gs "core/authentication/${serv}"; done

코어 모듈 파일은 다음과 같아야 합니다. NotFoundComponentErrorComponent 에 대해 경로가 등록되었습니다.

 @NgModule({ declarations: [HeaderComponent, NotFoundComponent, ErrorComponent], imports: [ RouterModule.forChild([ { path: '404', component: NotFoundComponent }, { path: 'error', component: ErrorComponent }, { path: '**', redirectTo: '/404' } ]), MatBadgeModule, SharedModule ], exports: [HeaderComponent] }) export class CoreModule { }

서비스

서비스 폴더에는 인증, 세션 및 헤더 서비스가 있습니다.

인증 서비스

AuthenticationService 를 사용하면 클라이언트 및 고객 토큰을 얻을 수 있습니다. 이 토큰은 나머지 API 경로에 액세스하는 데 사용됩니다. 고객 토큰은 사용자가 이메일과 비밀번호를 교환하고 더 넓은 범위의 권한을 가질 때 반환됩니다. 클라이언트 토큰은 자격 증명 없이 발급되며 더 좁은 권한을 갖습니다.

getClientSession 은 클라이언트 토큰을 가져옵니다. login 은 고객 토큰을 얻습니다. 두 방법 모두 세션도 생성합니다. 클라이언트 토큰 요청의 본문은 다음과 같아야 하고 고객 토큰의 본문은 다음과 같아야 합니다.

 @Injectable({ providedIn: 'root' }) export class AuthenticationService { private url: string = `${environment.apiUrl}/oauth/token`; constructor(private http: HttpClient, private eh: HttpErrorHandler) { } getClientSession(): Observable<object> { return this.http.post<object>( this.url, { grantType: 'client_credentials' }) .pipe(catchError(this.eh.handleError)); } login(email: string, password: string): Observable<object> { return this.http.post<object>( this.url, { username: email, password: password, grantType: 'password' }) .pipe(catchError(this.eh.handleError)); } }

Session Service

The SessionService is responsible for session management. The service will contain an observable from a BehaviorSubject called loggedInStatus to communicate whether a user is logged in. setLoggedInStatus sets the value of this subject, true for logged in, and false for not logged in. isCustomerLoggedIn makes a request to the server to check if the user has an existing session. logout destroys the session on the server. The last two methods access routes that are unique to the server that populates the request with a token. They are not available from Commerce Layer. You'll have to figure out how to implement them.

 @Injectable({ providedIn: 'root' }) export class SessionService { private url: string = `${environment.apiUrl}/session`; private isLoggedIn = new BehaviorSubject(false); loggedInStatus = this.isLoggedIn.asObservable(); constructor(private http: HttpClient, private eh: HttpErrorHandler) { } setLoggedInStatus(status: boolean) { this.isLoggedIn.next(status); } isCustomerLoggedIn(): Observable<{ message: string }> { return this.http.get<{ message: string }>(`${this.url}/customer/status`) .pipe(catchError(this.eh.handleError)); } logout(): Observable<{ message: string }> { return this.http.get<{ message: string }>(`${this.url}/destroy`) .pipe(catchError(this.eh.handleError)); } }

Header Service

The HeaderService is used to communicate whether the cart, login, and logout buttons should be shown in the header. These buttons are hidden on the login and signup pages but present on all other pages to prevent confusion. We'll use an observable from a BehaviourSubject called showHeaderButtons that shares this. We'll also have a setHeaderButtonsVisibility method to set this value.

 @Injectable({ providedIn: 'root' }) export class HeaderService { private headerButtonsVisibility = new BehaviorSubject(true); showHeaderButtons = this.headerButtonsVisibility.asObservable(); constructor() { } setHeaderButtonsVisibility(visible: boolean) { this.headerButtonsVisibility.next(visible); } }

구성품

Error Component

This component is used as an error page. It is useful in instances when server requests fail and absolutely no data is displayed on a page. Instead of showing a blank page, we let the user know that a problem occurred. Below is it's template.

 <app-simple-page title="An error occurred" subtitle="There was a problem fetching your page" buttonText="GO TO HOME" icon="report" [centerText]="true" route="/"> </app-simple-page>

This is what the component will look like.

Screenshot of error page
Screenshot of error page. (큰 미리보기)

Not Found Component

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

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

Header Component

The HeaderComponent is basically the header displayed at the top of a page. It will contain the app title, the cart, login, and logout buttons.

When this component is initialized, a request is made to check whether the user has a current session. This happens when subscribing to this.session.isCustomerLoggedIn() . We subscribe to this.session.loggedInStatus to check if the user logs out throughout the life of the app. The this.header.showHeaderButtons subscription decides whether to show all the buttons on the header or hide them. this.cart.cartValue$ gets the count of items in the cart.

사용자의 세션을 파괴하고 클라이언트 토큰을 할당하는 logout 방법이 있습니다. 고객 토큰을 유지 관리하는 세션이 파괴되고 각 API 요청에 여전히 토큰이 필요하기 때문에 클라이언트 토큰이 할당됩니다. 머티리얼 스낵바는 세션이 성공적으로 파괴되었는지 여부를 사용자에게 전달합니다.

@UntilDestroy({ checkProperties: true }) 데코레이터를 사용하여 구성 요소가 파괴될 때 모든 구독이 자동으로 구독 취소되어야 함을 나타냅니다.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-header', templateUrl: './header.component.html', styleUrls: ['./header.component.css'] }) export class HeaderComponent implements OnInit { cartAmount: number = 0; isLoggedIn: boolean = false; showButtons: boolean = true; constructor( private session: SessionService, private snackBar: MatSnackBar, private cart: CartService, private header: HeaderService, private auth: AuthenticationService ) { } ngOnInit() { this.session.isCustomerLoggedIn() .subscribe( () => { this.isLoggedIn = true; this.session.setLoggedInStatus(true); } ); this.session.loggedInStatus.subscribe(status => this.isLoggedIn = status); this.header.showHeaderButtons.subscribe(visible => this.showButtons = visible); this.cart.cartValue$.subscribe(cart => this.cartAmount = cart.itemCount); } logout() { concat( this.session.logout(), this.auth.getClientSession() ).subscribe( () => { this.snackBar.open('You have been logged out.', 'Close', { duration: 4000 }); this.session.setLoggedInStatus(false); }, err => this.snackBar.open('There was a problem logging you out.', 'Close', { duration: 4000 }) ); } }

아래는 헤더 템플릿이고 여기에 링크된 것은 스타일입니다.

 <div> <div routerLink="/"> <h1><span>Lime</span><span>Store</span></h1> </div> <div> <div *ngIf="showButtons"> <button mat-icon-button color="primary" aria-label="shopping cart"> <mat-icon [matBadge]="cartAmount" matBadgeColor="accent" aria-label="shopping cart" routerLink="/cart">shopping_cart</mat-icon> </button> <button mat-icon-button color="primary" aria-label="login" *ngIf="!isLoggedIn"> <mat-icon aria-label="login" matTooltip="login" routerLink="/login">login</mat-icon> </button> <button mat-icon-button color="primary" aria-label="logout" *ngIf="isLoggedIn" (click)="logout()"> <mat-icon aria-label="logout" matTooltip="logout">logout</mat-icon> </button> </div> </div> </div>

근위 연대

빈 카트 가드

이 가드는 사용자가 장바구니가 비어 있는 경우 결제 및 청구와 관련된 경로에 액세스하는 것을 방지합니다. 결제를 진행하려면 유효한 주문이 있어야 하기 때문입니다. 주문은 항목이 있는 카트에 해당합니다. 장바구니에 항목이 있는 경우 사용자는 보호된 페이지로 이동할 수 있습니다. 그러나 장바구니가 비어 있으면 사용자는 빈 장바구니 페이지로 리디렉션됩니다.

 @Injectable({ providedIn: 'root' }) export class EmptyCartGuard implements CanActivate { constructor(private cart: CartService, private router: Router) { } canActivate( route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree { if (this.cart.orderId) { if (this.cart.itemCount > 0) { return true; } } return this.router.parseUrl('/empty'); } }

인터셉터

옵션 인터셉터

이 인터셉터는 나가는 모든 HTTP 요청을 가로채서 두 가지 옵션을 요청에 추가합니다. 이는 Content-Type 헤더와 withCredentials 속성입니다. withCredentials 는 우리가 사용하는 http 전용 쿠키와 같은 발신 자격 증명과 함께 요청을 보내야 하는지 여부를 지정합니다. Content-Type 을 사용하여 json 리소스를 서버로 보내고 있음을 나타냅니다.

 @Injectable() export class OptionsInterceptor implements HttpInterceptor { constructor() { } intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { request = request.clone({ headers: request.headers.set('Content-Type', 'application/json'), withCredentials: true }); return next.handle(request); } }

기능 모듈

이 섹션에는 앱의 주요 기능이 포함되어 있습니다. 앞서 언급했듯이 기능은 인증, 제품, 장바구니 및 체크아웃 모듈의 네 가지 모듈로 그룹화됩니다.

제품 모듈

제품 모듈에는 판매 중인 제품을 표시하는 페이지가 있습니다. 여기에는 제품 페이지와 제품 목록 페이지가 포함됩니다. 아래와 같이 구성되어 있습니다.

 src/app/features/products ├── pages │ ├── product │ │ ├── product.component.css │ │ ├── product.component.html │ │ └── product.component.ts │ └── product-list │ ├── product-list.component.css │ ├── product-list.component.html │ └── product-list.component.ts └── products.module.ts

그것과 그 구성 요소를 생성하려면:

 ng gm features/products ng gc features/products/pages/product ng gc features/products/pages/product-list

이것은 모듈 파일입니다:

 @NgModule({ declarations: [ProductListComponent, ProductComponent], imports: [ RouterModule.forChild([ { path: 'product/:id', component: ProductComponent }, { path: '', component: ProductListComponent } ]), LayoutModule, MatCardModule, MatGridListModule, MatPaginatorModule, SharedModule ] }) export class ProductsModule { }

제품 목록 구성 요소

이 구성 요소는 판매 가능한 제품의 페이지를 매긴 목록을 표시합니다. 앱이 시작될 때 로드되는 첫 번째 페이지입니다.

제품이 그리드에 표시됩니다. Material grid list는 이를 위한 최고의 구성 요소입니다. 그리드를 반응형으로 만들기 위해 그리드 열의 수는 화면 크기에 따라 변경됩니다. BreakpointObserver 서비스를 사용하면 초기화 중에 화면 크기를 결정하고 열을 할당할 수 있습니다.

제품을 가져오기 위해 SkuServicegetProducts 메서드를 호출합니다. 성공하면 제품을 반환하고 그리드에 할당합니다. 그렇지 않은 경우 사용자를 오류 페이지로 라우팅합니다.

표시된 제품은 페이지가 매겨져 있으므로 추가 제품을 가져오는 getNextPage 메서드가 있습니다.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-product-list', templateUrl: './product-list.component.html', styleUrls: ['./product-list.component.css'] }) export class ProductListComponent implements OnInit { cols = 4; length = 0; pageIndex = 0; pageSize = 20; pageSizeOptions: number[] = [5, 10, 20]; pageEvent!: PageEvent | void; products: Sku[] = []; constructor( private breakpointObserver: BreakpointObserver, private skus: SkuService, private router: Router, private header: HeaderService) { } ngOnInit() { this.getProducts(1, 20); this.header.setHeaderButtonsVisibility(true); this.breakpointObserver.observe([ Breakpoints.Handset, Breakpoints.Tablet, Breakpoints.Web ]).subscribe(result => { if (result.matches) { if (result.breakpoints['(max-width: 599.98px) and (orientation: portrait)'] || result.breakpoints['(max-width: 599.98px) and (orientation: landscape)']) { this.cols = 1; } else if (result.breakpoints['(min-width: 1280px) and (orientation: portrait)'] || result.breakpoints['(min-width: 1280px) and (orientation: landscape)']) { this.cols = 4; } else { this.cols = 3; } } }); } private getProducts(page: number, pageSize: number) { this.skus.getSkus(page, pageSize) .subscribe( skus => { this.products = skus; this.length = skus[0].__collectionMeta.recordCount; }, err => this.router.navigateByUrl('/error') ); } getNextPage(event: PageEvent) { this.getProducts(event.pageIndex + 1, event.pageSize); } trackSkus(index: number, item: Sku) { return `${item.id}-${index}`; } }

템플릿은 아래에 표시되며 스타일은 여기에서 찾을 수 있습니다.

 <mat-grid-list cols="{{cols}}" rowHeight="400px" gutterSize="20px" class="grid-layout"> <mat-grid-tile *ngFor="let product of products; trackBy: trackSkus"> <mat-card> <img mat-card-image src="{{product.imageUrl}}" alt="product photo"> <mat-card-content> <mat-card-title matTooltip="{{product.name}}">{{product.name |wordWrap:35}}</mat-card-title> <mat-card-subtitle>{{product.prices[0].compareAtAmountFloat | currency:'EUR'}}</mat-card-subtitle> </mat-card-content> <mat-card-actions> <button mat-flat-button color="primary" [routerLink]="['/product', product.id]"> View </button> </mat-card-actions> </mat-card> </mat-grid-tile> </mat-grid-list> <mat-paginator [length]="length" [pageIndex]="pageIndex" [pageSize]="pageSize" [pageSizeOptions]="pageSizeOptions" (page)="pageEvent = getNextPage($event)"> </mat-paginator>

페이지는 다음과 같을 것입니다.

제품 목록 페이지의 스크린샷
제품 목록 페이지의 스크린샷입니다. (큰 미리보기)

제품 구성 요소

제품 목록 페이지에서 제품을 선택하면 이 구성 요소에 세부 정보가 표시됩니다. 여기에는 제품의 전체 이름, 가격 및 설명이 포함됩니다. 상품을 장바구니에 담을 수 있는 버튼도 있습니다.

초기화 시 경로 매개변수에서 제품 ID를 얻습니다. id를 사용하여 SkuService 에서 제품을 가져옵니다.

사용자가 장바구니에 항목을 추가하면 addItemToCart 메소드가 호출됩니다. 여기에서 장바구니에 대한 주문이 이미 생성되었는지 확인합니다. 그렇지 않은 경우 OrderService 를 사용하여 새 항목이 만들어집니다. 그런 다음 제품에 해당하는 순서대로 품목이 생성됩니다. 장바구니에 대한 주문이 이미 있는 경우 라인 항목만 생성됩니다. 요청 상태에 따라 스낵바 메시지가 사용자에게 표시됩니다.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-product', templateUrl: './product.component.html', styleUrls: ['./product.component.css'] }) export class ProductComponent implements OnInit { id: string = ''; product!: Sku; quantity: number = 0; constructor( private route: ActivatedRoute, private skus: SkuService, private location: Location, private router: Router, private header: HeaderService, private orders: OrderService, private lineItems: LineItemService, private cart: CartService, private snackBar: MatSnackBar ) { } ngOnInit() { this.route.paramMap .pipe( mergeMap(params => { const id = params.get('id') this.id = id ? id : ''; return this.skus.getSku(this.id); }), tap((sku) => { this.product = sku; }) ).subscribe({ error: (err) => this.router.navigateByUrl('/error') }); this.header.setHeaderButtonsVisibility(true); } addItemToCart() { if (this.quantity > 0) { if (this.cart.orderId == '') { this.orders.createOrder() .pipe( mergeMap((order: Order) => { this.cart.orderId = order.id || ''; return this.lineItems.createLineItem({ orderId: order.id, name: this.product.name, imageUrl: this.product.imageUrl, quantity: this.quantity, skuCode: this.product.code }); }) ) .subscribe( () => { this.cart.incrementItemCount(this.quantity); this.showSuccessSnackBar(); }, err => this.showErrorSnackBar() ); } else { this.lineItems.createLineItem({ orderId: this.cart.orderId, name: this.product.name, imageUrl: this.product.imageUrl, quantity: this.quantity, skuCode: this.product.code }).subscribe( () => { this.cart.incrementItemCount(this.quantity); this.showSuccessSnackBar(); }, err => this.showErrorSnackBar() ); } } else { this.snackBar.open('Select a quantity greater than 0.', 'Close', { duration: 8000 }); } } setQuantity(no: number) { this.quantity = no; } goBack() { this.location.back(); } private showSuccessSnackBar() { this.snackBar.open('Item successfully added to cart.', 'Close', { duration: 8000 }); } private showErrorSnackBar() { this.snackBar.open('Failed to add your item to the cart.', 'Close', { duration: 8000 }); } }

ProductComponent 템플릿은 다음과 같으며 스타일링은 여기에 링크됩니다.

 <div> <mat-card *ngIf="product" class="product-card"> <img mat-card-image src="{{product.imageUrl}}" alt="Photo of a product"> <mat-card-content> <mat-card-title>{{product.name}}</mat-card-title> <mat-card-subtitle>{{product.prices[0].compareAtAmountFloat | currency:'EUR'}}</mat-card-subtitle> <p> {{product.description}} </p> </mat-card-content> <mat-card-actions> <app-item-quantity [quantity]="quantity" [maxValue]="10" (setQuantityEvent)="setQuantity($event)"></app-item-quantity> <button mat-raised-button color="accent" (click)="addItemToCart()"> <mat-icon>add_shopping_cart</mat-icon> Add to cart </button> <button mat-raised-button color="primary" (click)="goBack()"> <mat-icon>storefront</mat-icon> Continue shopping </button> </mat-card-actions> </mat-card> </div>

페이지는 다음과 같을 것입니다.

제품 페이지 스크린샷
제품 페이지의 스크린샷입니다. (큰 미리보기)

인증 모듈

Auth 모듈에는 인증을 담당하는 페이지가 포함되어 있습니다. 여기에는 로그인 및 가입 페이지가 포함됩니다. 다음과 같이 구성되어 있습니다.

 src/app/features/auth/ ├── auth.module.ts └── pages ├── login │ ├── login.component.css │ ├── login.component.html │ └── login.component.ts └── signup ├── signup.component.css ├── signup.component.html └── signup.component.ts

그것과 그 구성 요소를 생성하려면:

 ng gm features/auth ng gc features/auth/pages/signup ng gc features/auth/pages/login

이것은 모듈 파일입니다.

 @NgModule({ declarations: [LoginComponent, SignupComponent], imports: [ RouterModule.forChild([ { path: 'login', component: LoginComponent }, { path: 'signup', component: SignupComponent } ]), MatFormFieldModule, MatInputModule, ReactiveFormsModule, SharedModule ] }) export class AuthModule { }

가입 구성 요소

사용자는 이 구성 요소를 사용하여 계정에 등록합니다. 프로세스에 이름, 성, 이메일 및 비밀번호가 필요합니다. 사용자는 비밀번호도 확인해야 합니다. 입력 필드는 FormBuilder 서비스로 생성됩니다. 모든 입력에 값이 있어야 하는 유효성 검사가 추가되었습니다. 최소 8자 길이를 보장하기 위해 암호 필드에 추가 유효성 검사가 추가됩니다. 사용자 지정 matchPasswords 유효성 검사기는 확인된 암호가 초기 암호와 일치하는지 확인합니다.

구성 요소가 초기화되면 헤더의 장바구니, 로그인 및 로그아웃 버튼이 숨겨집니다. 이것은 HeaderService 를 사용하여 헤더에 전달됩니다.

모든 필드가 유효한 것으로 표시된 후 사용자는 등록할 수 있습니다. signup 메소드에서 CustomerServicecreateCustomer 메소드가 이 입력을 수신합니다. 가입에 성공하면 스낵바를 사용하여 계정이 성공적으로 생성되었음을 사용자에게 알립니다. 그런 다음 홈 페이지로 다시 라우팅됩니다.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-signup', templateUrl: './signup.component.html', styleUrls: ['./signup.component.css'] }) export class SignupComponent implements OnInit { signupForm = this.fb.group({ firstName: ['', Validators.required], lastName: ['', Validators.required], email: ['', [Validators.required, Validators.email]], password: ['', [Validators.required, Validators.minLength(8)]], confirmedPassword: ['', [Validators.required]] }, { validators: this.matchPasswords }); @ViewChild(FormGroupDirective) sufDirective: FormGroupDirective | undefined; constructor( private customer: CustomerService, private fb: FormBuilder, private snackBar: MatSnackBar, private router: Router, private header: HeaderService ) { } ngOnInit() { this.header.setHeaderButtonsVisibility(false); } matchPasswords(signupGroup: AbstractControl): ValidationErrors | null { const password = signupGroup.get('password')?.value; const confirmedPassword = signupGroup.get('confirmedPassword')?.value; return password == confirmedPassword ? null : { differentPasswords: true }; } get password() { return this.signupForm.get('password'); } get confirmedPassword() { return this.signupForm.get('confirmedPassword'); } signup() { const customer = this.signupForm.value; this.customer.createCustomer( customer.email, customer.password, customer.firstName, customer.lastName ).subscribe( () => { this.signupForm.reset(); this.sufDirective?.resetForm(); this.snackBar.open('Account successfully created. You will be redirected in 5 seconds.', 'Close', { duration: 5000 }); setTimeout(() => this.router.navigateByUrl('/'), 6000); }, err => this.snackBar.open('There was a problem creating your account.', 'Close', { duration: 5000 }) ); } }

아래는 SignupComponent 에 대한 템플릿입니다.

 <form [formGroup]="signupForm" (ngSubmit)="signup()"> <h1 class="mat-display-3">Create Account</h1> <mat-form-field appearance="outline"> <mat-label>First Name</mat-label> <input matInput formControlName="firstName"> <mat-icon matPrefix>portrait</mat-icon> </mat-form-field> <mat-form-field appearance="outline"> <mat-label>Last Name</mat-label> <input matInput formControlName="lastName"> <mat-icon matPrefix>portrait</mat-icon> </mat-form-field> <mat-form-field appearance="outline"> <mat-label>Email</mat-label> <input matInput formControlName="email" type="email"> <mat-icon matPrefix>alternate_email</mat-icon> </mat-form-field> <mat-form-field appearance="outline"> <mat-label>Password</mat-label> <input matInput formControlName="password" type="password"> <mat-icon matPrefix>vpn_key</mat-icon> </mat-form-field> <mat-form-field appearance="outline"> <mat-label>Confirm Password</mat-label> <input matInput formControlName="confirmedPassword" type="password"> <mat-icon matPrefix>vpn_key</mat-icon> </mat-form-field> <div *ngIf="confirmedPassword?.invalid && (confirmedPassword?.dirty || confirmedPassword?.touched)"> <mat-error *ngIf="signupForm.hasError('differentPasswords')"> Your passwords do not match. </mat-error> </div> <div *ngIf="password?.invalid && (password?.dirty || password?.touched)"> <mat-error *ngIf="password?.hasError('minlength')"> Your password should be at least 8 characters. </mat-error> </div> <button mat-flat-button color="primary" [disabled]="!signupForm.valid">Sign Up</button> </form>

구성 요소는 다음과 같이 나타납니다.

가입 페이지 스크린샷
가입 페이지의 스크린샷입니다. (큰 미리보기)

로그인 구성 요소

등록된 사용자는 이 구성 요소를 사용하여 계정에 로그인합니다. 이메일과 비밀번호를 입력해야 합니다. 해당 입력 필드에는 필수 입력 필드인 유효성 검사가 있습니다.

SignupComponent 와 유사하게 헤더의 장바구니, 로그인 및 로그아웃 버튼은 숨겨져 있습니다. 가시성은 구성 요소 초기화 중에 HeaderService 를 사용하여 설정됩니다.

로그인하려면 자격 증명이 AuthenticationService 로 전달됩니다. 성공하면 사용자의 로그인 상태가 SessionService 를 사용하여 설정됩니다. 그런 다음 사용자는 그들이 있던 페이지로 다시 라우팅됩니다. 실패하면 스낵바가 오류와 함께 표시되고 암호 필드가 재설정됩니다.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-login', templateUrl: './login.component.html', styleUrls: ['./login.component.css'] }) export class LoginComponent implements OnInit { loginForm = this.fb.group({ email: ['', Validators.required], password: ['', Validators.required] }); constructor( private authService: AuthenticationService, private session: SessionService, private snackBar: MatSnackBar, private fb: FormBuilder, private header: HeaderService, private location: Location ) { } ngOnInit() { this.header.setHeaderButtonsVisibility(false); } login() { const credentials = this.loginForm.value; this.authService.login( credentials.email, credentials.password ).subscribe( () => { this.session.setLoggedInStatus(true); this.location.back(); }, err => { this.snackBar.open( 'Login failed. Check your login credentials.', 'Close', { duration: 6000 }); this.loginForm.patchValue({ password: '' }); } ); } }

아래는 LoginComponent 템플릿입니다.

 <form [formGroup]="loginForm" (ngSubmit)="login()"> <h1 class="mat-display-3">Login</h1> <mat-form-field appearance="outline"> <mat-label>Email</mat-label> <input matInput type="email" formControlName="email" required> <mat-icon matPrefix>alternate_email</mat-icon> </mat-form-field> <mat-form-field appearance="outline"> <mat-label>Password</mat-label> <input matInput type="password" formControlName="password" required> <mat-icon matPrefix>vpn_key</mat-icon> </mat-form-field> <button mat-flat-button color="primary" [disabled]="!loginForm.valid">Login</button> <p class="mat-h3">Not registered yet? <a routerLink="/signup">Create an account.</a></p> </form>

다음은 페이지의 스크린샷입니다.

로그인 페이지 스크린샷
로그인 페이지의 스크린샷입니다. (큰 미리보기)

카트 모듈

장바구니 모듈에는 장바구니와 관련된 모든 페이지가 포함됩니다. 여기에는 주문 요약 페이지, 쿠폰 및 기프트 카드 코드 페이지, 빈 장바구니 페이지가 포함됩니다. 다음과 같이 구성되어 있습니다.

 src/app/features/cart/ ├── cart.module.ts └── pages ├── codes │ ├── codes.component.css │ ├── codes.component.html │ └── codes.component.ts ├── empty │ ├── empty.component.css │ ├── empty.component.html │ └── empty.component.ts └── summary ├── summary.component.css ├── summary.component.html └── summary.component.ts

생성하려면 다음을 실행하십시오.

 ng gm features/cart ng gc features/cart/codes ng gc features/cart/empty ng gc features/cart/summary

이것은 모듈 파일입니다.

 @NgModule({ declarations: [SummaryComponent, CodesComponent, EmptyComponent], imports: [ RouterModule.forChild([ { path: '', canActivate: [EmptyCartGuard], children: [ { path: 'cart', component: SummaryComponent }, { path: 'codes', component: CodesComponent } ] }, { path: 'empty', component: EmptyComponent } ]), MatDividerModule, MatFormFieldModule, MatInputModule, MatMenuModule, ReactiveFormsModule, SharedModule ] }) export class CartModule { }

코드 구성 요소

앞서 언급했듯이 이 구성 요소는 주문에 쿠폰 또는 기프트 카드 코드를 추가하는 데 사용됩니다. 이를 통해 사용자는 결제를 진행하기 전에 주문 총액에 할인을 적용할 수 있습니다.

두 개의 입력 필드가 있습니다. 하나는 쿠폰용이고 다른 하나는 기프트 카드 코드용입니다.

코드는 주문을 업데이트하여 추가됩니다. OrderServiceupdateOrder 메서드는 코드로 주문을 업데이트합니다. 그런 다음 두 필드가 모두 재설정되고 사용자에게 스낵바 작업이 성공했음을 알립니다. 오류가 발생하면 스낵바도 표시됩니다. addCouponaddGiftCard 메소드는 모두 updateOrder 메소드를 호출합니다.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-codes', templateUrl: './codes.component.html', styleUrls: ['./codes.component.css'] }) export class CodesComponent { couponCode = new FormControl(''); giftCardCode = new FormControl(''); @ViewChild(FormControlDirective) codesDirective: FormControlDirective | undefined; constructor( private cart: CartService, private order: OrderService, private snackBar: MatSnackBar ) { } private updateOrder(order: Order, params: UpdateOrderParams[], codeType: string) { this.order.updateOrder(order, params) .subscribe( () => { this.snackBar.open(`Successfully added ${codeType} code.`, 'Close', { duration: 8000 }); this.couponCode.reset(); this.giftCardCode.reset(); this.codesDirective?.reset(); }, err => this.snackBar.open(`There was a problem adding your ${codeType} code.`, 'Close', { duration: 8000 }) ); } addCoupon() { this.updateOrder({ id: this.cart.orderId, couponCode: this.couponCode.value }, [UpdateOrderParams.couponCode], 'coupon'); } addGiftCard() { this.updateOrder({ id: this.cart.orderId, giftCardCode: this.giftCardCode.value }, [UpdateOrderParams.giftCardCode], 'gift card'); } }

템플릿은 아래에 표시되며 해당 스타일은 이 링크에서 찾을 수 있습니다.

 <div> <app-title title="Redeem a code" subtitle="Enter a coupon code or gift card" [centerText]="true"></app-title> <div class="input-row"> <mat-form-field appearance="outline"> <mat-label>Coupon Code</mat-label> <input matInput [formControl]="couponCode" required> <mat-icon matPrefix>card_giftcard</mat-icon> </mat-form-field> <button class="redeem" mat-flat-button color="accent" [disabled]="couponCode.invalid" (click)="addCoupon()">Redeem</button> </div> <div class="input-row"> <mat-form-field appearance="outline"> <mat-label>Gift Card Code</mat-label> <input matInput [formControl]="giftCardCode" required> <mat-icon matPrefix>redeem</mat-icon> </mat-form-field> <button class="redeem" mat-flat-button color="accent" [disabled]="giftCardCode.invalid" (click)="addGiftCard()">Redeem</button> </div> <button color="primary" mat-flat-button routerLink="/cart"> <mat-icon>shopping_cart</mat-icon> CONTINUE TO CART </button> </div>

다음은 페이지의 스크린샷입니다.

코드 페이지 스크린샷
코드 페이지의 스크린샷. (큰 미리보기)

빈 구성 요소

빈 카트로 체크아웃을 할 수 없어야 합니다. 사용자가 장바구니가 비어 있는 체크아웃 모듈 페이지에 액세스하지 못하도록 하는 가드가 필요합니다. 이것은 이미 CoreModule 의 일부로 다루어졌습니다. 가드는 빈 카트가 있는 체크아웃 페이지에 대한 요청을 EmptyCartComponent 로 리디렉션합니다.

장바구니가 비어 있음을 사용자에게 알리는 텍스트가 있는 매우 간단한 구성 요소입니다. 또한 사용자가 클릭하여 홈페이지로 이동하여 장바구니에 물건을 추가할 수 있는 버튼이 있습니다. 따라서 SimplePageComponent 를 사용하여 표시합니다. 다음은 템플릿입니다.

 <app-simple-page title="Your cart is empty" subtitle="There is currently nothing in your cart. Head to the home page to add items." buttonText="GO TO HOME PAGE" icon="shopping_basket" [centerText]="true" route="/"> </app-simple-page>

다음은 페이지의 스크린샷입니다.

빈 장바구니 페이지의 스크린샷
빈 장바구니 페이지의 스크린샷입니다. (큰 미리보기)

요약 구성 요소

이 구성 요소는 장바구니/주문을 요약합니다. 여기에는 카트의 모든 항목, 이름, 수량 및 사진이 나열됩니다. 또한 세금, 배송 및 할인을 포함한 주문 비용을 분류합니다. 사용자는 이것을 보고 체크아웃을 진행하기 전에 항목과 비용에 만족하는지 결정할 수 있어야 합니다.

초기화 시 OrderService 를 사용하여 주문 및 해당 라인 항목을 가져옵니다. 사용자는 광고 항목을 수정하거나 주문에서 제거할 수 있어야 합니다. deleteLineItem 메서드가 호출되면 항목이 제거됩니다. 여기서 LineItemServicedeleteLineItem 메소드는 삭제할 라인 항목의 ID를 수신합니다. 삭제에 성공하면 CartService 를 사용하여 장바구니의 항목 수를 업데이트합니다.

그런 다음 사용자는 체크아웃 프로세스를 시작하는 고객 페이지로 연결됩니다. checkout 방법은 라우팅을 수행합니다.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-summary', templateUrl: './summary.component.html', styleUrls: ['./summary.component.css'] }) export class SummaryComponent implements OnInit { order: Order = {}; summary: { name: string, amount: string | undefined, id: string }[] = []; constructor( private orders: OrderService, private lineItems: LineItemService, private cart: CartService, private snackBar: MatSnackBar, private router: Router ) { } ngOnInit() { this.orders.getOrder(this.cart.orderId, GetOrderParams.cart) .subscribe( order => this.processOrder(order), err => this.showOrderError('retrieving your cart') ); } private processOrder(order: Order) { this.order = order; this.summary = [ { name: 'Subtotal', amount: order.formattedSubtotalAmount, id: 'subtotal' }, { name: 'Discount', amount: order.formattedDiscountAmount, id: 'discount' }, { name: 'Taxes (included)', amount: order.formattedTotalTaxAmount, id: 'taxes' }, { name: 'Shipping', amount: order.formattedShippingAmount, id: 'shipping' }, { name: 'Gift Card', amount: order.formattedGiftCardAmount, id: 'gift-card' } ]; } private showOrderError(msg: string) { this.snackBar.open(`There was a problem ${msg}.`, 'Close', { duration: 8000 }); } checkout() { this.router.navigateByUrl('/customer'); } deleteLineItem(id: string) { this.lineItems.deleteLineItem(id) .pipe( mergeMap(() => this.orders.getOrder(this.cart.orderId, GetOrderParams.cart)) ).subscribe( order => { this.processOrder(order); this.cart.itemCount = order.skusCount || this.cart.itemCount; this.snackBar.open(`Item successfully removed from cart.`, 'Close', { duration: 8000 }) }, err => this.showOrderError('deleting your order') ); } }

아래는 템플릿이며 스타일은 여기에 링크됩니다.

 <div class="container" *ngIf="order"> <h3>Order #{{order.number}} ({{order.skusCount}} items)</h3> <div class="line-item" *ngFor="let item of order.lineItems"> <div> <img *ngIf="item.imageUrl" class="image-xs" src="{{item.imageUrl}}" alt="product photo"> <div *ngIf="!item.imageUrl" class="image-xs no-image"></div> <div> <div>{{item.name}}</div> <div> {{item.formattedUnitAmount }} </div> </div> </div> <div> <app-item-quantity [quantity]="item.quantity || 0" [disabled]="true"></app-item-quantity> <div class="itemTotal"> {{item.formattedTotalAmount }} </div> <button mat-icon-button color="warn" (click)="deleteLineItem(item.id || '')"> <mat-icon>clear</mat-icon> </button> </div> </div> <mat-divider></mat-divider> <div class="costSummary"> <div class="costItem" *ngFor="let item of summary" [id]="item.id"> <h3 class="costLabel">{{item.name}}</h3> <p> {{item.amount }} </p> </div> </div> <mat-divider></mat-divider> <div class="costSummary"> <div class="costItem"> <h2>Total</h2> <h2> {{order.formattedTotalAmountWithTaxes}} </h2> </div> </div> <div> <button color="accent" mat-flat-button routerLink="/codes"> <mat-icon>redeem</mat-icon> ADD GIFT CARD/COUPON </button> <button color="primary" mat-flat-button (click)="checkout()"> <mat-icon>point_of_sale</mat-icon> CHECKOUT </button> </div> </div>

다음은 페이지의 스크린샷입니다.

요약 페이지의 스크린샷
요약 페이지의 스크린샷. (큰 미리보기)

체크아웃 모듈

이 모듈은 체크아웃 프로세스를 담당합니다. 체크아웃에는 청구 및 배송 주소, 고객 이메일 제공, 배송 및 결제 방법 선택이 포함됩니다. 이 프로세스의 마지막 단계는 주문의 배치 및 확인입니다. 모듈의 구조는 다음과 같습니다.

 src/app/features/checkout/ ├── components │ ├── address │ ├── address-list │ └── country-select └── pages ├── billing-address ├── cancel-payment ├── customer ├── payment ├── place-order ├── shipping-address └── shipping-methods

이 모듈은 지금까지 가장 큰 것으로 3개의 구성 요소와 7개의 페이지로 구성되어 있습니다. 이를 생성하고 구성 요소를 실행하려면 다음을 수행합니다.

 ng gm features/checkout for comp in \ address address-list country-select; do \ ng gc "features/checkout/components/${comp}" \ ; done for page in \ billing-address cancel-payment customer \ payment place-order shipping-address \ shipping-methods; do \ ng gc "features/checkout/pages/${page}"; done

이것은 모듈 파일입니다.

 @NgModule({ declarations: [ CustomerComponent, AddressComponent, BillingAddressComponent, ShippingAddressComponent, ShippingMethodsComponent, PaymentComponent, PlaceOrderComponent, AddressListComponent, CountrySelectComponent, CancelPaymentComponent ], imports: [ RouterModule.forChild([ { path: '', canActivate: [EmptyCartGuard], children: [ { path: 'billing-address', component: BillingAddressComponent }, { path: 'cancel-payment', component: CancelPaymentComponent }, { path: 'customer', component: CustomerComponent }, { path: 'payment', component: PaymentComponent }, { path: 'place-order', component: PlaceOrderComponent }, { path: 'shipping-address', component: ShippingAddressComponent }, { path: 'shipping-methods', component: ShippingMethodsComponent } ] } ]), MatCardModule, MatCheckboxModule, MatDividerModule, MatInputModule, MatMenuModule, MatRadioModule, ReactiveFormsModule, SharedModule ] }) export class CheckoutModule { }

구성품

국가 선택 구성 요소

이 구성 요소를 사용하면 사용자가 주소의 일부로 국가를 선택할 수 있습니다. 재료 선택 구성 요소는 주소 양식의 입력 필드와 비교할 때 모양이 상당히 다릅니다. 따라서 균일성을 위해 재료 메뉴 구성 요소가 대신 사용됩니다.

구성 요소가 초기화되면 CountryService 를 사용하여 국가 코드 데이터를 가져옵니다. countries 속성은 서비스에서 반환된 값을 보유합니다. 이 값은 템플릿의 메뉴에 추가됩니다.

구성 요소에는 하나의 출력 속성인 setCountryEvent 가 있습니다. 국가가 선택되면 이 이벤트는 국가의 alpha-2 코드를 내보냅니다.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-country-select', templateUrl: './country-select.component.html', styleUrls: ['./country-select.component.css'] }) export class CountrySelectComponent implements OnInit { country: string = 'Country'; countries: Country[] = []; @Output() setCountryEvent = new EventEmitter<string>(); constructor(private countries: CountryService) { } ngOnInit() { this.countries.getCountries() .subscribe( countries => { this.countries = countries; } ); } setCountry(value: Country) { this.country = value.name || ''; this.setCountryEvent.emit(value.code); }}

아래는 템플릿이고 여기에 링크된 것은 스타일입니다.

 <button mat-stroked-button [matMenuTriggerFor]="countryMenu"> {{country}} <mat-icon>expand_more</mat-icon> </button> <mat-menu #countryMenu="matMenu"> <button *ngFor="let cnt of countries" (click)="setCountry(cnt)" mat-menu-item>{{cnt.name}}</button> </mat-menu>

주소 구성 요소

주소를 캡처하는 양식입니다. 배송 및 청구 주소 페이지 모두에서 사용됩니다. 유효한 Commerce Layer 주소에는 이름과 성, 주소 입력란, 도시, 우편번호, 주 코드, 국가 코드 및 전화번호가 포함되어야 합니다.

FormBuilder 서비스는 양식 그룹을 생성합니다. 이 구성 요소는 여러 페이지에서 사용되기 때문에 많은 입력 및 출력 속성이 있습니다. 입력 속성에는 버튼 텍스트, 표시되는 제목 및 확인란 텍스트가 포함됩니다. 출력 속성은 버튼을 클릭하여 주소를 생성할 때와 체크박스 값이 변경될 때를 위한 이벤트 이미터가 됩니다.

버튼을 클릭하면 addAddress 메서드가 호출되고 createAddress 이벤트가 전체 주소를 내보냅니다. 마찬가지로 확인란이 선택되면 isCheckboxChecked 이벤트가 확인란 값을 내보냅니다.

 @Component({ selector: 'app-address', templateUrl: './address.component.html', styleUrls: ['./address.component.css'] }) export class AddressComponent { @Input() buttonText: string = ''; @Input() showTitle?: boolean = false; @Output() createAddress = new EventEmitter<Address>(); @Input() checkboxText: string = ''; @Output() isCheckboxChecked = new EventEmitter<boolean>(); countryCode: string = ''; addressForm = this.fb.group({ firstName: [''], lastName: [''], line1: [''], city: [''], zipCode: [''], stateCode: [''], phone: [''] }); @ViewChild(FormGroupDirective) afDirective: FormGroupDirective | undefined; constructor(private fb: FormBuilder) { } setCountryCode(code: string) { this.countryCode = code; } addAddress() { this.createAddress.emit({ firstName: this.addressForm.get('firstName')?.value, lastName: this.addressForm.get('lastName')?.value, line1: this.addressForm.get('line1')?.value, city: this.addressForm.get('city')?.value, zipCode: this.addressForm.get('zipCode')?.value, stateCode: this.addressForm.get('stateCode')?.value || 'N/A', countryCode: this.countryCode, phone: this.addressForm.get('phone')?.value }); } setCheckboxValue(change: MatCheckboxChange) { if (this.isCheckboxChecked) { this.isCheckboxChecked.emit(change.checked); } } }

This is its template and its styling is linked here.

 <form [formGroup]="addressForm"> <p class="mat-headline" *ngIf="showTitle">Or add a new address</p> <div class="row"> <mat-form-field appearance="outline"> <mat-label>First Name</mat-label> <input matInput formControlName="firstName"> </mat-form-field> <mat-form-field appearance="outline"> <mat-label>Last Name</mat-label> <input matInput formControlName="lastName"> </mat-form-field> </div> <div class="row"> <mat-form-field appearance="outline"> <mat-label>Address</mat-label> <input matInput formControlName="line1"> </mat-form-field> <mat-form-field appearance="outline"> <mat-label>City</mat-label> <input matInput formControlName="city"> </mat-form-field> </div> <div class="row"> <mat-form-field appearance="outline"> <mat-label>State Code</mat-label> <input matInput formControlName="stateCode"> </mat-form-field> <mat-form-field appearance="outline"> <mat-label>Zip Code</mat-label> <input matInput formControlName="zipCode"> </mat-form-field> </div> <div class="row"> <mat-form-field appearance="outline"> <mat-label>Phone</mat-label> <input matInput formControlName="phone"> </mat-form-field> <app-country-select (setCountryEvent)="setCountryCode($event)"></app-country-select> </div> <mat-checkbox color="accent" (change)="setCheckboxValue($event)"> {{checkboxText}} </mat-checkbox> <button mat-flat-button color="primary" (click)="addAddress()"> {{buttonText}} </button> </form>

Address List Component

When a customer logs in, they can access their existing addresses. Instead of having them re-enter an address, they can pick from an address list. This is the purpose of this component. On initialization, all the customer's addresses are fetched using the CustomerAddressService if they are logged in. We will check their login status using the SessionService .

This component has a setAddressEvent output property. When an address is selected, setAddressEvent emits its id to the parent component.

@Component({ selector: 'app-address-list', templateUrl: './address-list.component.html', styleUrls: ['./address-list.component.css'] }) export class AddressListComponent implements OnInit { addresses: CustomerAddress[] = []; @Output() setAddressEvent = new EventEmitter<string>(); constructor( private session: SessionService, private customerAddresses: CustomerAddressService, private snackBar: MatSnackBar ) { } ngOnInit() { this.session.loggedInStatus .pipe( mergeMap( status => iif(() => status, this.customerAddresses.getCustomerAddresses()) )) .subscribe( addresses => { if (addresses.length) { this.addresses = addresses } }, err => this.snackBar.open('There was a problem getting your existing addresses.', 'Close', { duration: 8000 }) ); } setAddress(change: MatRadioChange) { this.setAddressEvent.emit(change.value); } }

다음은 템플릿입니다. 여기에서 스타일링을 찾을 수 있습니다.

 <div> <p class="mat-headline">Pick an existing address</p> <mat-error *ngIf="!addresses.length">You have no existing addresses</mat-error> <mat-radio-group *ngIf="addresses.length" class="addresses" (change)="setAddress($event)"> <mat-card class="address" *ngFor="let address of addresses"> <mat-radio-button [value]="address.address?.id" color="primary"> <p>{{address.address?.firstName}} {{address.address?.lastName}},</p> <p>{{address.address?.line1}},</p> <p>{{address.address?.city}},</p> <p>{{address.address?.zipCode}},</p> <p>{{address.address?.stateCode}}, {{address.address?.countryCode}}</p> <p>{{address.address?.phone}}</p> </mat-radio-button> </mat-card> </mat-radio-group> </div>

페이지

고객 구성 요소

주문은 이메일 주소와 연결되어야 합니다. 이 구성 요소는 고객 이메일 주소를 캡처하는 양식입니다. 구성 요소가 초기화될 때 현재 고객이 로그인되어 있으면 현재 고객의 이메일 주소를 가져옵니다. CustomerService 에서 고객을 가져옵니다. 이메일 주소 변경을 원하지 않는 경우 이 이메일이 기본값이 됩니다.

이메일이 변경되었거나 고객이 로그인하지 않은 경우 입력한 이메일로 주문이 업데이트됩니다. OrderService 를 사용하여 새 이메일 주소로 주문을 업데이트합니다. 성공하면 고객을 청구서 수신 주소 페이지로 안내합니다.

 @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-customer', templateUrl: './customer.component.html', styleUrls: ['./customer.component.css'] }) export class CustomerComponent implements OnInit { email = new FormControl('', [Validators.required, Validators.email]); constructor( private orders: OrderService, private customers: CustomerService, private cart: CartService, private router: Router, private snackBar: MatSnackBar ) { } ngOnInit() { this.customers.getCurrentCustomer() .subscribe( customer => this.email.setValue(customer.email) ); } addCustomerEmail() { this.orders.updateOrder( { id: this.cart.orderId, customerEmail: this.email.value }, [UpdateOrderParams.customerEmail]) .subscribe( () => this.router.navigateByUrl('/billing-address'), err => this.snackBar.open('There was a problem adding your email to the order.', 'Close', { duration: 8000 }) ); } }

다음은 구성 요소 템플릿이고 여기에 링크된 것은 스타일입니다.

 <div> <app-title no="1" title="Customer" subtitle="Billing information and shipping address"></app-title> <mat-form-field appearance="outline"> <mat-label>Email</mat-label> <input matInput [formControl]="email" required> <mat-icon matPrefix>alternate_email</mat-icon> </mat-form-field> <button mat-flat-button color="primary" [disabled]="email.invalid" (click)="addCustomerEmail()"> PROCEED TO BILLING ADDRESS </button> </div>

다음은 고객 페이지의 스크린샷입니다.

고객 페이지 스크린샷
고객 페이지의 스크린샷입니다. (큰 미리보기)

청구서 수신 주소 구성요소

청구 주소 구성 요소를 사용하면 고객이 새 청구 주소를 추가하거나 기존 주소에서 선택할 수 있습니다. 로그인하지 않은 사용자는 새 주소를 입력해야 합니다. 로그인한 사용자는 새 주소 또는 기존 주소 중에서 선택할 수 있습니다.

showAddress 속성은 기존 주소를 구성 요소에 표시해야 하는지 여부를 나타냅니다. sameShippingAddressAsBilling 은 배송 주소가 청구 주소가 설정된 것과 같아야 하는지 여부를 나타냅니다. 고객이 기존 주소를 선택하면 해당 ID가 selectedCustomerAddressId 에 할당됩니다.

구성 요소가 초기화되면 SessionService 를 사용하여 현재 사용자가 로그인되어 있는지 확인합니다. 로그인되어 있으면 기존 주소가 있으면 표시합니다.

앞서 언급했듯이 사용자가 로그인하면 기존 주소를 청구서 수신 주소로 선택할 수 있습니다. updateBillingAddress 메소드에서 로그인한 경우 선택한 주소가 복제되어 주문의 청구서 수신 주소로 설정됩니다. OrderServiceupdateOrder 메서드를 사용하여 주문을 업데이트하고 주소 ID를 제공하여 이를 수행합니다.

로그인하지 않은 경우 사용자는 주소를 제공해야 합니다. 주소가 제공되면 createAddress 메소드를 사용하여 주소가 생성됩니다. 거기에서 AddressService 는 입력을 받아 새 주소를 만듭니다. 그런 다음 새로 생성된 주소의 ID를 사용하여 주문이 업데이트됩니다. 오류가 있거나 작업 중 하나가 성공하면 스낵바가 표시됩니다.

동일한 주소가 배송 주소로 선택되면 사용자는 배송 방법 페이지로 라우팅됩니다. 대체 배송 주소를 제공하려는 경우 배송 주소 페이지로 이동합니다.

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

배송 주소 페이지는 다음과 같습니다.

배송 주소 페이지의 스크린샷
배송 주소 페이지의 스크린샷. (큰 미리보기)

배송 방법 구성 요소

이 구성 요소는 주문을 이행하는 데 필요한 배송 수, 사용 가능한 배송 방법 및 관련 비용을 표시합니다. 그런 다음 고객은 각 배송에 대해 선호하는 배송 방법을 선택할 수 있습니다.

shipments 속성에는 주문의 모든 배송이 포함됩니다. ShippingsForm은 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 URL은 사용자가 버튼을 클릭할 때 연결되는 페이팔 링크입니다.

초기화하는 동안 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>

결제 페이지는 다음과 같습니다.

결제 페이지 스크린샷
결제 페이지 스크린샷입니다. (큰 미리보기)

결제 구성 요소 취소

페이팔에는 결제 취소 페이지가 필요합니다. 이 구성 요소는 이러한 목적을 수행합니다. 이것은 템플릿입니다.

 <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은 URL에 지불인 ID 쿼리 매개변수를 추가합니다. 사용자의 페이팔 ID입니다.

구성 요소가 초기화되면 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>

다음은 페이지의 스크린샷입니다.

주문 배치 페이지의 스크린샷
주문 배치 페이지의 스크린샷. (큰 미리보기)

앱 모듈

인증을 제외하고 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를 확인하세요. 이 저장소에서 이 프로젝트의 코드를 볼 수 있습니다.