diff --git a/src/app/core/utils/dev/basket-mock-data.ts b/src/app/core/utils/dev/basket-mock-data.ts index 8a84f14f96..3d0d303686 100644 --- a/src/app/core/utils/dev/basket-mock-data.ts +++ b/src/app/core/utils/dev/basket-mock-data.ts @@ -18,6 +18,7 @@ export class BasketMockData { commonShippingMethod: BasketMockData.getShippingMethod(), payment: BasketMockData.getPayment(), totals: BasketMockData.getTotals(), + attributes: [{ name: 'orderReferenceID', value: '111-222-333' }], } as BasketView; } diff --git a/src/app/pages/checkout-review/checkout-review/checkout-review.component.html b/src/app/pages/checkout-review/checkout-review/checkout-review.component.html index f759e17f78..30461df4f0 100644 --- a/src/app/pages/checkout-review/checkout-review/checkout-review.component.html +++ b/src/app/pages/checkout-review/checkout-review/checkout-review.component.html @@ -35,7 +35,7 @@

- +
diff --git a/src/app/pages/checkout-shipping/checkout-shipping/checkout-shipping.component.html b/src/app/pages/checkout-shipping/checkout-shipping/checkout-shipping.component.html index 448c1befe3..12a6dde70b 100644 --- a/src/app/pages/checkout-shipping/checkout-shipping/checkout-shipping.component.html +++ b/src/app/pages/checkout-shipping/checkout-shipping/checkout-shipping.component.html @@ -7,59 +7,65 @@
-
-
- -

{{ 'checkout.shipping_method.selection.heading' | translate }}

+
+ +
+ +

{{ 'checkout.shipping_method.selection.heading' | translate }}

-
-
-
- - - + + + + + {{ 'checkout.general.back_to_cart.button.label' | translate }} + + +
- {{ 'checkout.general.back_to_cart.button.label' | translate }} - - + + +
diff --git a/src/app/pages/checkout-shipping/checkout-shipping/checkout-shipping.component.spec.ts b/src/app/pages/checkout-shipping/checkout-shipping/checkout-shipping.component.spec.ts index 003092e897..e8c1807c57 100644 --- a/src/app/pages/checkout-shipping/checkout-shipping/checkout-shipping.component.spec.ts +++ b/src/app/pages/checkout-shipping/checkout-shipping/checkout-shipping.component.spec.ts @@ -4,15 +4,17 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; import { MockComponent, MockDirective, MockPipe } from 'ng-mocks'; -import { anything, capture, spy, verify } from 'ts-mockito'; +import { anything, capture, instance, mock, spy, verify } from 'ts-mockito'; import { ServerHtmlDirective } from 'ish-core/directives/server-html.directive'; +import { AccountFacade } from 'ish-core/facades/account.facade'; import { PricePipe } from 'ish-core/models/price/price.pipe'; import { makeHttpError } from 'ish-core/utils/dev/api-service-utils'; import { BasketMockData } from 'ish-core/utils/dev/basket-mock-data'; import { BasketAddressSummaryComponent } from 'ish-shared/components/basket/basket-address-summary/basket-address-summary.component'; import { BasketCostSummaryComponent } from 'ish-shared/components/basket/basket-cost-summary/basket-cost-summary.component'; import { BasketItemsSummaryComponent } from 'ish-shared/components/basket/basket-items-summary/basket-items-summary.component'; +import { BasketOrderReferenceComponent } from 'ish-shared/components/basket/basket-order-reference/basket-order-reference.component'; import { BasketValidationResultsComponent } from 'ish-shared/components/basket/basket-validation-results/basket-validation-results.component'; import { ErrorMessageComponent } from 'ish-shared/components/common/error-message/error-message.component'; @@ -22,14 +24,18 @@ describe('Checkout Shipping Component', () => { let component: CheckoutShippingComponent; let fixture: ComponentFixture; let element: HTMLElement; + let accountFacade: AccountFacade; beforeEach(async () => { + accountFacade = mock(AccountFacade); + await TestBed.configureTestingModule({ declarations: [ CheckoutShippingComponent, MockComponent(BasketAddressSummaryComponent), MockComponent(BasketCostSummaryComponent), MockComponent(BasketItemsSummaryComponent), + MockComponent(BasketOrderReferenceComponent), MockComponent(BasketValidationResultsComponent), MockComponent(ErrorMessageComponent), MockComponent(FaIconComponent), @@ -38,6 +44,7 @@ describe('Checkout Shipping Component', () => { MockPipe(PricePipe), ], imports: [ReactiveFormsModule, TranslateModule.forRoot()], + providers: [{ provide: AccountFacade, useFactory: () => instance(accountFacade) }], }).compileComponents(); }); diff --git a/src/app/pages/checkout-shipping/checkout-shipping/checkout-shipping.component.ts b/src/app/pages/checkout-shipping/checkout-shipping/checkout-shipping.component.ts index ac04dacded..696bf0a1d1 100644 --- a/src/app/pages/checkout-shipping/checkout-shipping/checkout-shipping.component.ts +++ b/src/app/pages/checkout-shipping/checkout-shipping/checkout-shipping.component.ts @@ -10,9 +10,10 @@ import { SimpleChanges, } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; -import { Subject } from 'rxjs'; +import { Observable, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; +import { AccountFacade } from 'ish-core/facades/account.facade'; import { Basket } from 'ish-core/models/basket/basket.model'; import { HttpError } from 'ish-core/models/http-error/http-error.model'; import { ShippingMethod } from 'ish-core/models/shipping-method/shipping-method.model'; @@ -30,12 +31,18 @@ export class CheckoutShippingComponent implements OnInit, OnChanges, OnDestroy { @Output() updateShippingMethod = new EventEmitter(); @Output() nextStep = new EventEmitter(); + isBusinessCustomer$: Observable; + shippingForm: FormGroup; submitted = false; private destroy$ = new Subject(); + constructor(private accountFacade: AccountFacade) {} + ngOnInit() { + this.isBusinessCustomer$ = this.accountFacade.isBusinessCustomer$; + this.shippingForm = new FormGroup({ id: new FormControl(this.getCommonShippingMethod()), }); diff --git a/src/app/shared/components/basket/basket-buyer/basket-buyer.component.html b/src/app/shared/components/basket/basket-buyer/basket-buyer.component.html index 58a737c93f..bf4785997e 100644 --- a/src/app/shared/components/basket/basket-buyer/basket-buyer.component.html +++ b/src/app/shared/components/basket/basket-buyer/basket-buyer.component.html @@ -12,4 +12,17 @@ {{ userName }}
{{ object.email || object.invoiceToAddress?.email }}

+ +
+ + + + {{ 'checkout.widget.buyer.orderReferenceId' | translate }} +

{{ orderReferenceID }}

+
diff --git a/src/app/shared/components/basket/basket-buyer/basket-buyer.component.spec.ts b/src/app/shared/components/basket/basket-buyer/basket-buyer.component.spec.ts index 54f7724fc2..b257f47bd5 100644 --- a/src/app/shared/components/basket/basket-buyer/basket-buyer.component.spec.ts +++ b/src/app/shared/components/basket/basket-buyer/basket-buyer.component.spec.ts @@ -1,5 +1,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; import { of } from 'rxjs'; import { instance, mock, when } from 'ts-mockito'; @@ -20,8 +23,8 @@ describe('Basket Buyer Component', () => { accountFacade = mock(AccountFacade); await TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot()], - declarations: [BasketBuyerComponent], + imports: [RouterTestingModule, TranslateModule.forRoot()], + declarations: [BasketBuyerComponent, MockComponent(FaIconComponent)], providers: [{ provide: AccountFacade, useFactory: () => instance(accountFacade) }], }).compileComponents(); }); @@ -50,4 +53,10 @@ describe('Basket Buyer Component', () => { expect(element.querySelector('[data-testing-id="taxationID"]')).toBeTruthy(); expect(element.querySelector('[data-testing-id="taxationID"]').innerHTML).toContain('1234'); }); + + it('should display the order reference id of the customer', () => { + fixture.detectChanges(); + expect(element.querySelector('[data-testing-id="orderReferenceID"]')).toBeTruthy(); + expect(element.querySelector('[data-testing-id="orderReferenceID"]').innerHTML).toContain('111-222-333'); + }); }); diff --git a/src/app/shared/components/basket/basket-buyer/basket-buyer.component.ts b/src/app/shared/components/basket/basket-buyer/basket-buyer.component.ts index feaefa257f..931b9e4e99 100644 --- a/src/app/shared/components/basket/basket-buyer/basket-buyer.component.ts +++ b/src/app/shared/components/basket/basket-buyer/basket-buyer.component.ts @@ -16,11 +16,16 @@ import { whenTruthy } from 'ish-core/utils/operators'; }) export class BasketBuyerComponent implements OnInit, OnDestroy { @Input() object: Basket | Order; + /** + * Router link for editing the order reference id. If a routerLink is given a link is displayed to route to an edit page. + */ + @Input() editRouterLink?: string; customer$: Observable; user$: Observable; taxationID: string; + orderReferenceID: string; userName: string; private destroy$ = new Subject(); @@ -28,8 +33,10 @@ export class BasketBuyerComponent implements OnInit, OnDestroy { constructor(private accountFacade: AccountFacade) {} ngOnInit() { + this.taxationID = this.getAttributeValue('taxationID'); + this.orderReferenceID = this.getAttributeValue('orderReferenceID'); + // default values for anonymous users - this.taxationID = this.object.attributes?.find(attr => attr.name === 'taxationID')?.value as string; this.userName = `${this.object.invoiceToAddress?.firstName} ${this.object.invoiceToAddress?.lastName}`; this.customer$ = this.accountFacade.customer$; @@ -45,6 +52,10 @@ export class BasketBuyerComponent implements OnInit, OnDestroy { }); } + private getAttributeValue(attributeName: string): string { + return this.object.attributes?.find(attr => attr.name === attributeName)?.value as string; + } + ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); diff --git a/src/app/shared/components/basket/basket-order-reference/basket-order-reference.component.html b/src/app/shared/components/basket/basket-order-reference/basket-order-reference.component.html new file mode 100644 index 0000000000..10013bf51b --- /dev/null +++ b/src/app/shared/components/basket/basket-order-reference/basket-order-reference.component.html @@ -0,0 +1,19 @@ +
+

{{ 'checkout.orderReferenceId.title' | translate }}

+ +
+
+
+ +
+
+ +
+
+ +
+
+
+
diff --git a/src/app/shared/components/basket/basket-order-reference/basket-order-reference.component.spec.ts b/src/app/shared/components/basket/basket-order-reference/basket-order-reference.component.spec.ts new file mode 100644 index 0000000000..8081d59984 --- /dev/null +++ b/src/app/shared/components/basket/basket-order-reference/basket-order-reference.component.spec.ts @@ -0,0 +1,84 @@ +import { SimpleChange } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl, FormGroup } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; + +import { CheckoutFacade } from 'ish-core/facades/checkout.facade'; +import { Basket } from 'ish-core/models/basket/basket.model'; +import { SuccessMessageComponent } from 'ish-shared/components/common/success-message/success-message.component'; +import { FormlyTestingModule } from 'ish-shared/formly/dev/testing/formly-testing.module'; +import { SpecialValidators } from 'ish-shared/forms/validators/special-validators'; + +import { BasketOrderReferenceComponent } from './basket-order-reference.component'; + +describe('Basket Order Reference Component', () => { + let component: BasketOrderReferenceComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let checkoutFacade: CheckoutFacade; + + beforeEach(async () => { + checkoutFacade = mock(CheckoutFacade); + + await TestBed.configureTestingModule({ + imports: [FormlyTestingModule, TranslateModule.forRoot()], + declarations: [BasketOrderReferenceComponent, MockComponent(SuccessMessageComponent)], + providers: [{ provide: CheckoutFacade, useFactory: () => instance(checkoutFacade) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BasketOrderReferenceComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + + when(checkoutFacade.setBasketCustomAttribute(anything())).thenReturn(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should display order reference id input fields on form', () => { + fixture.detectChanges(); + + expect(element.innerHTML).toContain('orderReferenceId'); + }); + + it('should read the order reference id from the basket', () => { + component.basket = { + attributes: [{ name: 'orderReferenceID', value: '4711' }], + } as Basket; + + fixture.detectChanges(); + component.ngOnChanges({ basket: new SimpleChange(undefined, component.basket, false) }); + + expect(component.model.orderReferenceId).toBe('4711'); + }); + + it('should emit form when a valid form is submitted', () => { + fixture.detectChanges(); + + component.form = new FormGroup({ + orderReferenceId: new FormControl('xxx', SpecialValidators.noSpecialChars), + }); + component.submitForm(); + + verify(checkoutFacade.setBasketCustomAttribute(anything())).once(); + }); + + it('should not emit form when an invalid form is submitted', () => { + fixture.detectChanges(); + + component.form = new FormGroup({ + orderReferenceId: new FormControl('%%%', SpecialValidators.noSpecialChars), + }); + component.submitForm(); + + verify(checkoutFacade.setBasketCustomAttribute(anything())).never(); + }); +}); diff --git a/src/app/shared/components/basket/basket-order-reference/basket-order-reference.component.ts b/src/app/shared/components/basket/basket-order-reference/basket-order-reference.component.ts new file mode 100644 index 0000000000..1abb6b432f --- /dev/null +++ b/src/app/shared/components/basket/basket-order-reference/basket-order-reference.component.ts @@ -0,0 +1,97 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + OnChanges, + OnInit, + SimpleChange, + SimpleChanges, +} from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { FormlyFieldConfig } from '@ngx-formly/core'; + +import { CheckoutFacade } from 'ish-core/facades/checkout.facade'; +import { Basket } from 'ish-core/models/basket/basket.model'; +import { SpecialValidators } from 'ish-shared/forms/validators/special-validators'; + +@Component({ + selector: 'ish-basket-order-reference', + templateUrl: './basket-order-reference.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BasketOrderReferenceComponent implements OnInit, OnChanges { + @Input() basket: Basket; + + form = new FormGroup({}); + model: { orderReferenceId: string } = { orderReferenceId: '' }; + fields: FormlyFieldConfig[]; + + showSuccessMessage = false; + + constructor(private checkoutFacade: CheckoutFacade, private cd: ChangeDetectorRef) {} + + ngOnInit() { + this.fields = [ + { + key: 'orderReferenceId', + type: 'ish-text-input-field', + templateOptions: { + postWrappers: ['description'], + label: 'checkout.orderReferenceId.label', + maxLength: 35, + customDescription: { + class: 'input-help', + key: 'checkout.orderReferenceId.note', + }, + }, + validators: { + validation: [SpecialValidators.noSpecialChars], + }, + validation: { + messages: { + noSpecialChars: 'account.name.error.forbidden.chars', + }, + }, + }, + ]; + } + + ngOnChanges(changes: SimpleChanges) { + if (this.basket) { + this.successMessage(changes.basket); + this.model.orderReferenceId = this.getOrderReferenceId(this.basket); + } + } + + private getOrderReferenceId(basket: Basket): string { + return basket?.attributes?.find(attr => attr.name === 'orderReferenceID')?.value as string; + } + + private successMessage(basketChange: SimpleChange) { + if ( + this.getOrderReferenceId(basketChange?.previousValue) !== this.getOrderReferenceId(basketChange?.currentValue) && + !basketChange?.firstChange + ) { + this.showSuccessMessage = true; + setTimeout(() => { + this.showSuccessMessage = false; + this.cd.markForCheck(); + }, 5000); + } + } + + get disabled() { + return this.form.invalid || (!this.getOrderReferenceId(this.basket) && !this.form.get('orderReferenceId').value); + } + + submitForm() { + if (this.disabled) { + return; + } + this.checkoutFacade.setBasketCustomAttribute({ + name: 'orderReferenceID', + value: this.form.get('orderReferenceId').value, + }); + } +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index d0cc7c2717..befef18966 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -56,6 +56,7 @@ import { BasketBuyerComponent } from './components/basket/basket-buyer/basket-bu import { BasketCostSummaryComponent } from './components/basket/basket-cost-summary/basket-cost-summary.component'; import { BasketInfoComponent } from './components/basket/basket-info/basket-info.component'; import { BasketItemsSummaryComponent } from './components/basket/basket-items-summary/basket-items-summary.component'; +import { BasketOrderReferenceComponent } from './components/basket/basket-order-reference/basket-order-reference.component'; import { BasketPromotionCodeComponent } from './components/basket/basket-promotion-code/basket-promotion-code.component'; import { BasketPromotionComponent } from './components/basket/basket-promotion/basket-promotion.component'; import { BasketValidationItemsComponent } from './components/basket/basket-validation-items/basket-validation-items.component'; @@ -216,6 +217,7 @@ const exportedComponents = [ BasketInfoComponent, BasketInvoiceAddressWidgetComponent, BasketItemsSummaryComponent, + BasketOrderReferenceComponent, BasketPromotionCodeComponent, BasketPromotionComponent, BasketShippingAddressWidgetComponent, diff --git a/src/assets/i18n/de_DE.json b/src/assets/i18n/de_DE.json index acffdc52f7..a5cd98cd3d 100644 --- a/src/assets/i18n/de_DE.json +++ b/src/assets/i18n/de_DE.json @@ -611,6 +611,11 @@ "checkout.order.number.label": "Ihre Bestellnummer ist:", "checkout.order.shipping.label": "Versand", "checkout.order.total_cost.label": "Gesamtkosten", + "checkout.orderReferenceId.apply.button.label": "Übernehmen", + "checkout.orderReferenceId.label": "Bestellungs-ID", + "checkout.orderReferenceId.note": "Sie können eine ID für Ihre eigene Buchhaltung eingeben. Diese wird auf der Rechnung und dem Packzettel erscheinen.", + "checkout.orderReferenceId.success.message": "Ihre Bestellungs-ID wurde übernommen.", + "checkout.orderReferenceId.title": "Bestellungs-ID eingeben", "checkout.order_details.heading": "Bestellübersicht", "checkout.order_review.heading.text": "Prüfen Sie die Details Ihrer Bestellung und nehmen Sie, falls notwendig, noch Änderungen vor. Klicken Sie auf \"Bestellung absenden\", um den Bestellvorgang abzuschließen.", "checkout.order_review.heading.title": "Ihre Bestelldaten prüfen", @@ -693,6 +698,7 @@ "checkout.widget.billing-address.heading": "Rechnungsadresse", "checkout.widget.buyer.TaxationID": "Steuernummer:", "checkout.widget.buyer.heading": "Einkäufer", + "checkout.widget.buyer.orderReferenceId": "Bestellungs-ID", "checkout.widget.payment_method.heading": "Zahlungsart", "checkout.widget.promotion.discount": "Rabatt", "checkout.widget.return_to_cart.link": "Zurück zum Warenkorb", diff --git a/src/assets/i18n/en_US.json b/src/assets/i18n/en_US.json index 059245a7fc..6b9e626aaf 100644 --- a/src/assets/i18n/en_US.json +++ b/src/assets/i18n/en_US.json @@ -613,6 +613,11 @@ "checkout.order.number.label": "Your order number is:", "checkout.order.shipping.label": "Shipping", "checkout.order.total_cost.label": "Total Cost", + "checkout.orderReferenceId.apply.button.label": "Apply", + "checkout.orderReferenceId.label": "Order Reference ID", + "checkout.orderReferenceId.note": "You can enter an ID for your own book keeping. It will appear on your invoice and packing slip.", + "checkout.orderReferenceId.success.message": "Your order reference ID has been applied.", + "checkout.orderReferenceId.title": "Enter an order reference ID", "checkout.order_details.heading": "Order Summary", "checkout.order_review.heading.text": "Review the details of your order below and make any changes if needed. Click \"Submit Order\" to complete your purchase.", "checkout.order_review.heading.title": "Review Your Order Information", @@ -695,6 +700,7 @@ "checkout.widget.billing-address.heading": "Invoice Address", "checkout.widget.buyer.TaxationID": "Taxation ID:", "checkout.widget.buyer.heading": "Buyer", + "checkout.widget.buyer.orderReferenceId": "Order Reference ID", "checkout.widget.payment_method.heading": "Payment Method", "checkout.widget.promotion.discount": "Discount", "checkout.widget.return_to_cart.link": "Return to Cart", diff --git a/src/assets/i18n/fr_FR.json b/src/assets/i18n/fr_FR.json index 9adbe59248..2666ea144b 100644 --- a/src/assets/i18n/fr_FR.json +++ b/src/assets/i18n/fr_FR.json @@ -613,6 +613,11 @@ "checkout.order.number.label": "Votre numéro de commande est :", "checkout.order.shipping.label": "Livraison", "checkout.order.total_cost.label": "Coût total", + "checkout.orderReferenceId.apply.button.label": "Appliquer", + "checkout.orderReferenceId.label": "ID de référence de la commande", + "checkout.orderReferenceId.note": "Vous pouvez entrer un ID pour votre propre comptabilisation. Il apparaîtra sur votre facture et sur votre bordereau d’expédition.", + "checkout.orderReferenceId.success.message": "L'ID de référence de votre commande a été appliqué.", + "checkout.orderReferenceId.title": "Entrer un ID de référence de commande", "checkout.order_details.heading": "Récapitulatif de la commande", "checkout.order_review.heading.text": "Vérifiez les détails de votre commande ci-dessous et faites des modifications si nécessaire. Cliquez « Soumettre la commande » pour effectuer l’achat.", "checkout.order_review.heading.title": "Vérifier vos informations de commande", @@ -695,6 +700,7 @@ "checkout.widget.billing-address.heading": "Adresse de facturation", "checkout.widget.buyer.TaxationID": "ID de taxation:", "checkout.widget.buyer.heading": "Acheteur", + "checkout.widget.buyer.orderReferenceId": "ID de référence de la commande", "checkout.widget.payment_method.heading": "Mode de paiement", "checkout.widget.promotion.discount": "Réduction", "checkout.widget.return_to_cart.link": "Retour au panier",