mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
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:
committed by
Nino Righi
parent
6f238816ef
commit
56b4051e0b
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user