Skip to content

feat: initial structure for components and b2b flow #20103

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Mar 26, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { Card, ICON_TYPE } from '@spartacus/storefront';
import { combineLatest, Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { CheckoutStepService } from '../services/checkout-step.service';
import '@spartacus/checkout/b2b/root';

@Component({
selector: 'cx-review-submit',
Expand All @@ -37,6 +38,7 @@ export class CheckoutReviewSubmitComponent {
readonly cartOutlets = CartOutlets;
iconTypes = ICON_TYPE;

checkoutStepTypePaymentType = CheckoutStepType.PAYMENT_TYPE;
checkoutStepTypeDeliveryAddress = CheckoutStepType.DELIVERY_ADDRESS;
checkoutStepTypePaymentDetails = CheckoutStepType.PAYMENT_DETAILS;
checkoutStepTypeDeliveryMode = CheckoutStepType.DELIVERY_MODE;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export const enum CheckoutStepType {
DELIVERY_MODE = 'deliveryMode',
PAYMENT_DETAILS = 'paymentDetails',
REVIEW_ORDER = 'reviewOrder',
// TODO: Add augmentation from OPF LIB
OPF_PAYMENT_AND_REVIEW = 'opfPaymentAndReview',
}

export const enum CheckoutStepState {
Expand Down
2 changes: 1 addition & 1 deletion integration-libs/opf/_index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ $opf-components-allowlist: cx-opf-payment-method-details,
cx-opf-checkout-billing-address-form, cx-opf-checkout-payment-wrapper,
cx-opf-checkout-terms-and-conditions-alert, cx-opf-error-modal,
cx-opf-cta-element, cx-opf-google-pay, cx-opf-apple-pay,
cx-opf-quick-buy-buttons !default;
cx-opf-quick-buy-buttons, cx-opf-b2b-checkout-payment-type !default;

$skipComponentStyles: () !default;

Expand Down
1 change: 1 addition & 0 deletions integration-libs/opf/base/root/model/opf-base.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface OpfActiveConfiguration {
displayName?: string;
acquirerCountryCode?: string;
logoUrl?: string;
code?: string;
}

export interface OpfActiveConfigurationsPagination {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
{
"opfCheckout": {
"tabs": {
"paymentType": "Method of payment",
"shipping": "Shipping",
"deliveryMethod": "Delivery Method",
"paymentAndReview": "Payment & Review"
},
"paymentAndReviewTitle": "Payment and review",
"billingAddress": "Billing Address",
"paymentOption": "Payment option",
"paymentMethod": "Method of Payment",
"noPaymentMethod": "No payment method selected",
"editCardDefaultLabel": "Edit",
"editPaymentMethod": "Edit payment method",
"poNumber": "Purchase Order Number",
"noPoNumber": "None",
"editPoNumber": "Edit purchase order number",
"termsAndConditions": "Terms & Conditions",
"closeTermsAndConditionsAlert": "Close alert",
"itemsToBeShipped": "Items to be shipped",
Expand Down
8 changes: 8 additions & 0 deletions integration-libs/opf/checkout/components/b2b/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* SPDX-FileCopyrightText: 2025 SAP Spartacus team <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0
*/

export * from './opf-b2b-checkout-components.module';
export * from './opf-b2b-checkout-payment-type/index';
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: 2025 SAP Spartacus team <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0
*/

import { NgModule } from '@angular/core';

import { OpfB2bCheckoutPaymentTypeModule } from './opf-b2b-checkout-payment-type';

@NgModule({
imports: [OpfB2bCheckoutPaymentTypeModule],
})
export class OpfB2bCheckoutComponentsModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* SPDX-FileCopyrightText: 2025 SAP Spartacus team <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0
*/

export * from './opf-b2b-checkout-payment-type.component';
export * from './opf-b2b-checkout-payment-type.module';
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<h2 class="cx-checkout-title d-none d-lg-block d-xl-block">
{{ 'checkoutB2B.methodOfPayment.paymentType' | cxTranslate }}
</h2>
<ng-container
*ngIf="(typeSelected$ | async) && !(isUpdating$ | async); else loading"
>
<ng-container>
<div
*cxFeature="'!a11yRemoveStatusLoadedRole'"
role="status"
[attr.aria-label]="'common.loaded' | cxTranslate"
></div>
<div class="row">
<div class="col-md-12 col-lg-6">
<label>
<span class="label-content">{{
'checkoutB2B.poNumber' | cxTranslate
}}</span>
<input
#poNumber
class="form-control"
formControlName="poNumber"
type="text"
placeholder="{{ 'checkoutB2B.placeholder' | cxTranslate }}"
value="{{ cartPoNumber$ | async }}"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does it affect perf to has a sync directly in form attribute?
wondering if we should subscribe it once in 'div' or 'container'?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it may affect performance. I will try to fix this.

/>
</label>
</div>
</div>

<cx-opf-checkout-payments
[isHeadingDisplayed]="false"
[isPaymentRenderBelow]="false"
[isPaymentInfoMessageEnabled]="false"
(paymentChange)="handlePaymentChange($event)"
></cx-opf-checkout-payments>

<div class="cx-checkout-btns row">
<div class="col-md-12 col-lg-6">
<button class="btn btn-block btn-secondary" (click)="back()">
{{ 'checkout.backToCart' | cxTranslate }}
</button>
</div>
<div class="col-md-12 col-lg-6">
<button class="btn btn-block btn-primary" (click)="next()">
{{ 'common.continue' | cxTranslate }}
</button>
</div>
</div>
</ng-container>
</ng-container>
<ng-template #loading>
<div class="cx-spinner" *ngIf="!paymentTypesError">
<cx-spinner></cx-spinner>
</div>
</ng-template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/*
* SPDX-FileCopyrightText: 2025 SAP Spartacus team <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0
*/

import {
ChangeDetectionStrategy,
Component,
ElementRef,
inject,
ViewChild,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { PaymentType } from '@spartacus/cart/base/root';
import {
B2BPaymentTypeEnum,
CheckoutPaymentTypeFacade,
} from '@spartacus/checkout/b2b/root';
import { CheckoutStepService } from '@spartacus/checkout/base/components';
import { CheckoutStepType } from '@spartacus/checkout/base/root';
import {
getLastValueSync,
GlobalMessageService,
GlobalMessageType,
HttpErrorModel,
isNotUndefined,
OccHttpErrorType,
} from '@spartacus/core';
import {
OpfActiveConfiguration,
OpfBaseFacade,
} from '@spartacus/opf/base/root';
import { BehaviorSubject, combineLatest, Observable, of } from 'rxjs';
import {
catchError,
distinctUntilChanged,
filter,
map,
tap,
} from 'rxjs/operators';

@Component({
selector: 'cx-opf-b2b-checkout-payment-type',
templateUrl: './opf-b2b-checkout-payment-type.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class OpfB2bCheckoutPaymentTypeComponent {
protected opfBaseService = inject(OpfBaseFacade);
@ViewChild('poNumber', { static: false })
private poNumberInputElement: ElementRef<HTMLInputElement>;

protected busy$ = new BehaviorSubject<boolean>(false);

typeSelected?: string;
paymentTypesError = false;

isUpdating$ = combineLatest([
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seeing a number of methods without return type, fee free to add them even though in this it makes sens it is a boolean form method name.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code will be evaluating, but I will add all needed typing for the final version.

this.busy$,
this.checkoutPaymentTypeFacade
.getSelectedPaymentTypeState()
.pipe(map((state) => state.loading)),
]).pipe(
map(([busy, loading]) => busy || loading),
distinctUntilChanged()
);

paymentTypes$: Observable<OpfActiveConfiguration[]> =
this.checkoutPaymentTypeFacade.getPaymentTypes().pipe(
tap(() => (this.paymentTypesError = false)),
catchError((error: HttpErrorModel) => {
if (
error.details?.[0]?.type === OccHttpErrorType.CLASS_MISMATCH_ERROR
) {
this.globalMessageService.add(
{ key: 'httpHandlers.forbidden' },
GlobalMessageType.MSG_TYPE_ERROR
);
this.paymentTypesError = true;
}
return of([]);
})
);

typeSelected$: Observable<PaymentType> = combineLatest([
this.checkoutPaymentTypeFacade.getSelectedPaymentTypeState().pipe(
filter((state) => !state.loading),
map((state) => state.data)
),
this.paymentTypes$,
]).pipe(
map(
([selectedPaymentType, availablePaymentTypes]: [
PaymentType | undefined,
PaymentType[],
]) => {
if (
selectedPaymentType &&
availablePaymentTypes.find((availablePaymentType) => {
return availablePaymentType.code === selectedPaymentType.code;
})
) {
return selectedPaymentType;
}
if (availablePaymentTypes.length) {
this.busy$.next(true);
this.checkoutPaymentTypeFacade
.setPaymentType(
availablePaymentTypes[0].code as string,
this.poNumberInputElement?.nativeElement?.value
)
.subscribe({
complete: () => this.onSuccess(),
error: () => this.onError(),
});
return availablePaymentTypes[0];
}
return undefined;
}
),
filter(isNotUndefined),
distinctUntilChanged(),
tap((selected) => {
this.typeSelected = selected?.code;
this.checkoutStepService.disableEnableStep(
CheckoutStepType.PAYMENT_DETAILS,
selected?.code === B2BPaymentTypeEnum.ACCOUNT_PAYMENT
);
this.checkoutStepService.disableEnableStep(
CheckoutStepType.REVIEW_ORDER,
selected?.code === B2BPaymentTypeEnum.CARD_PAYMENT
);
})
);

cartPoNumber$: Observable<string> = this.checkoutPaymentTypeFacade
.getPurchaseOrderNumberState()
.pipe(
filter((state) => !state.loading),
map((state) => state.data),
filter(isNotUndefined),
distinctUntilChanged()
);

constructor(
protected checkoutPaymentTypeFacade: CheckoutPaymentTypeFacade,
protected checkoutStepService: CheckoutStepService,
protected activatedRoute: ActivatedRoute,
protected globalMessageService: GlobalMessageService
) {}

changeType(code: string): void {
this.busy$.next(true);
this.typeSelected = code;

this.checkoutPaymentTypeFacade
.setPaymentType(code, this.poNumberInputElement?.nativeElement.value)
.subscribe({
complete: () => this.onSuccess(),
error: () => this.onError(),
});
}

next(): void {
if (!this.typeSelected) {
return;
}

const poNumberInput = this.poNumberInputElement?.nativeElement.value;
// if the PO number didn't change
if (poNumberInput === getLastValueSync(this.cartPoNumber$)) {
this.checkoutStepService.next(this.activatedRoute);
return;
}

this.busy$.next(true);
this.checkoutPaymentTypeFacade
.setPaymentType(this.typeSelected, poNumberInput)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to unsubscribe or we are 100% sure it complete after 1 time?
In the doubt should we add 'take(1)' or sub.add + ngDestroy callback?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, I will fix that.

.subscribe({
// we don't call onSuccess here, because it can cause a spinner flickering
complete: () => this.checkoutStepService.next(this.activatedRoute),
error: () => this.onError(),
});
}

back(): void {
this.checkoutStepService.back(this.activatedRoute);
}

protected onSuccess(): void {
this.busy$.next(false);
}

protected onError(): void {
this.busy$.next(false);
}

handlePaymentChange(payment: OpfActiveConfiguration) {
if (payment.merchantId === 'B2B_ACCOUNT') {
this.changeType(B2BPaymentTypeEnum.ACCOUNT_PAYMENT);
} else {
this.changeType(B2BPaymentTypeEnum.CARD_PAYMENT);
}
}
}
Loading