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

@@ -282,7 +282,7 @@ export class PurchaseOptionsListItemComponent
return (
this.useRedemptionPoints() &&
this.isReservePurchaseOption() &&
this.availability().inStock < 2
(!this.availability() || this.availability().inStock < 2)
);
});

View File

@@ -44,6 +44,9 @@ export class PurchaseOptionsModalService {
return Promise.resolve(undefined);
}
return this.#customerFacade.fetchCustomer({ customerId });
return this.#customerFacade.fetchCustomer(
{ customerId },
new AbortController().signal,
);
}
}

View File

@@ -1,9 +1,20 @@
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, Host, inject } from '@angular/core';
import {
Component,
ChangeDetectionStrategy,
OnInit,
OnDestroy,
Host,
inject,
} from '@angular/core';
import { CustomerSearchStore } from '../../store';
import { CrmCustomerService } from '@domain/crm';
import { debounceTime, map, switchMap, takeUntil } from 'rxjs/operators';
import { Observable, Subject, combineLatest } from 'rxjs';
import { AssignedPayerDTO, CustomerDTO, ListResponseArgsOfAssignedPayerDTO } from '@generated/swagger/crm-api';
import {
AssignedPayerDTO,
CustomerDTO,
ListResponseArgsOfAssignedPayerDTO,
} from '@generated/swagger/crm-api';
import { AsyncPipe } from '@angular/common';
import { CustomerPipesModule } from '@shared/pipes/customer';
import { ComponentStore } from '@ngrx/component-store';
@@ -14,6 +25,8 @@ import { CustomerSearchNavigation } from '@shared/services/navigation';
import { RouterLink } from '@angular/router';
import { CustomerDetailsViewMainComponent } from '../details-main-view.component';
import { PayerDTO } from '@generated/swagger/checkout-api';
import { CrmTabMetadataService } from '@isa/crm/data-access';
import { injectTabId } from '@isa/core/tabs';
interface DetailsMainViewBillingAddressesComponentState {
assignedPayers: AssignedPayerDTO[];
@@ -32,6 +45,9 @@ export class DetailsMainViewBillingAddressesComponent
extends ComponentStore<DetailsMainViewBillingAddressesComponentState>
implements OnInit, OnDestroy
{
tabId = injectTabId();
crmTabMetadataService = inject(CrmTabMetadataService);
private _host = inject(CustomerDetailsViewMainComponent, { host: true });
private _store = inject(CustomerSearchStore);
private _customerService = inject(CrmCustomerService);
@@ -42,13 +58,20 @@ export class DetailsMainViewBillingAddressesComponent
selectedPayer$ = this.select((state) => state.selectedPayer);
isNotBusinessKonto$ = this._store.isBusinessKonto$.pipe(map((isBusinessKonto) => !isBusinessKonto));
isNotBusinessKonto$ = this._store.isBusinessKonto$.pipe(
map((isBusinessKonto) => !isBusinessKonto),
);
showCustomerAddress$ = combineLatest([
this._store.isBusinessKonto$,
this._store.isMitarbeiter$,
this._store.isKundenkarte$,
]).pipe(map(([isBusinessKonto, isMitarbeiter, isKundenkarte]) => isBusinessKonto || isMitarbeiter || isKundenkarte));
]).pipe(
map(
([isBusinessKonto, isMitarbeiter, isKundenkarte]) =>
isBusinessKonto || isMitarbeiter || isKundenkarte,
),
);
get showCustomerAddress() {
return this._store.isBusinessKonto || this._store.isMitarbeiter;
@@ -65,14 +88,22 @@ export class DetailsMainViewBillingAddressesComponent
),
);
canEditAddress$ = combineLatest([this._store.isKundenkarte$]).pipe(map(([isKundenkarte]) => isKundenkarte));
canEditAddress$ = combineLatest([this._store.isKundenkarte$]).pipe(
map(([isKundenkarte]) => isKundenkarte),
);
customer$ = this._store.customer$;
private _onDestroy$ = new Subject<void>();
editRoute$ = combineLatest([this._store.processId$, this._store.customerId$, this._store.isBusinessKonto$]).pipe(
map(([processId, customerId, isB2b]) => this._navigation.editRoute({ processId, customerId, isB2b })),
editRoute$ = combineLatest([
this._store.processId$,
this._store.customerId$,
this._store.isBusinessKonto$,
]).pipe(
map(([processId, customerId, isB2b]) =>
this._navigation.editRoute({ processId, customerId, isB2b }),
),
);
addBillingAddressRoute$ = combineLatest([
@@ -81,7 +112,9 @@ export class DetailsMainViewBillingAddressesComponent
this._store.customerId$,
]).pipe(
map(([canAddNewAddress, processId, customerId]) =>
canAddNewAddress ? this._navigation.addBillingAddressRoute({ processId, customerId }) : undefined,
canAddNewAddress
? this._navigation.addBillingAddressRoute({ processId, customerId })
: undefined,
),
);
@@ -116,6 +149,10 @@ export class DetailsMainViewBillingAddressesComponent
.subscribe(([selectedPayer, customer]) => {
if (selectedPayer) {
this._host.setPayer(this._createPayerFromCrmPayerDTO(selectedPayer));
this.crmTabMetadataService.setSelectedPayerAddressId(
this.tabId(),
selectedPayer?.payer?.id,
);
} else if (this.showCustomerAddress) {
this._host.setPayer(this._createPayerFormCustomer(customer));
}
@@ -133,7 +170,9 @@ export class DetailsMainViewBillingAddressesComponent
title: payer.title,
firstName: payer.firstName,
lastName: payer.lastName,
communicationDetails: payer.communicationDetails ? { ...payer.communicationDetails } : undefined,
communicationDetails: payer.communicationDetails
? { ...payer.communicationDetails }
: undefined,
organisation: payer.organisation ? { ...payer.organisation } : undefined,
address: payer.address ? { ...payer.address } : undefined,
source: payer.id,
@@ -150,8 +189,12 @@ export class DetailsMainViewBillingAddressesComponent
title: customer.title,
firstName: customer.firstName,
lastName: customer.lastName,
communicationDetails: customer.communicationDetails ? { ...customer.communicationDetails } : undefined,
organisation: customer.organisation ? { ...customer.organisation } : undefined,
communicationDetails: customer.communicationDetails
? { ...customer.communicationDetails }
: undefined,
organisation: customer.organisation
? { ...customer.organisation }
: undefined,
address: customer.address ? { ...customer.address } : undefined,
};
}
@@ -166,26 +209,36 @@ export class DetailsMainViewBillingAddressesComponent
switchMap((customerId) =>
this._customerService
.getAssignedPayers({ customerId })
.pipe(tapResponse(this.handleLoadAssignedPayersResponse, this.handleLoadAssignedPayersError)),
.pipe(
tapResponse(
this.handleLoadAssignedPayersResponse,
this.handleLoadAssignedPayersError,
),
),
),
),
);
handleLoadAssignedPayersResponse = (response: ListResponseArgsOfAssignedPayerDTO) => {
const selectedPayer = response.result.reduce<AssignedPayerDTO>((prev, curr) => {
if (!prev) {
handleLoadAssignedPayersResponse = (
response: ListResponseArgsOfAssignedPayerDTO,
) => {
const selectedPayer = response.result.reduce<AssignedPayerDTO>(
(prev, curr) => {
if (!prev) {
return curr;
}
const prevDate = new Date(prev?.isDefault ?? 0);
const currDate = new Date(curr?.isDefault ?? 0);
if (prevDate > currDate) {
return prev;
}
return curr;
}
const prevDate = new Date(prev?.isDefault ?? 0);
const currDate = new Date(curr?.isDefault ?? 0);
if (prevDate > currDate) {
return prev;
}
return curr;
}, undefined);
},
undefined,
);
this.patchState({
assignedPayers: response.result,

View File

@@ -1,9 +1,20 @@
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, Host, inject } from '@angular/core';
import {
Component,
ChangeDetectionStrategy,
OnInit,
OnDestroy,
Host,
inject,
} from '@angular/core';
import { CustomerSearchStore } from '../../store';
import { CrmCustomerService } from '@domain/crm';
import { map, switchMap, takeUntil } from 'rxjs/operators';
import { Observable, Subject, combineLatest } from 'rxjs';
import { CustomerDTO, ListResponseArgsOfAssignedPayerDTO, ShippingAddressDTO } from '@generated/swagger/crm-api';
import {
CustomerDTO,
ListResponseArgsOfAssignedPayerDTO,
ShippingAddressDTO,
} from '@generated/swagger/crm-api';
import { AsyncPipe } from '@angular/common';
import { CustomerPipesModule } from '@shared/pipes/customer';
import { ComponentStore } from '@ngrx/component-store';
@@ -13,6 +24,8 @@ import { UiModalService } from '@ui/modal';
import { CustomerSearchNavigation } from '@shared/services/navigation';
import { RouterLink } from '@angular/router';
import { CustomerDetailsViewMainComponent } from '../details-main-view.component';
import { CrmTabMetadataService } from '@isa/crm/data-access';
import { injectTabId } from '@isa/core/tabs';
interface DetailsMainViewDeliveryAddressesComponentState {
shippingAddresses: ShippingAddressDTO[];
@@ -31,6 +44,9 @@ export class DetailsMainViewDeliveryAddressesComponent
extends ComponentStore<DetailsMainViewDeliveryAddressesComponentState>
implements OnInit, OnDestroy
{
tabId = injectTabId();
crmTabMetadataService = inject(CrmTabMetadataService);
private _host = inject(CustomerDetailsViewMainComponent, { host: true });
private _store = inject(CustomerSearchStore);
private _customerService = inject(CrmCustomerService);
@@ -39,7 +55,9 @@ export class DetailsMainViewDeliveryAddressesComponent
shippingAddresses$ = this.select((state) => state.shippingAddresses);
selectedShippingAddress$ = this.select((state) => state.selectedShippingAddress);
selectedShippingAddress$ = this.select(
(state) => state.selectedShippingAddress,
);
get selectedShippingAddress() {
return this.get((s) => s.selectedShippingAddress);
@@ -51,7 +69,12 @@ export class DetailsMainViewDeliveryAddressesComponent
this._store.isBusinessKonto$,
this._store.isMitarbeiter$,
this._store.isKundenkarte$,
]).pipe(map(([isBusinessKonto, isMitarbeiter, isKundenkarte]) => isBusinessKonto || isMitarbeiter || isKundenkarte));
]).pipe(
map(
([isBusinessKonto, isMitarbeiter, isKundenkarte]) =>
isBusinessKonto || isMitarbeiter || isKundenkarte,
),
);
get showCustomerAddress() {
return this._store.isBusinessKonto || this._store.isMitarbeiter;
@@ -67,13 +90,29 @@ export class DetailsMainViewDeliveryAddressesComponent
this._store.isMitarbeiter$,
]).pipe(
map(
([isOnlinekonto, isOnlineKontoMitKundenkarte, isKundenkarte, isBusinessKonto, isMitarbeiter]) =>
isOnlinekonto || isOnlineKontoMitKundenkarte || isKundenkarte || isBusinessKonto || isMitarbeiter,
([
isOnlinekonto,
isOnlineKontoMitKundenkarte,
isKundenkarte,
isBusinessKonto,
isMitarbeiter,
]) =>
isOnlinekonto ||
isOnlineKontoMitKundenkarte ||
isKundenkarte ||
isBusinessKonto ||
isMitarbeiter,
),
);
editRoute$ = combineLatest([this._store.processId$, this._store.customerId$, this._store.isBusinessKonto$]).pipe(
map(([processId, customerId, isB2b]) => this._navigation.editRoute({ processId, customerId, isB2b })),
editRoute$ = combineLatest([
this._store.processId$,
this._store.customerId$,
this._store.isBusinessKonto$,
]).pipe(
map(([processId, customerId, isB2b]) =>
this._navigation.editRoute({ processId, customerId, isB2b }),
),
);
addShippingAddressRoute$ = combineLatest([
@@ -82,15 +121,25 @@ export class DetailsMainViewDeliveryAddressesComponent
this._store.customerId$,
]).pipe(
map(([canAddNewAddress, processId, customerId]) =>
canAddNewAddress ? this._navigation.addShippingAddressRoute({ processId, customerId }) : undefined,
canAddNewAddress
? this._navigation.addShippingAddressRoute({ processId, customerId })
: undefined,
),
);
editShippingAddressRoute$ = (shippingAddressId: number) =>
combineLatest([this.canEditAddress$, this._store.processId$, this._store.customerId$]).pipe(
combineLatest([
this.canEditAddress$,
this._store.processId$,
this._store.customerId$,
]).pipe(
map(([canEditAddress, processId, customerId]) => {
if (canEditAddress) {
return this._navigation.editShippingAddressRoute({ processId, customerId, shippingAddressId });
return this._navigation.editShippingAddressRoute({
processId,
customerId,
shippingAddressId,
});
}
return undefined;
}),
@@ -100,7 +149,12 @@ export class DetailsMainViewDeliveryAddressesComponent
this._store.isKundenkarte$,
this._store.isBusinessKonto$,
this._store.isMitarbeiter$,
]).pipe(map(([isKundenkarte, isBusinessKonto, isMitarbeiter]) => isKundenkarte || isBusinessKonto || isMitarbeiter));
]).pipe(
map(
([isKundenkarte, isBusinessKonto, isMitarbeiter]) =>
isKundenkarte || isBusinessKonto || isMitarbeiter,
),
);
constructor() {
super({
@@ -110,20 +164,32 @@ export class DetailsMainViewDeliveryAddressesComponent
}
ngOnInit() {
this._store.customerId$.pipe(takeUntil(this._onDestroy$)).subscribe((customerId) => {
this.resetStore();
if (customerId) {
this.loadShippingAddresses(customerId);
}
});
this._store.customerId$
.pipe(takeUntil(this._onDestroy$))
.subscribe((customerId) => {
this.resetStore();
if (customerId) {
this.loadShippingAddresses(customerId);
}
});
combineLatest([this.selectedShippingAddress$, this._store.customer$])
.pipe(takeUntil(this._onDestroy$))
.subscribe(([selectedShippingAddress, customer]) => {
if (selectedShippingAddress) {
this._host.setShippingAddress(this._createShippingAddressFromShippingAddress(selectedShippingAddress));
this._host.setShippingAddress(
this._createShippingAddressFromShippingAddress(
selectedShippingAddress,
),
);
this.crmTabMetadataService.setSelectedShippingAddressId(
this.tabId(),
selectedShippingAddress?.id,
);
} else if (this.showCustomerAddress) {
this._host.setShippingAddress(this._createShippingAddressFromCustomer(customer));
this._host.setShippingAddress(
this._createShippingAddressFromCustomer(customer),
);
}
});
}
@@ -140,8 +206,12 @@ export class DetailsMainViewDeliveryAddressesComponent
title: customer?.title,
firstName: customer?.firstName,
lastName: customer?.lastName,
communicationDetails: customer?.communicationDetails ? { ...customer?.communicationDetails } : undefined,
organisation: customer?.organisation ? { ...customer?.organisation } : undefined,
communicationDetails: customer?.communicationDetails
? { ...customer?.communicationDetails }
: undefined,
organisation: customer?.organisation
? { ...customer?.organisation }
: undefined,
address: customer?.address ? { ...customer?.address } : undefined,
};
}
@@ -153,8 +223,12 @@ export class DetailsMainViewDeliveryAddressesComponent
title: address.title,
firstName: address.firstName,
lastName: address.lastName,
communicationDetails: address.communicationDetails ? { ...address.communicationDetails } : undefined,
organisation: address.organisation ? { ...address.organisation } : undefined,
communicationDetails: address.communicationDetails
? { ...address.communicationDetails }
: undefined,
organisation: address.organisation
? { ...address.organisation }
: undefined,
address: address.address ? { ...address.address } : undefined,
source: address.id,
};
@@ -165,26 +239,36 @@ export class DetailsMainViewDeliveryAddressesComponent
switchMap((customerId) =>
this._customerService
.getShippingAddresses({ customerId })
.pipe(tapResponse(this.handleLoadShippingAddressesResponse, this.handleLoadAssignedPayersError)),
.pipe(
tapResponse(
this.handleLoadShippingAddressesResponse,
this.handleLoadAssignedPayersError,
),
),
),
),
);
handleLoadShippingAddressesResponse = (response: ListResponseArgsOfAssignedPayerDTO) => {
const selectedShippingAddress = response.result.reduce<ShippingAddressDTO>((prev, curr) => {
if (!this.showCustomerAddress && !prev) {
handleLoadShippingAddressesResponse = (
response: ListResponseArgsOfAssignedPayerDTO,
) => {
const selectedShippingAddress = response.result.reduce<ShippingAddressDTO>(
(prev, curr) => {
if (!this.showCustomerAddress && !prev) {
return curr;
}
const prevDate = new Date(prev?.isDefault ?? 0);
const currDate = new Date(curr?.isDefault ?? 0);
if (prevDate > currDate) {
return prev;
}
return curr;
}
const prevDate = new Date(prev?.isDefault ?? 0);
const currDate = new Date(curr?.isDefault ?? 0);
if (prevDate > currDate) {
return prev;
}
return curr;
}, undefined);
},
undefined,
);
this.patchState({
shippingAddresses: response.result,

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,
});
}
}