Merged PR 1979: fix(checkout): correct reward output desktop/mobile layout and add insufficie...

fix(checkout): correct reward output desktop/mobile layout and add insufficient points validation

- Fix desktop layout to display 4 columns with 80px gaps (164px, 164px, 164px, 444px)
- Add responsive tablet layout (3 columns in row 1, 1 column in row 2)
- Add error message when reading points are insufficient
- Disable CTA button when reading points are insufficient
- Create calculateTotalLoyaltyPoints helper to reduce code duplication
- Use @ng-icons/core for proper icon rendering

Resolves #5399

Related work items: #5399
This commit is contained in:
Lorenz Hilpert
2025-10-24 12:03:31 +00:00
committed by Nino Righi
parent 6f238816ef
commit 56b4051e0b
7 changed files with 352 additions and 12 deletions

View File

@@ -5,6 +5,7 @@ import {
hasValidItemId,
hasValidDestinationData,
ShippingTargets,
calculateTotalLoyaltyPoints,
} from './checkout-data.helpers';
import {
EntityDTOContainerOfShoppingCartItemDTO,
@@ -610,4 +611,214 @@ describe('Checkout Data Helpers', () => {
expect(ShippingTargets.B2B_DELIVERY).toBe(16);
});
});
describe('calculateTotalLoyaltyPoints', () => {
it('should calculate total loyalty points with quantities', () => {
// Arrange
const items: EntityDTOContainerOfShoppingCartItemDTO[] = [
{
id: 1,
data: {
loyalty: { value: 18500 },
quantity: 1,
},
} as EntityDTOContainerOfShoppingCartItemDTO,
{
id: 2,
data: {
loyalty: { value: 20100 },
quantity: 2,
},
} as EntityDTOContainerOfShoppingCartItemDTO,
];
// Act
const result = calculateTotalLoyaltyPoints(items);
// Assert
// 18500 × 1 + 20100 × 2 = 18500 + 40200 = 58700
expect(result).toBe(58700);
});
it('should return 0 for empty array', () => {
// Arrange
const items: EntityDTOContainerOfShoppingCartItemDTO[] = [];
// Act
const result = calculateTotalLoyaltyPoints(items);
// Assert
expect(result).toBe(0);
});
it('should return 0 for undefined items', () => {
// Arrange
const items = undefined;
// Act
const result = calculateTotalLoyaltyPoints(items);
// Assert
expect(result).toBe(0);
});
it('should handle items without loyalty points', () => {
// Arrange
const items: EntityDTOContainerOfShoppingCartItemDTO[] = [
{
id: 1,
data: {
loyalty: null,
quantity: 2,
},
} as EntityDTOContainerOfShoppingCartItemDTO,
{
id: 2,
data: {
loyalty: { value: 10000 },
quantity: 1,
},
} as EntityDTOContainerOfShoppingCartItemDTO,
];
// Act
const result = calculateTotalLoyaltyPoints(items);
// Assert
// 0 × 2 + 10000 × 1 = 10000
expect(result).toBe(10000);
});
it('should treat missing quantity as 1', () => {
// Arrange
const items: EntityDTOContainerOfShoppingCartItemDTO[] = [
{
id: 1,
data: {
loyalty: { value: 15000 },
quantity: undefined,
},
} as EntityDTOContainerOfShoppingCartItemDTO,
];
// Act
const result = calculateTotalLoyaltyPoints(items);
// Assert
// 15000 × 1 (default) = 15000
expect(result).toBe(15000);
});
it('should handle items with zero loyalty points', () => {
// Arrange
const items: EntityDTOContainerOfShoppingCartItemDTO[] = [
{
id: 1,
data: {
loyalty: { value: 0 },
quantity: 5,
},
} as EntityDTOContainerOfShoppingCartItemDTO,
{
id: 2,
data: {
loyalty: { value: 12000 },
quantity: 1,
},
} as EntityDTOContainerOfShoppingCartItemDTO,
];
// Act
const result = calculateTotalLoyaltyPoints(items);
// Assert
// 0 × 5 + 12000 × 1 = 12000
expect(result).toBe(12000);
});
it('should handle items with null data', () => {
// Arrange
const items: EntityDTOContainerOfShoppingCartItemDTO[] = [
{
id: 1,
data: null,
} as EntityDTOContainerOfShoppingCartItemDTO,
{
id: 2,
data: {
loyalty: { value: 8000 },
quantity: 2,
},
} as EntityDTOContainerOfShoppingCartItemDTO,
];
// Act
const result = calculateTotalLoyaltyPoints(items);
// Assert
// 0 + 8000 × 2 = 16000
expect(result).toBe(16000);
});
it('should handle multiple items with different quantities', () => {
// Arrange
const items: EntityDTOContainerOfShoppingCartItemDTO[] = [
{
id: 1,
data: {
loyalty: { value: 5000 },
quantity: 3,
},
} as EntityDTOContainerOfShoppingCartItemDTO,
{
id: 2,
data: {
loyalty: { value: 10000 },
quantity: 1,
},
} as EntityDTOContainerOfShoppingCartItemDTO,
{
id: 3,
data: {
loyalty: { value: 2500 },
quantity: 4,
},
} as EntityDTOContainerOfShoppingCartItemDTO,
];
// Act
const result = calculateTotalLoyaltyPoints(items);
// Assert
// 5000 × 3 + 10000 × 1 + 2500 × 4 = 15000 + 10000 + 10000 = 35000
expect(result).toBe(35000);
});
it('should handle items with zero quantity', () => {
// Arrange
const items: EntityDTOContainerOfShoppingCartItemDTO[] = [
{
id: 1,
data: {
loyalty: { value: 20000 },
quantity: 0,
},
} as EntityDTOContainerOfShoppingCartItemDTO,
{
id: 2,
data: {
loyalty: { value: 10000 },
quantity: 2,
},
} as EntityDTOContainerOfShoppingCartItemDTO,
];
// Act
const result = calculateTotalLoyaltyPoints(items);
// Assert
// 20000 × 0 + 10000 × 2 = 0 + 20000 = 20000
expect(result).toBe(20000);
});
});
});

View File

@@ -162,3 +162,51 @@ export function hasValidDestinationData(
destination.data !== undefined
);
}
/**
* Calculates the total loyalty points required for a shopping cart.
*
* @remarks
* Pure calculation function that sums up all loyalty point values from shopping cart items,
* accounting for item quantities. Returns 0 if the cart has no items or if items is undefined/null.
* Safely handles missing loyalty data by treating missing values as 0 points.
* Treats missing quantities as 1 (default quantity).
*
* Used in reward shopping cart components to:
* - Calculate total points required for checkout
* - Validate if customer has sufficient points
* - Display remaining points after purchase
*
* @param items - Shopping cart items to calculate total points from
* @returns Total loyalty points required (sum of all item loyalty values × quantities), or 0 if no items
*
* @example
* ```typescript
* const items = [
* { data: { loyalty: { value: 20100 }, quantity: 2 } }, // 20100 × 2 = 40200
* { data: { loyalty: { value: 9100 }, quantity: 1 } }, // 9100 × 1 = 9100
* { data: { loyalty: null } } // 0 points (counted as 0)
* ];
* const total = calculateTotalLoyaltyPoints(items);
* // Returns: 49300
* ```
*
* @example
* ```typescript
* const emptyCart = [];
* const total = calculateTotalLoyaltyPoints(emptyCart);
* // Returns: 0
* ```
*/
export function calculateTotalLoyaltyPoints(
items: EntityDTOContainerOfShoppingCartItemDTO[] | undefined,
): number {
if (!items?.length) {
return 0;
}
return items.reduce((sum, item) => {
const loyaltyValue = item.data?.loyalty?.value ?? 0;
const quantity = item.data?.quantity ?? 1;
return sum + loyaltyValue * quantity;
}, 0);
}

View File

@@ -6,10 +6,11 @@ import {
signal,
} from '@angular/core';
import { ButtonComponent } from '@isa/ui/buttons';
import { CheckoutMetadataService } from '@isa/checkout/data-access';
import { CheckoutMetadataService, SelectedRewardShoppingCartResource, calculateTotalLoyaltyPoints } from '@isa/checkout/data-access';
import {
SelectedCustomerResource,
CrmTabMetadataService,
PrimaryCustomerCardResource,
} from '@isa/crm/data-access';
import { injectTabId } from '@isa/core/tabs';
import { logger } from '@isa/core/logging';
@@ -35,12 +36,27 @@ export class CompleteOrderButtonComponent {
#orchestrator = inject(CheckoutCompletionOrchestratorService);
#customerResource = inject(SelectedCustomerResource);
#shoppingCartResource = inject(SelectedRewardShoppingCartResource).resource;
#primaryCustomerCardResource = inject(PrimaryCustomerCardResource);
#router = inject(Router);
isBusy = signal(false);
isCompleted = signal(false);
primaryBonusCardPoints = computed(
() => this.#primaryCustomerCardResource.primaryCustomerCard()?.totalPoints ?? 0,
);
totalPointsRequired = computed(() => {
const cart = this.#shoppingCartResource.value();
return calculateTotalLoyaltyPoints(cart?.items);
});
hasInsufficientPoints = computed(() => {
return this.primaryBonusCardPoints() < this.totalPointsRequired();
});
shippingAddress = computed(() => {
const tabId = this.#tabId();
const sa = tabId
@@ -77,7 +93,7 @@ export class CompleteOrderButtonComponent {
});
isDisabled = computed(() => {
return this.isBusy() || this.isCompleted() || this.isLoading();
return this.isBusy() || this.isCompleted() || this.isLoading() || this.hasInsufficientPoints();
});
shoppingCartId = computed(() => {

View File

@@ -1,12 +1,38 @@
:host {
@apply bg-isa-neutral-400 rounded-2xl p-6 text-isa-neutral-900;
@apply grid grid-cols-[repeat(3,auto)] gap-6 items-stretch justify-between;
@apply flex flex-wrap gap-[80px] items-stretch;
}
.info-block {
@apply flex flex-col justify-between flex-grow-0;
}
.info-block:nth-child(1) {
@apply w-[164px];
}
.info-block:nth-child(2) {
@apply w-[164px];
}
.info-block:nth-child(3) {
@apply w-[164px];
}
.info-block:nth-child(4) {
@apply w-[444px];
}
/* Tablet responsive - reduces gap and allows natural flex-wrap for 3+1 layout */
@media (max-width: 1279px) {
:host {
@apply gap-6;
}
.info-block {
@apply w-auto min-w-[164px];
}
}
.info-block--value {
@apply isa-text-body-2-bold;
}

View File

@@ -4,7 +4,7 @@ import {
computed,
inject,
} from '@angular/core';
import { SelectedRewardShoppingCartResource } from '@isa/checkout/data-access';
import { SelectedRewardShoppingCartResource, calculateTotalLoyaltyPoints } from '@isa/checkout/data-access';
import {
SelectedCustomerResource,
PrimaryCustomerCardResource,
@@ -42,11 +42,7 @@ export class CheckoutCustomerRewardCardComponent {
totalPointsRequired = computed(() => {
const cart = this.#shoppingCartResource.value();
if (!cart?.items?.length) {
return 0;
}
const loyalty = cart.items?.map((i) => i.data?.loyalty?.value ?? 0);
return loyalty.reduce((a, b) => a + b, 0);
return calculateTotalLoyaltyPoints(cart?.items);
});
bonusCardsLoading = this.#primaryCustomerCardResource.loading;
@@ -57,4 +53,10 @@ export class CheckoutCustomerRewardCardComponent {
primaryBonusCardPoints = computed(
() => this.primaryCustomerCardValue()?.totalPoints ?? 0,
);
hasInsufficientPoints = computed(() => {
const available = this.primaryBonusCardPoints();
const required = this.totalPointsRequired();
return available < required;
});
}

View File

@@ -9,6 +9,22 @@
<lib-reward-selection-trigger></lib-reward-selection-trigger>
</div>
<checkout-customer-reward-card></checkout-customer-reward-card>
@if (insufficientPoints()) {
<div
class="flex gap-2 items-center"
data-what="error-message"
data-which="insufficient-points"
>
<ng-icon
name="isaOtherInfo"
size="1.5rem"
class="text-isa-accent-red"
></ng-icon>
<p class="isa-text-body-2-bold text-isa-accent-red">
Lesepunkte reichen nicht für alle Artikel
</p>
</div>
}
<checkout-billing-and-shipping-address-card></checkout-billing-and-shipping-address-card>
<checkout-reward-shopping-cart-items></checkout-reward-shopping-cart-items>
<checkout-complete-order-button

View File

@@ -1,14 +1,17 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { NavigateBackButtonComponent } from '@isa/core/tabs';
import { CheckoutCustomerRewardCardComponent } from './customer-reward-card/customer-reward-card.component';
import { BillingAndShippingAddressCardComponent } from './billing-and-shipping-address-card/billing-and-shipping-address-card.component';
import { RewardShoppingCartItemsComponent } from './reward-shopping-cart-items/reward-shopping-cart-items.component';
import { SelectedRewardShoppingCartResource } from '@isa/checkout/data-access';
import { SelectedRewardShoppingCartResource, calculateTotalLoyaltyPoints } from '@isa/checkout/data-access';
import {
SelectedCustomerResource,
PrimaryCustomerCardResource,
} from '@isa/crm/data-access';
import { CompleteOrderButtonComponent } from './complete-order-button/complete-order-button.component';
import { RewardSelectionTriggerComponent } from '@isa/checkout/shared/reward-selection-dialog';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaOtherInfo } from '@isa/icons';
@Component({
selector: 'checkout-reward-shopping-cart',
@@ -22,10 +25,28 @@ import { RewardSelectionTriggerComponent } from '@isa/checkout/shared/reward-sel
RewardShoppingCartItemsComponent,
CompleteOrderButtonComponent,
RewardSelectionTriggerComponent,
NgIconComponent,
],
providers: [
SelectedRewardShoppingCartResource,
SelectedCustomerResource,
provideIcons({ isaOtherInfo }),
],
})
export class RewardShoppingCartComponent {}
export class RewardShoppingCartComponent {
#shoppingCartResource = inject(SelectedRewardShoppingCartResource).resource;
#primaryCustomerCardResource = inject(PrimaryCustomerCardResource);
primaryBonusCardPoints = computed(
() => this.#primaryCustomerCardResource.primaryCustomerCard()?.totalPoints ?? 0,
);
totalPointsRequired = computed(() => {
const cart = this.#shoppingCartResource.value();
return calculateTotalLoyaltyPoints(cart?.items);
});
insufficientPoints = computed(() => {
return this.primaryBonusCardPoints() < this.totalPointsRequired();
});
}