Merged PR 1961: feat(checkout-reward): implement reward catalog customer integration and purc...

feat(checkout-reward): implement reward catalog customer integration and purchase flow

- Add customer card resource and display in reward header with reset functionality
- Implement shopping cart creation and management for reward purchases
- Add purchase options modal integration with redemption points support
- Extract route helper for customer navigation with proper query params
- Update checkout metadata service constants with proper namespacing
- Add reward context initialization for tab metadata
- Improve component styling and layout for reward action buttons
- Fix customer facade method signature to require AbortSignal parameter

The reward catalog now supports full customer workflow from selection
through purchase options with proper state management and navigation.

Ref: #5263, #5358
This commit is contained in:
Nino Righi
2025-09-30 13:54:31 +00:00
committed by Lorenz Hilpert
parent c745f82f3a
commit 9d57ebf376
30 changed files with 507 additions and 163 deletions

View File

@@ -1,7 +1,8 @@
export const SELECTED_BRANCH_METADATA_KEY = 'CHECKOUT_SELECTED_BRANCH_ID';
export const SELECTED_BRANCH_METADATA_KEY =
'checkout-data-access.checkoutSelectedBranchId';
export const CHECKOUT_SHOPPING_CART_ID_METADATA_KEY =
'CHECKOUT_SHOPPING_CART_ID';
'checkout-data-access.checkoutShoppingCartId';
export const CHECKOUT_REWARD_SHOPPING_CART_ID_METADATA_KEY =
'CHECKOUT_REWARD_SHOPPING_CART_ID';
'checkout-data-access.checkoutRewardShoppingCartId';

View File

@@ -5,6 +5,10 @@ import { ShoppingCartService } from '../services';
export class ShoppingCartFacade {
#shoppingCartService = inject(ShoppingCartService);
createShoppingCart() {
return this.#shoppingCartService.createShoppingCart();
}
getShoppingCart(shoppingCartId: number, abortSignal?: AbortSignal) {
return this.#shoppingCartService.getShoppingCart(
shoppingCartId,

View File

@@ -43,7 +43,7 @@ const CanAddOLAAvailabilitySchema = z.object({
const CanAddItemPayloadSchema = z.object({
availabilities: z.array(CanAddOLAAvailabilitySchema),
customerFeatures: z.record(z.string()),
customerFeatures: z.record(z.string().optional()),
orderType: OrderTypeSchema,
id: z.string(),
});

View File

@@ -22,7 +22,7 @@ export class CheckoutMetadataService {
tabId,
SELECTED_BRANCH_METADATA_KEY,
z.number().optional(),
this.#tabService.entities(),
this.#tabService.entityMap(),
);
}
@@ -37,13 +37,13 @@ export class CheckoutMetadataService {
tabId,
CHECKOUT_SHOPPING_CART_ID_METADATA_KEY,
z.number().optional(),
this.#tabService.entities(),
this.#tabService.entityMap(),
);
}
setRewardShoppingCartId(tabId: number, shoppingCartId: number | undefined) {
this.#tabService.patchTabMetadata(tabId, {
CHECKOUT_REWARD_SHOPPING_CART_ID_METADATA_KEY: shoppingCartId,
[CHECKOUT_REWARD_SHOPPING_CART_ID_METADATA_KEY]: shoppingCartId,
});
}
@@ -52,7 +52,7 @@ export class CheckoutMetadataService {
tabId,
CHECKOUT_REWARD_SHOPPING_CART_ID_METADATA_KEY,
z.number().optional(),
this.#tabService.entities(),
this.#tabService.entityMap(),
);
}
}

View File

@@ -0,0 +1,14 @@
export const getRouteToCustomer = (tabId: number | null) => {
const path = [
'/kunde',
tabId,
'customer',
{ outlets: { primary: 'search', side: 'search-customer-main' } },
].filter(Boolean);
const queryParams = {
filter_customertype: 'webshop&loyalty;loyalty&!webshop', // Filter only Customer Card Customers
};
return { path, queryParams };
};

View File

@@ -0,0 +1 @@
export * from './get-route-to-customer.helper';

View File

@@ -1,2 +1,3 @@
export * from './reward-catalog.resource';
export * from './reward-customer-card.resource';
export * from './reward-shopping-cart.resource';

View File

@@ -1,17 +1,15 @@
import { inject, resource } from '@angular/core';
import { ResponseArgsError } from '@isa/common/data-access';
import { injectTabId } from '@isa/core/tabs';
import { CustomerCardsFacade } from '@isa/crm/data-access';
import { SelectedCustomerFacade } from '@isa/crm/data-access';
export const createRewardCustomerCardResource = (
params: () => {
customerId: number | undefined;
},
) => {
export const createRewardCustomerCardResource = () => {
const tabId = injectTabId();
const customerCardsFacade = inject(CustomerCardsFacade);
const selectedCustomerFacade = inject(SelectedCustomerFacade);
return resource({
params,
loader: async ({ abortSignal, params }) => {
const { customerId } = params;
loader: async ({ abortSignal }) => {
const customerId = selectedCustomerFacade.get(tabId()!);
if (!customerId) {
return undefined;

View File

@@ -0,0 +1,28 @@
import { inject, resource } from '@angular/core';
import {
CheckoutMetadataService,
ShoppingCartFacade,
} from '@isa/checkout/data-access';
import { injectTabId } from '@isa/core/tabs';
export const createRewardShoppingCartResource = () => {
const tabId = injectTabId();
const checkoutMetadataService = inject(CheckoutMetadataService);
const shoppingCartFacade = inject(ShoppingCartFacade);
return resource({
loader: async ({ abortSignal }) => {
const shoppingCartId = checkoutMetadataService.getRewardShoppingCartId(
tabId()!,
);
if (!shoppingCartId) {
return undefined;
}
const fetchCustomerCardsResponse =
await shoppingCartFacade.getShoppingCart(shoppingCartId, abortSignal);
return fetchCustomerCardsResponse;
},
});
};

View File

@@ -0,0 +1,3 @@
:host {
@apply flex flex-col self-end fixed bottom-6;
}

View File

@@ -1,7 +1,6 @@
<button
class="fixed right-6 bottom-6"
data-which="start-remission"
data-what="start-remission"
data-which="select-rewards"
data-what="select-rewards"
uiButton
color="brand"
size="large"

View File

@@ -4,8 +4,18 @@ import {
linkedSignal,
inject,
} from '@angular/core';
import { RewardCatalogStore } from '@isa/checkout/data-access';
import {
RewardCatalogStore,
CheckoutMetadataService,
ShoppingCartFacade,
} from '@isa/checkout/data-access';
import { injectTabId } from '@isa/core/tabs';
import { ButtonComponent } from '@isa/ui/buttons';
import { PurchaseOptionsModalService } from '@modal/purchase-options';
import { firstValueFrom } from 'rxjs';
import { createRewardCustomerCardResource } from '../resources';
import { Router } from '@angular/router';
import { getRouteToCustomer } from '../helpers';
@Component({
selector: 'reward-action',
@@ -15,7 +25,19 @@ import { ButtonComponent } from '@isa/ui/buttons';
imports: [ButtonComponent],
})
export class RewardActionComponent {
#router = inject(Router);
#store = inject(RewardCatalogStore);
#tabId = injectTabId();
#purchasingOptionsModal = inject(PurchaseOptionsModalService);
#shoppingCartFacade = inject(ShoppingCartFacade);
#checkoutMetadataService = inject(CheckoutMetadataService);
rewardCustomerCardResource = createRewardCustomerCardResource(); // TODO: Refactor and use global resource
customerCardResponseValue = linkedSignal(() =>
this.rewardCustomerCardResource.value(),
);
selectedItems = linkedSignal(() => this.#store.selectedItems());
@@ -23,7 +45,60 @@ export class RewardActionComponent {
return Object.keys(this.selectedItems() || {}).length > 0;
});
continueToPurchasingOptions() {
console.log('Kaufoptionen Modal öffnen mit: ', this.selectedItems());
async continueToPurchasingOptions() {
const tabId = this.#tabId();
const items = Object.values(this.selectedItems() || {});
if (!items?.length || !tabId) {
return;
}
let rewardShoppingCartId =
this.#checkoutMetadataService.getRewardShoppingCartId(tabId);
if (!rewardShoppingCartId) {
rewardShoppingCartId = await this.#createShoppingCart(tabId);
}
const modalRef = await this.#purchasingOptionsModal.open({
type: 'add',
tabId,
shoppingCartId: rewardShoppingCartId,
items,
useRedemptionPoints: true,
});
const result = await firstValueFrom(modalRef.afterClosed$);
if (!result?.data) {
return;
}
this.#store.clearSelectedItems();
if (result.data !== 'continue-shopping') {
await this.#navigation(tabId);
}
}
async #navigation(tabId: number) {
const hasCustomer = this.customerCardResponseValue();
if (hasCustomer) {
// TODO: Update Reward Shopping Cart Resource
} else {
const route = getRouteToCustomer(tabId);
await this.#router.navigate(route.path, {
queryParams: route.queryParams,
});
}
}
async #createShoppingCart(tabId: number): Promise<number> {
const shoppingCart = await this.#shoppingCartFacade.createShoppingCart();
this.#checkoutMetadataService.setRewardShoppingCartId(
tabId,
shoppingCart.id,
);
return shoppingCart.id!;
}
}

View File

@@ -0,0 +1,3 @@
:host {
@apply relative;
}

View File

@@ -17,6 +17,7 @@ import { RewardHeaderComponent } from './reward-header/reward-header.component';
import { RewardListComponent } from './reward-list/reward-list.component';
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
import { RewardActionComponent } from './reward-action/reward-action.component';
import { TabService } from '@isa/core/tabs';
/**
* Factory function to retrieve query settings from the activated route data.
@@ -49,14 +50,28 @@ function querySettingsFactory() {
},
})
export class RewardCatalogComponent {
#tabService = inject(TabService);
restoreScrollPosition = injectRestoreScrollPosition();
searchTrigger = signal<SearchTrigger | 'reload' | 'initial'>('initial');
#filterService = inject(FilterService);
constructor() {
this.initRewardContext();
}
search(trigger: SearchTrigger): void {
this.searchTrigger.set(trigger); // Ist entweder 'scan', 'input', 'filter' oder 'orderBy'
this.#filterService.commit();
}
// Wichtig damit Kundensuche weiß, dass wir im Reward Kontext sind - Über Header CTA oder Kaufoptionen gelangt man zur Kundensuche
initRewardContext() {
const tabId = this.#tabService.activatedTabId();
if (tabId) {
this.#tabService.patchTabMetadata(tabId, { context: 'reward' });
}
}
}

View File

@@ -4,7 +4,7 @@
>{{ card()?.firstName }} {{ card()?.lastName }}
</span>
<span class="isa-text-body-1-bold"
>{{ card()?.totalPoints ?? 0 }} Lesepunkte</span
>{{ customerCardTotalPoints() }} Lesepunkte</span
>
</div>
@@ -13,7 +13,7 @@
type="button"
color="subtle"
size="small"
(click)="resetCustomer()"
(click)="reset.emit()"
>
Zurücksetzen
</ui-text-button>
@@ -21,5 +21,23 @@
<div class="flex flex-col gap-2">
<span class="isa-text-body-1-regular">Prämien ausgewählt</span>
<span class="isa-text-body-1-bold">0</span>
<span
*uiSkeletonLoader="shoppingCartResponseFetching()"
class="isa-text-body-1-bold"
>{{ cartItemsLength() }}</span
>
</div>
@if (cartItemsLength()) {
<button
data-which="continue-to-reward-checkout"
data-what="continue-to-reward-checkout"
uiButton
color="brand"
size="large"
[disabled]="disableContinueCta()"
(click)="continueToRewardCheckout()"
>
Prämienausgabe
</button>
}

View File

@@ -1,27 +1,60 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
linkedSignal,
output,
} from '@angular/core';
import { TextButtonComponent } from '@isa/ui/buttons';
import { BonusCardInfo, SelectedCustomerFacade } from '@isa/crm/data-access';
import { injectTabId } from '@isa/core/tabs';
import { ButtonComponent, TextButtonComponent } from '@isa/ui/buttons';
import { BonusCardInfo } from '@isa/crm/data-access';
import { createRewardShoppingCartResource } from '../../resources';
import { SkeletonLoaderDirective } from '@isa/ui/skeleton-loader';
@Component({
selector: 'reward-customer-card',
templateUrl: './reward-customer-card.component.html',
styleUrl: './reward-customer-card.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TextButtonComponent],
imports: [TextButtonComponent, ButtonComponent, SkeletonLoaderDirective],
})
export class RewardCustomerCardComponent {
tabId = injectTabId();
selectedCustomerFacade = inject(SelectedCustomerFacade);
card = input.required<BonusCardInfo>();
reset = output<void>();
resetCustomer() {
this.selectedCustomerFacade.clear(this.tabId()!);
rewardShoppingCartResource = createRewardShoppingCartResource(); // TODO: Refactor and use global resource
shoppingCartResponseValue = linkedSignal(() =>
this.rewardShoppingCartResource.value(),
);
shoppingCartResponseFetching = linkedSignal(
() => this.rewardShoppingCartResource.status() === 'loading',
);
cartItemsLength = linkedSignal(
() => this.shoppingCartResponseValue()?.items?.length ?? 0,
);
cartTotalLoyaltyPoints = linkedSignal(() => {
return (
this.shoppingCartResponseValue()?.items?.reduce(
(sum, item) => sum + (item?.data?.loyalty?.value ?? 0),
0,
) ?? 0
);
});
customerCardTotalPoints = linkedSignal(() => this.card()?.totalPoints ?? 0);
disableContinueCta = linkedSignal(() => {
return (
this.shoppingCartResponseFetching() ||
this.customerCardTotalPoints() <= 0 ||
this.cartTotalLoyaltyPoints() > this.customerCardTotalPoints()
);
});
continueToRewardCheckout() {
// TODO: Navigate to Reward Checkout Page
}
}

View File

@@ -2,7 +2,10 @@
@let cardFetching = customerCardResponseFetching();
@if (!cardFetching) {
@if (card) {
<reward-customer-card [card]="card"></reward-customer-card>
<reward-customer-card
[card]="card"
(reset)="resetCustomer()"
></reward-customer-card>
} @else {
<reward-start-card></reward-start-card>
}

View File

@@ -5,11 +5,11 @@ import {
linkedSignal,
} from '@angular/core';
import { RewardStartCardComponent } from './reward-start-card/reward-start-card.component';
import { injectTabId } from '@isa/core/tabs';
import { SelectedCustomerFacade } from '@isa/crm/data-access';
import { RewardCustomerCardComponent } from './reward-customer-card/reward-customer-card.component';
import { createRewardCustomerCardResource } from '../resources';
import { IconButtonComponent } from '@isa/ui/buttons';
import { injectTabId } from '@isa/core/tabs';
import { SelectedCustomerFacade } from '@isa/crm/data-access';
@Component({
selector: 'reward-header',
templateUrl: './reward-header.component.html',
@@ -25,25 +25,15 @@ export class RewardHeaderComponent {
tabId = injectTabId();
selectedCustomerFacade = inject(SelectedCustomerFacade);
selectedCustomerId = linkedSignal(() =>
this.selectedCustomerFacade.get(this.tabId()!),
);
rewardCustomerCardResource = createRewardCustomerCardResource(); // TODO: Refactor and use global resource
rewardCustomerCardResource = createRewardCustomerCardResource(() => {
return {
customerId: this.selectedCustomerId(),
};
});
readonly customerCardResponseValue =
this.rewardCustomerCardResource.value.asReadonly();
readonly customerCardResponseFetching =
this.rewardCustomerCardResource.isLoading;
// TODO: Falls notwendig und die Karte allein nicht ausreicht, Customer Fetchen und überprüfen ob folgende features vorhanden sind:
// features?.some((c) => c.key === 'p4mUser')
// oder
// features?.some((c) => c.key === 'd-account' && c.description.includes('Kundenkarte') && c.enabled)
customerCardResponseValue = linkedSignal(() =>
this.rewardCustomerCardResource.value(),
);
customerCardResponseFetching = linkedSignal(
() => this.rewardCustomerCardResource.status() === 'loading',
);
resetCustomer() {
this.selectedCustomerFacade.clear(this.tabId()!);
this.rewardCustomerCardResource.reload();
}
}

View File

@@ -14,9 +14,8 @@
uiButton
color="tertiary"
size="large"
[routerLink]="pathToCrmSearch()"
[queryParams]="queryParams"
(click)="setTabContext()"
[routerLink]="route().path"
[queryParams]="route().queryParams"
>
Kund*in auswählen
</a>

View File

@@ -1,12 +1,8 @@
import {
ChangeDetectionStrategy,
Component,
linkedSignal,
inject,
} from '@angular/core';
import { ChangeDetectionStrategy, Component, computed } from '@angular/core';
import { ButtonComponent } from '@isa/ui/buttons';
import { injectTabId, TabService } from '@isa/core/tabs';
import { injectTabId } from '@isa/core/tabs';
import { RouterLink } from '@angular/router';
import { getRouteToCustomer } from '../../helpers';
@Component({
selector: 'reward-start-card',
templateUrl: './reward-start-card.component.html',
@@ -15,24 +11,6 @@ import { RouterLink } from '@angular/router';
imports: [ButtonComponent, RouterLink],
})
export class RewardStartCardComponent {
tabService = inject(TabService);
tabId = injectTabId();
pathToCrmSearch = linkedSignal(() =>
[
'/kunde',
this.tabId(),
'customer',
{ outlets: { primary: 'search', side: 'search-customer-main' } },
].filter(Boolean),
);
queryParams = {
filter_customertype: 'webshop&loyalty;loyalty&!webshop', // Filter only Customer Card Customers
};
// Wichtig damit Kundensuche weiß, dass wir im Reward Kontext sind
setTabContext() {
this.tabService.patchTabMetadata(this.tabId()!, { context: 'reward' });
}
route = computed(() => getRouteToCustomer(this.tabId()));
}

View File

@@ -1,6 +1,5 @@
import z from 'zod';
import { Tab } from './schemas';
import { computed } from '@angular/core';
export function getTabHelper(
tabId: number,

View File

@@ -1,3 +1,4 @@
export * from './lib/facades';
export * from './lib/constants';
export * from './lib/models';
export * from './lib/services';

View File

@@ -1 +1,5 @@
export const SELECTED_CUSTOMER_ID = 'crm-data-access.selectedCustomerId';
export const SELECTED_SHIPPING_ADDRESS_ID =
'crm-data-access.selectedShippingAddressId';
export const SELECTED_PAYER_ADDRESS_ID =
'crm-data-access.selectedPayerAddressId';

View File

@@ -9,7 +9,7 @@ export class CustomerFacade {
async fetchCustomer(
params: FetchCustomerInput,
abortSignal?: AbortSignal,
abortSignal: AbortSignal,
): Promise<Customer | undefined> {
const res = await this.#customerService.fetchCustomer(params, abortSignal);
return res.result;

View File

@@ -9,11 +9,11 @@ export class SelectedCustomerFacade {
this.#crmTabMetadataService.setSelectedCustomerId(tabId, customerId);
}
get(tab: number) {
return this.#crmTabMetadataService.selectedCustomerId(tab);
get(tabId: number) {
return this.#crmTabMetadataService.selectedCustomerId(tabId);
}
clear(tab: number) {
this.#crmTabMetadataService.setSelectedCustomerId(tab, undefined);
clear(tabId: number) {
this.#crmTabMetadataService.setSelectedCustomerId(tabId, undefined);
}
}

View File

@@ -1,6 +1,10 @@
import { Injectable, inject } from '@angular/core';
import { TabService, getMetadataHelper, getTabHelper } from '@isa/core/tabs';
import { SELECTED_CUSTOMER_ID } from '../constants';
import { TabService, getMetadataHelper } from '@isa/core/tabs';
import {
SELECTED_CUSTOMER_ID,
SELECTED_PAYER_ADDRESS_ID,
SELECTED_SHIPPING_ADDRESS_ID,
} from '../constants';
import z from 'zod';
@Injectable({ providedIn: 'root' })
@@ -12,7 +16,25 @@ export class CrmTabMetadataService {
tabId,
SELECTED_CUSTOMER_ID,
z.number().optional(),
this.#tabService.entities(),
this.#tabService.entityMap(),
);
}
selectedShippingAddressId(tabId: number): number | undefined {
return getMetadataHelper(
tabId,
SELECTED_SHIPPING_ADDRESS_ID,
z.number().optional(),
this.#tabService.entityMap(),
);
}
selectedPayerAddressId(tabId: number): number | undefined {
return getMetadataHelper(
tabId,
SELECTED_PAYER_ADDRESS_ID,
z.number().optional(),
this.#tabService.entityMap(),
);
}
@@ -21,4 +43,19 @@ export class CrmTabMetadataService {
[SELECTED_CUSTOMER_ID]: customerId,
});
}
setSelectedShippingAddressId(
tabId: number,
shippingAddressId: number | undefined,
) {
this.#tabService.patchTabMetadata(tabId, {
[SELECTED_SHIPPING_ADDRESS_ID]: shippingAddressId,
});
}
setSelectedPayerAddressId(tabId: number, payerAddressId: number | undefined) {
this.#tabService.patchTabMetadata(tabId, {
[SELECTED_PAYER_ADDRESS_ID]: payerAddressId,
});
}
}