diff --git a/feature-libs/quote/assets/translations/en/quote.i18n.ts b/feature-libs/quote/assets/translations/en/quote.i18n.ts index daedf453e2d..b699fa88f58 100644 --- a/feature-libs/quote/assets/translations/en/quote.i18n.ts +++ b/feature-libs/quote/assets/translations/en/quote.i18n.ts @@ -158,6 +158,15 @@ export const quote = { save: 'Save quote header information', }, }, + price: { + hint: 'No taxes are included in the total', + orderDiscount: 'Order Discount', + productDiscount: 'Item Discount', + quoteDiscount: 'Quote Discount', + subtotal: 'Subtotal', + title: 'Order Summary', + total: 'Total', + }, overview: { id: 'Quote ID', status: 'Status', diff --git a/feature-libs/quote/components/header/price/quote-header-price.component.html b/feature-libs/quote/components/header/price/quote-header-price.component.html index 0ae2b7b859d..eaf42db68f5 100644 --- a/feature-libs/quote/components/header/price/quote-header-price.component.html +++ b/feature-libs/quote/components/header/price/quote-header-price.component.html @@ -1,11 +1,65 @@ - + {{ 'quote.header.price.title' | cxTranslate }} + +
+
+ {{ 'quote.header.price.subtotal' | cxTranslate }} +
+
+ {{ quoteDetails.totalPrice?.formattedValue }} +
+
+ +
- +
+ {{ 'quote.header.price.orderDiscount' | cxTranslate }} +
+
+ {{ quoteDetails.orderDiscounts.formattedValue }} +
+
+ +
+
+ {{ 'quote.header.price.productDiscount' | cxTranslate }} +
+
+ {{ quoteDetails.productDiscounts?.formattedValue }} +
+
+ +
+
+ {{ 'quote.header.price.quoteDiscount' | cxTranslate }} +
+
+ {{ quoteDetails.quoteDiscounts?.formattedValue }} +
+
+ +
+
+ {{ 'quote.header.price.total' | cxTranslate }} +
+
+ {{ quoteDetails.totalPrice?.formattedValue }} +
+
+
diff --git a/feature-libs/quote/components/header/price/quote-header-price.component.spec.ts b/feature-libs/quote/components/header/price/quote-header-price.component.spec.ts index e0e48f9d8c1..4dc85d9493e 100644 --- a/feature-libs/quote/components/header/price/quote-header-price.component.spec.ts +++ b/feature-libs/quote/components/header/price/quote-header-price.component.spec.ts @@ -1,34 +1,13 @@ -import { Directive, Input } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { I18nTestingModule, Price } from '@spartacus/core'; -import { - Quote, - QuoteActionType, - QuoteFacade, - QuoteState, -} from '@spartacus/quote/root'; -import { OutletDirective } from '@spartacus/storefront'; +import { I18nTestingModule } from '@spartacus/core'; +import { Quote, QuoteFacade } from '@spartacus/quote/root'; import { BehaviorSubject, NEVER, Observable } from 'rxjs'; import { createEmptyQuote } from '../../../core/testing/quote-test-utils'; -import { CommonQuoteTestUtilsService } from '../../testing/common-quote-test-utils.service'; +import { CommonQuoteTestUtilsService as TestUtil } from '../../testing/common-quote-test-utils.service'; import { QuoteHeaderPriceComponent } from './quote-header-price.component'; -const cartId = '1234'; -const quoteCode = '3333'; -const threshold = 20; -const totalPrice: Price = { value: threshold + 1 }; - const quote: Quote = { ...createEmptyQuote(), - allowedActions: [ - { type: QuoteActionType.EDIT, isPrimary: false }, - { type: QuoteActionType.REQUOTE, isPrimary: true }, - ], - state: QuoteState.BUYER_DRAFT, - cartId: cartId, - code: quoteCode, - threshold: threshold, - totalPrice: totalPrice, }; const mockQuoteDetails$ = new BehaviorSubject(quote); @@ -39,24 +18,15 @@ class MockCommerceQuotesFacade implements Partial { } } -@Directive({ - selector: '[cxOutlet]', -}) -class MockOutletDirective implements Partial { - @Input() cxOutlet: string; - @Input() cxOutletContext: string; -} - describe('QuoteHeaderPriceComponent', () => { let fixture: ComponentFixture; let htmlElem: HTMLElement; let component: QuoteHeaderPriceComponent; - let facade: QuoteFacade; beforeEach(() => { TestBed.configureTestingModule({ imports: [I18nTestingModule], - declarations: [QuoteHeaderPriceComponent, MockOutletDirective], + declarations: [QuoteHeaderPriceComponent], providers: [ { provide: QuoteFacade, @@ -70,13 +40,119 @@ describe('QuoteHeaderPriceComponent', () => { fixture = TestBed.createComponent(QuoteHeaderPriceComponent); htmlElem = fixture.nativeElement; component = fixture.componentInstance; - facade = TestBed.inject(QuoteFacade); - mockQuoteDetails$.next(quote); + withPrices(); + fixture.detectChanges(); }); + function withPrices() { + quote.totalPrice = { value: 1000, formattedValue: '$1,000.00' }; + quote.orderDiscounts = { value: 5.99, formattedValue: '$5.99' }; + quote.productDiscounts = { value: 50, formattedValue: '$50.00' }; + quote.quoteDiscounts = { value: 100, formattedValue: '$100.00' }; + } + it('should create component', () => { expect(component).toBeDefined(); - expect(facade).toBeDefined(); + }); + + it('should display all prices and discounts when present', () => { + TestUtil.expectNumberOfElementsPresent( + expect, + htmlElem, + '.cx-price-row', + 5 + ); + TestUtil.expectNumberOfElementsPresent( + expect, + htmlElem, + '.cx-price-savings', + 3 + ); + + TestUtil.expectElementToContainText( + expect, + htmlElem, + '.cx-price-row', + '.subtotal $1,000.00', + 0 + ); + TestUtil.expectElementToContainText( + expect, + htmlElem, + '.cx-price-row', + '.orderDiscount $5.99', + 1 + ); + TestUtil.expectElementToContainText( + expect, + htmlElem, + '.cx-price-row', + '.productDiscount $50.00', + 2 + ); + TestUtil.expectElementToContainText( + expect, + htmlElem, + '.cx-price-row', + '.quoteDiscount $100.00', + 3 + ); + TestUtil.expectElementToContainText( + expect, + htmlElem, + '.cx-price-row', + '.total $1,000.00', + 4 + ); + }); + + it('should display only totals when discounts are zero', () => { + quote.orderDiscounts = {}; + quote.productDiscounts = undefined; + quote.quoteDiscounts = { value: 0, formattedValue: '$0.00' }; + fixture.detectChanges(); + + TestUtil.expectNumberOfElementsPresent( + expect, + htmlElem, + '.cx-price-row', + 2 + ); + TestUtil.expectElementNotPresent(expect, htmlElem, '.cx-price-savings'); + + TestUtil.expectElementToContainText( + expect, + htmlElem, + '.cx-price-row', + '.subtotal $1,000.00', + 0 + ); + + TestUtil.expectElementToContainText( + expect, + htmlElem, + '.cx-price-row', + '.total $1,000.00', + 1 + ); + }); + + describe('hasNonZeroPriceValue', () => { + it('should return true if price value is present', () => { + expect(component.hasNonZeroPriceValue({ value: 99.99 })).toBe(true); + }); + + it('should return false if price is not present', () => { + expect(component.hasNonZeroPriceValue(undefined)).toBe(false); + }); + + it('should return false if price value is not present', () => { + expect(component.hasNonZeroPriceValue({})).toBe(false); + }); + + it('should return false if price value is zero', () => { + expect(component.hasNonZeroPriceValue({ value: 0.0 })).toBe(false); + }); }); describe('Ghost animation', () => { @@ -84,39 +160,35 @@ describe('QuoteHeaderPriceComponent', () => { component.quoteDetails$ = NEVER; fixture.detectChanges(); - CommonQuoteTestUtilsService.expectElementPresent( + TestUtil.expectElementPresent( expect, htmlElem, '.cx-ghost-summary-heading' ); - CommonQuoteTestUtilsService.expectElementPresent( - expect, - htmlElem, - '.cx-ghost-title' - ); + TestUtil.expectElementPresent(expect, htmlElem, '.cx-ghost-title'); - CommonQuoteTestUtilsService.expectElementPresent( + TestUtil.expectElementPresent( expect, htmlElem, '.cx-ghost-summary-partials' ); - CommonQuoteTestUtilsService.expectNumberOfElementsPresent( + TestUtil.expectNumberOfElementsPresent( expect, htmlElem, '.cx-ghost-row', 4 ); - CommonQuoteTestUtilsService.expectNumberOfElementsPresent( + TestUtil.expectNumberOfElementsPresent( expect, htmlElem, '.cx-ghost-summary-label', 4 ); - CommonQuoteTestUtilsService.expectNumberOfElementsPresent( + TestUtil.expectNumberOfElementsPresent( expect, htmlElem, '.cx-ghost-summary-amount', diff --git a/feature-libs/quote/components/header/price/quote-header-price.component.ts b/feature-libs/quote/components/header/price/quote-header-price.component.ts index 34e9f695b9c..b1ec18dde74 100644 --- a/feature-libs/quote/components/header/price/quote-header-price.component.ts +++ b/feature-libs/quote/components/header/price/quote-header-price.component.ts @@ -5,7 +5,7 @@ */ import { Component, inject } from '@angular/core'; -import { CartOutlets } from '@spartacus/cart/base/root'; +import { Price } from '@spartacus/core'; import { QuoteFacade } from '@spartacus/quote/root'; @Component({ @@ -15,6 +15,15 @@ import { QuoteFacade } from '@spartacus/quote/root'; export class QuoteHeaderPriceComponent { protected quoteFacade = inject(QuoteFacade); - readonly cartOutlets = CartOutlets; quoteDetails$ = this.quoteFacade.getQuoteDetails(); + + /** + * Checks whether the price has a non-zero value + * + * @param price to check + * @returns true, only if the price has a non zero value + */ + hasNonZeroPriceValue(price?: Price): boolean { + return !!price && !!price.value && price.value > 0; + } } diff --git a/feature-libs/quote/components/header/price/quote-header-price.module.ts b/feature-libs/quote/components/header/price/quote-header-price.module.ts index 370fd5261ac..059e268e332 100644 --- a/feature-libs/quote/components/header/price/quote-header-price.module.ts +++ b/feature-libs/quote/components/header/price/quote-header-price.module.ts @@ -12,11 +12,10 @@ import { I18nModule, provideDefaultConfig, } from '@spartacus/core'; -import { OutletModule } from '@spartacus/storefront'; import { QuoteHeaderPriceComponent } from './quote-header-price.component'; @NgModule({ - imports: [CommonModule, I18nModule, OutletModule], + imports: [CommonModule, I18nModule], providers: [ provideDefaultConfig({ cmsComponents: { diff --git a/feature-libs/quote/styles/_quote-header-price.scss b/feature-libs/quote/styles/_quote-header-price.scss index 855daa7064a..f07d05cf87d 100644 --- a/feature-libs/quote/styles/_quote-header-price.scss +++ b/feature-libs/quote/styles/_quote-header-price.scss @@ -1,4 +1,26 @@ %cx-quote-header-price { + > div { + padding-block-end: 0.5rem; + } + .cx-price-heading { + @include type('4'); + font-weight: var(--cx-font-weight-bold); + } + .cx-price-footer { + font-style: italic; + } + .cx-price-row { + display: flex; + flex-wrap: nowrap; + justify-content: space-between; + } + .cx-price-savings { + color: var(--cx-color-success); + } + .cx-price-total { + font-weight: var(--cx-font-weight-bold); + } + &:not(:empty) { .cx-ghost-title, .cx-ghost-summary-label,