Merged PR 1957: #5258 Prämie Landing

#5258 Prämie Landing
- feat(crm-data-access): improve error handling and encapsulation in CRM services
- feat(reward): separate reward selection from checkout flow and restructure catalog
- Merge branch 'feature/5202-Praemie' into feature/5263-Praemie-Item-List-Und-Lieferung-Auswaehlen
- feat(libs-checkout): implement reward catalog with list display and pagination
- feat(checkout): add reward selection and action components for catalog
- feat(lib-checkout, lib-catalogue, lib-shared): implement reward catalog pagination with enhanced filtering
This commit is contained in:
Nino Righi
2025-09-24 18:31:35 +00:00
committed by Lorenz Hilpert
parent d9ccf68314
commit 334436c737
50 changed files with 880 additions and 380 deletions

View File

@@ -202,7 +202,7 @@
} @else {
<button
type="button"
(click)="continue()"
(click)="continueReward()"
class="w-60 text-white text-lg bg-brand rounded-full px-5 py-3 absolute top-[calc(100vh-19.375rem)] left-1/2 -translate-x-1/2 font-bold disabled:bg-inactive-branch"
[disabled]="!(hasKundenkarte$ | async)"
>

View File

@@ -348,6 +348,15 @@ export class CustomerDetailsViewMainComponent
this._onDestroy$.complete();
}
@logAsync
// #5262 Für die Auswahl des Kunden im "Prämienshop-Modus" (Getrennt vom regulären Checkout-Prozess)
async continueReward() {
if (this.isRewardTab()) {
this.selectCustomerFacade.set(this.processId, this.customer.id);
await this._router.navigate(['/', this.processId, 'reward']);
}
}
@logAsync
async continue() {
if (this.isBusy) return;
@@ -393,26 +402,18 @@ export class CustomerDetailsViewMainComponent
this._setShippingAddress();
// #5262 Damit der Prämienshop den selektierten Customer mitbekommt
// Evtl. nicht mehr notwendig, wenn der neue TabService Adapter in Develop ist
this.selectCustomerFacade.set(this.processId, this.customer.id);
if (this.isRewardTab()) {
await this._router.navigate(['/', this.processId, 'reward']);
if (this.shoppingCartHasItems) {
// Navigation zum Warenkorb
const path = this._checkoutNavigation.getCheckoutReviewPath(
this.processId,
).path;
this._router.navigate(path);
} else {
if (this.shoppingCartHasItems) {
// Navigation zum Warenkorb
const path = this._checkoutNavigation.getCheckoutReviewPath(
this.processId,
).path;
this._router.navigate(path);
} else {
// Navigation zur Artikelsuche
const path = this._catalogNavigation.getArticleSearchBasePath(
this.processId,
).path;
this._router.navigate(path);
}
// Navigation zur Artikelsuche
const path = this._catalogNavigation.getArticleSearchBasePath(
this.processId,
).path;
this._router.navigate(path);
}
try {

View File

@@ -3,3 +3,4 @@ export * from './item';
export * from './price-value';
export * from './price';
export * from './product';
export * from './query-settings';

View File

@@ -19,8 +19,8 @@ export const QueryTokenSchema = z.object({
filter: z.record(z.any()).default({}), // Filter criteria as key-value pairs
input: z.record(z.string()).default({}).optional(),
orderBy: z.array(OrderBySchema).default([]).optional(), // Sorting parameters
skip: z.number().int().min(0).default(0),
take: z.number().int().min(1).max(100).default(20),
skip: z.number().default(0),
take: z.number().default(25),
});
/**

View File

@@ -1,5 +1,5 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { of, throwError } from 'rxjs';
import { CatalougeSearchService } from './catalouge-search.service';
import { SearchService } from '@generated/swagger/cat-search-api';
import { Item } from '../models';
@@ -14,6 +14,8 @@ describe('CatalougeSearchService', () => {
const searchServiceMock = {
SearchByEAN: jest.fn(),
SearchSearch: jest.fn(),
SearchLoyaltySettings: jest.fn(),
SearchLoyaltyItems: jest.fn(),
};
TestBed.configureTestingModule({
@@ -246,6 +248,61 @@ describe('CatalougeSearchService', () => {
});
});
describe('fetchLoyaltyQuerySettings', () => {
it('should return query settings when successful', async () => {
// Arrange
const mockQuerySettings = {
filters: ['category', 'brand'],
orderByOptions: [
{ by: 'name', label: 'Name', desc: false, selected: true },
{ by: 'price', label: 'Price', desc: true, selected: false },
],
};
const mockResponse = {
error: false,
result: mockQuerySettings,
} as any; // Using any to match the API response structure
searchServiceSpy.SearchLoyaltySettings.mockReturnValue(of(mockResponse));
// Act
const result = await service.fetchLoyaltyQuerySettings();
// Assert
expect(result).toEqual(mockQuerySettings);
expect(searchServiceSpy.SearchLoyaltySettings).toHaveBeenCalled();
});
it('should handle error and return undefined when response has error', async () => {
// Arrange
// Mock the service to throw an error when catchResponseArgsErrorPipe processes the error response
searchServiceSpy.SearchLoyaltySettings.mockReturnValue(
throwError(() => new Error('Failed to fetch settings')),
);
// Act
const result = await service.fetchLoyaltyQuerySettings();
// Assert
expect(result).toBeUndefined();
expect(searchServiceSpy.SearchLoyaltySettings).toHaveBeenCalled();
});
it('should handle HTTP error and return undefined', async () => {
// Arrange
const errorResponse = new Error('HTTP Error');
searchServiceSpy.SearchLoyaltySettings.mockReturnValue(
throwError(() => errorResponse),
);
// Act
const result = await service.fetchLoyaltyQuerySettings();
// Assert
expect(result).toBeUndefined();
expect(searchServiceSpy.SearchLoyaltySettings).toHaveBeenCalled();
});
});
describe('searchLoyaltyItems', () => {
it('should return loyalty items when search is successful', async () => {
// Arrange
@@ -335,7 +392,7 @@ describe('CatalougeSearchService', () => {
input: { search: 'test' },
orderBy: undefined,
skip: 0,
take: 20,
take: 25,
});
});
@@ -376,6 +433,56 @@ describe('CatalougeSearchService', () => {
});
});
it('should handle error and return undefined when response has error', async () => {
// Arrange
const mockResponse = {
error: true,
message: 'Search failed',
};
// Mock the service to throw an error when catchResponseArgsErrorPipe processes the error response
searchServiceSpy.SearchSearch.mockReturnValue(
throwError(() => new Error('Search failed')),
);
const params: QueryTokenInput = {
input: { search: 'test' },
};
const abortController = new AbortController();
// Act
const result = await service.searchLoyaltyItems(
params,
abortController.signal,
);
// Assert
expect(result).toBeUndefined();
expect(searchServiceSpy.SearchSearch).toHaveBeenCalled();
});
it('should handle HTTP error and return undefined', async () => {
// Arrange
const errorResponse = new Error('HTTP Error');
searchServiceSpy.SearchSearch.mockReturnValue(
throwError(() => errorResponse),
);
const params: QueryTokenInput = {
input: { search: 'test' },
};
const abortController = new AbortController();
// Act
const result = await service.searchLoyaltyItems(
params,
abortController.signal,
);
// Assert
expect(result).toBeUndefined();
expect(searchServiceSpy.SearchSearch).toHaveBeenCalled();
});
it('should handle minimal parameters with defaults', async () => {
// Arrange
const mockResponse = {
@@ -404,7 +511,7 @@ describe('CatalougeSearchService', () => {
input: undefined,
orderBy: undefined,
skip: 0,
take: 20,
take: 25,
});
});
});

View File

@@ -1,24 +1,30 @@
import { inject, Injectable } from '@angular/core';
import {
ListResponseArgsOfItemDTO,
ResponseArgsOfUISettingsDTO,
SearchService,
} from '@generated/swagger/cat-search-api';
import { firstValueFrom, map, Observable } from 'rxjs';
import {
catchResponseArgsErrorPipe,
ResponseArgsError,
takeUntilAborted,
takeUntilKeydownEscape,
} from '@isa/common/data-access';
import { Item } from '../models';
import { Item, QuerySettings } from '../models';
import {
SearchByTermInput,
SearchByTermSchema,
} from '../schemas/catalouge-search.schemas';
import { ListResponseArgs } from '@isa/common/data-access';
import { QueryTokenInput, QueryTokenSchema } from '../schemas';
import { logger } from '@isa/core/logging';
import { HttpErrorResponse } from '@angular/common/http';
@Injectable({ providedIn: 'root' })
export class CatalougeSearchService {
#searchService = inject(SearchService);
#logger = logger(() => ({ service: 'CatalougeSearchService' }));
searchByEans(...ean: string[]): Observable<Item[]> {
return this.#searchService.SearchByEAN(ean).pipe(
@@ -58,7 +64,32 @@ export class CatalougeSearchService {
return res as ListResponseArgs<Item>;
}
async searchLoyaltyItems(params: QueryTokenInput, abortSignal: AbortSignal) {
async fetchLoyaltyQuerySettings(): Promise<QuerySettings> {
this.#logger.info('Fetching loyalty query settings from API');
const req$ = this.#searchService
.SearchLoyaltySettings()
.pipe(catchResponseArgsErrorPipe<ResponseArgsOfUISettingsDTO>());
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched loyalty query settings');
return res.result as QuerySettings;
} catch (error:
| ResponseArgsError<ResponseArgsOfUISettingsDTO>
| HttpErrorResponse
| Error
| unknown) {
this.#logger.error('Error fetching loyalty query settings', error);
return undefined as unknown as QuerySettings;
}
}
async searchLoyaltyItems(
params: QueryTokenInput,
abortSignal: AbortSignal,
): Promise<ListResponseArgs<Item>> {
this.#logger.info('Fetching loyalty items from API');
const { filter, input, orderBy, skip, take } =
QueryTokenSchema.parse(params);
@@ -78,15 +109,21 @@ export class CatalougeSearchService {
})
.pipe(
takeUntilAborted(abortSignal),
takeUntilKeydownEscape(),
catchResponseArgsErrorPipe<ListResponseArgsOfItemDTO>(),
);
const res = await firstValueFrom(req$);
if (res.error) {
throw new Error(res.message);
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched loyalty items');
return res as ListResponseArgs<Item>;
} catch (error:
| ResponseArgsError<ListResponseArgsOfItemDTO>
| HttpErrorResponse
| Error
| unknown) {
this.#logger.error('Error fetching loyalty items', error);
return undefined as unknown as ListResponseArgs<Item>;
}
return res as ListResponseArgs<Item>;
}
}

View File

@@ -13,5 +13,13 @@
"jest.config.ts",
"src/**/*.test.ts"
],
"include": ["src/**/*.ts"]
"include": [
"src/**/*.ts",
"../../checkout/data-access/src/lib/models/checkout-item.ts",
"../../checkout/data-access/src/lib/models/checkout.ts",
"../../checkout/data-access/src/lib/models/destination.ts",
"../../checkout/data-access/src/lib/models/shipping-address.ts",
"../../checkout/data-access/src/lib/models/shipping-target.ts",
"../../checkout/data-access/src/lib/models/shopping-cart-item.ts"
]
}

View File

@@ -1,3 +1,3 @@
export * from './lib/helpers';
export * from './lib/services';
export * from './lib/models';
export * from './lib/store';
export * from './lib/helpers';
export * from './lib/models';

View File

@@ -1,2 +0,0 @@
export * from './models';
export * from './services';

View File

@@ -1,3 +1,3 @@
import { CheckoutItemDTO } from '@generated/swagger/checkout-api';
export type CheckoutItem = CheckoutItemDTO;
import { CheckoutItemDTO } from '@generated/swagger/checkout-api';
export type CheckoutItem = CheckoutItemDTO;

View File

@@ -1,3 +1,3 @@
import { CheckoutDTO } from '@generated/swagger/checkout-api';
export type Checkout = CheckoutDTO;
import { CheckoutDTO } from '@generated/swagger/checkout-api';
export type Checkout = CheckoutDTO;

View File

@@ -1,6 +1,6 @@
import { DestinationDTO } from '@generated/swagger/checkout-api';
import { ShippingTarget } from './shipping-target';
export type Destination = DestinationDTO & {
target: ShippingTarget;
};
import { DestinationDTO } from '@generated/swagger/checkout-api';
import { ShippingTarget } from './shipping-target';
export type Destination = DestinationDTO & {
target: ShippingTarget;
};

View File

@@ -1,8 +1,7 @@
export * from './availability';
export * from './checkout-item';
export * from './checkout';
export * from './destination';
export * from './query-settings';
export * from './shipping-address';
export * from './shipping-target';
export * from './shopping-cart-item';
export * from './availability';
export * from './checkout-item';
export * from './checkout';
export * from './destination';
export * from './shipping-address';
export * from './shipping-target';
export * from './shopping-cart-item';

View File

@@ -1,3 +1,3 @@
import { ShippingAddressDTO } from '@generated/swagger/checkout-api';
export type ShippingAddress = ShippingAddressDTO;
import { ShippingAddressDTO } from '@generated/swagger/checkout-api';
export type ShippingAddress = ShippingAddressDTO;

View File

@@ -1,8 +1,8 @@
export const ShippingTarget = {
None: 0,
Branch: 1,
Delivery: 2,
} as const;
export type ShippingTarget =
(typeof ShippingTarget)[keyof typeof ShippingTarget];
export const ShippingTarget = {
None: 0,
Branch: 1,
Delivery: 2,
} as const;
export type ShippingTarget =
(typeof ShippingTarget)[keyof typeof ShippingTarget];

View File

@@ -1,3 +1,3 @@
import { ShoppingCartItemDTO } from '@generated/swagger/checkout-api';
export type ShoppingCartItem = ShoppingCartItemDTO;
import { ShoppingCartItemDTO } from '@generated/swagger/checkout-api';
export type ShoppingCartItem = ShoppingCartItemDTO;

View File

@@ -1 +0,0 @@
export * from './reward-checkout.service';

View File

@@ -1,34 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { logger } from '@isa/core/logging';
import {
ResponseArgsOfUISettingsDTO,
SearchService,
} from '@generated/swagger/cat-search-api';
import { firstValueFrom } from 'rxjs';
import { QuerySettings } from '../models';
import { catchResponseArgsErrorPipe } from '@isa/common/data-access';
@Injectable({ providedIn: 'root' })
export class RewardCheckoutService {
#searchService = inject(SearchService);
#logger = logger(() => ({ service: 'RewardCheckoutService' }));
async fetchQuerySettings(): Promise<QuerySettings> {
this.#logger.info('Fetching query settings from API');
const req$ = this.#searchService
.SearchLoyaltySettings()
.pipe(catchResponseArgsErrorPipe<ResponseArgsOfUISettingsDTO>());
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
const error = new Error(res.message || 'Failed to fetch Query Settings');
this.#logger.error('Failed to fetch query settings', error);
throw error;
}
this.#logger.debug('Successfully fetched query settings');
return res.result as QuerySettings;
}
}

View File

@@ -0,0 +1 @@
export * from './reward-catalog.store';

View File

@@ -0,0 +1,165 @@
import {
patchState,
signalStore,
type,
withComputed,
withHooks,
withMethods,
} from '@ngrx/signals';
import {
addEntity,
entityConfig,
setAllEntities,
updateEntity,
withEntities,
} from '@ngrx/signals/entities';
import { SessionStorageProvider, withStorage } from '@isa/core/storage';
import { Item } from '@isa/catalogue/data-access';
import { computed, effect, inject } from '@angular/core';
import { TabService } from '@isa/core/tabs';
export interface RewardCatalogEntity {
tabId: number;
items: Item[];
hits: number;
selectedItems: Record<number, Item>;
}
const config = entityConfig({
entity: type<RewardCatalogEntity>(),
selectId: (e) => e.tabId,
});
// TODO: Soll ausgetauscht werden durch einen Store der die TabId und SelectedItems generisch pro Tab im eingesetzten Feature verwaltet
// Der Store soll außerdem keine Items halten
export const RewardCatalogStore = signalStore(
{ providedIn: 'root' },
withStorage(
'reward-checkout-data-access.reward-catalog-store',
SessionStorageProvider,
),
withEntities<RewardCatalogEntity>(config),
withComputed((store, tabService = inject(TabService)) => {
const activeTabId = computed(() => tabService.activatedTabId());
const activeEntity = computed<RewardCatalogEntity | undefined>(() => {
const pid = activeTabId();
if (pid == null) return undefined;
return store.entities().find((e) => e.tabId === pid);
});
return {
activeTabId,
items: computed(() => activeEntity()?.items ?? []),
hits: computed(() => activeEntity()?.hits ?? 0),
selectedItems: computed(
() => activeEntity()?.selectedItems ?? ({} as Record<number, Item>),
),
} as const;
}),
withMethods((store) => {
const ensureEntity = (tabId: number) => {
const entity = store.entities().find((e) => e.tabId === tabId);
if (!entity) {
const newEntity: RewardCatalogEntity = {
tabId,
items: [],
hits: 0,
selectedItems: {},
};
patchState(store, addEntity(newEntity, config));
}
};
return {
setItems(items: Item[], hits: number) {
const pid = store.activeTabId();
if (pid == null) return;
ensureEntity(pid);
patchState(
store,
updateEntity(
{
id: pid,
changes: { items, hits },
},
config,
),
);
store.storeState();
},
updateItems(items: Item[], hits: number) {
const pid = store.activeTabId();
if (pid == null) return;
ensureEntity(pid);
const existing = store.entities().find((e) => e.tabId === pid);
const merged = existing ? [...(existing.items ?? []), ...items] : items;
patchState(
store,
updateEntity(
{
id: pid,
changes: { items: merged, hits },
},
config,
),
);
store.storeState();
},
selectItem(itemId: number, item: Item) {
const pid = store.activeTabId();
if (pid == null) return;
ensureEntity(pid);
const entity = store.entities().find((e) => e.tabId === pid)!;
const selectedItems = { ...entity.selectedItems, [itemId]: item };
patchState(
store,
updateEntity({ id: pid, changes: { selectedItems } }, config),
);
},
removeItem(itemId: number) {
const pid = store.activeTabId();
if (pid == null) return;
const entity = store.entities().find((e) => e.tabId === pid);
if (!entity) return;
const selectedItems = { ...entity.selectedItems };
delete selectedItems[itemId];
patchState(
store,
updateEntity({ id: pid, changes: { selectedItems } }, config),
);
},
clearSelectedItems() {
const pid = store.activeTabId();
if (pid == null) return;
const entity = store.entities().find((e) => e.tabId === pid);
if (!entity) return;
if (Object.keys(entity.selectedItems).length === 0) return;
patchState(
store,
updateEntity({ id: pid, changes: { selectedItems: {} } }, config),
);
},
clearState() {
patchState(store, setAllEntities<RewardCatalogEntity>([], config));
store.storeState();
},
removeAllEntitiesByTabId(tabId: number) {
const remaining = store.entities().filter((e) => e.tabId !== tabId);
patchState(
store,
setAllEntities<RewardCatalogEntity>(remaining, config),
);
store.storeState();
},
};
}),
withHooks((store, tabService = inject(TabService)) => ({
onInit() {
effect(() => {
const tabIds = tabService.ids();
const orphan = store.entities().find((e) => !tabIds.includes(e.tabId));
if (orphan) {
store.removeAllEntitiesByTabId(orphan.tabId);
}
});
},
})),
);

View File

@@ -1,9 +1,9 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import {
CatalougeSearchService,
QuerySettings,
RewardCheckoutService,
} from '@isa/checkout/data-access';
} from '@isa/catalogue/data-access';
export const querySettingsResolverFn: ResolveFn<QuerySettings> = () =>
inject(RewardCheckoutService).fetchQuerySettings();
inject(CatalougeSearchService).fetchLoyaltyQuerySettings();

View File

@@ -3,7 +3,7 @@ import {
CatalougeSearchService,
QueryTokenInput,
} from '@isa/catalogue/data-access';
import { ResponseArgsError } from '@isa/common/data-access';
import { RewardCatalogStore } from '@isa/checkout/data-access';
import { SearchTrigger } from '@isa/shared/filter';
export const createRewardCatalogResource = (
@@ -13,18 +13,43 @@ export const createRewardCatalogResource = (
},
) => {
const catalogSearchService = inject(CatalougeSearchService);
const rewardCatalogStore = inject(RewardCatalogStore);
return resource({
params,
loader: async ({ abortSignal, params }) => {
// Load from session storage if items exist
if (params.searchTrigger === 'initial') {
await rewardCatalogStore.restoreState();
if (rewardCatalogStore.items().length > 0) {
return;
}
}
let skip = rewardCatalogStore.items().length;
if (params.searchTrigger !== 'reload') {
rewardCatalogStore.clearState();
skip = 0;
}
const fetchCatalogResponse =
await catalogSearchService.searchLoyaltyItems(
params.queryToken,
{
...params.queryToken,
skip,
},
abortSignal,
);
if (fetchCatalogResponse?.error) {
throw new ResponseArgsError(fetchCatalogResponse);
const result = fetchCatalogResponse?.result || [];
const hits = fetchCatalogResponse?.hits || 0;
if (params.searchTrigger === 'reload') {
rewardCatalogStore.updateItems(result, hits);
} else {
rewardCatalogStore.setItems(result, hits);
}
return fetchCatalogResponse;

View File

@@ -22,10 +22,6 @@ export const createRewardCustomerCardResource = (
abortSignal,
);
if (fetchCustomerCardsResponse?.error) {
throw new ResponseArgsError(fetchCustomerCardsResponse);
}
const activePrimaryCard =
fetchCustomerCardsResponse.result?.find(
(card) => card.isPrimary && card.isActive,

View File

@@ -0,0 +1,12 @@
<button
class="fixed right-6 bottom-6"
data-which="start-remission"
data-what="start-remission"
uiButton
color="brand"
size="large"
[disabled]="!hasSelectedItems()"
(click)="continueToPurchasingOptions()"
>
Prämie auswählen
</button>

View File

@@ -0,0 +1,29 @@
import {
ChangeDetectionStrategy,
Component,
linkedSignal,
inject,
} from '@angular/core';
import { RewardCatalogStore } from '@isa/checkout/data-access';
import { ButtonComponent } from '@isa/ui/buttons';
@Component({
selector: 'reward-action',
templateUrl: './reward-action.component.html',
styleUrl: './reward-action.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ButtonComponent],
})
export class RewardActionComponent {
#store = inject(RewardCatalogStore);
selectedItems = linkedSignal(() => this.#store.selectedItems());
hasSelectedItems = linkedSignal(() => {
return Object.keys(this.selectedItems() || {}).length > 0;
});
continueToPurchasingOptions() {
console.log('Kaufoptionen Modal öffnen mit: ', this.selectedItems());
}
}

View File

@@ -1,9 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'reward-catalog-item',
templateUrl: './reward-catalog-item.component.html',
styleUrl: './reward-catalog-item.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RewardCatalogItemComponent {}

View File

@@ -1,69 +1,7 @@
<reward-header></reward-header>
<filter-controls-panel (triggerSearch)="search($event)"></filter-controls-panel>
<span
class="text-isa-neutral-900 isa-text-body-2-regular self-start"
data-what="result-count"
>
{{ hits() }} Einträge
</span>
<div class="flex flex-col gap-4 w-full items-center justify-center mb-24">
<!-- @if (hits()) {
<!-- @for (item of items(); track item.id) {
@defer (on viewport) {
<reward-catalog-item
#listElement
[item]="item"
[stock]="getStockForItem(item)"
[stockFetching]="inStockFetching()"
[productGroupValue]="getProductGroupValueForItem(item)"
(inProgressChange)="onListItemActionInProgress($event)"
></reward-catalog-item>
} @placeholder {
<div class="h-[7.75rem] w-full flex items-center justify-center">
<ui-icon-button
[pending]="true"
[color]="'tertiary'"
data-what="load-spinner"
data-which="item-placeholder"
></ui-icon-button>
</div>
}
}
} @else {
<ui-empty-state
class="w-full justify-self-center"
title="Keine Suchergebnisse"
description="Bitte prüfen Sie die Schreibweise oder ändern Sie die Filtereinstellungen."
>
</ui-empty-state>
} -->
<ui-empty-state
class="w-full justify-self-center"
title="Keine Suchergebnisse"
description="Bitte prüfen Sie die Schreibweise oder ändern Sie die Filtereinstellungen."
>
</ui-empty-state>
</div>
<!--
<ui-stateful-button
class="fixed right-6 bottom-6"
(clicked)="remitItems()"
(action)="remitItems()"
[(state)]="remitItemsState"
defaultContent="Prämie auswählen"
defaultWidth="13rem"
[errorContent]="remitItemsError()"
errorWidth="32rem"
errorAction="Erneut versuchen"
successContent="Hinzugefügt"
successWidth="20rem"
size="large"
color="brand"
[pending]="remitItemsInProgress()"
[disabled]="!hasSelectedItems() || listItemActionInProgress()"
>
</ui-stateful-button>} -->
<reward-list
[searchTrigger]="searchTrigger()"
(searchTriggerChange)="searchTrigger.set($event)"
></reward-list>
<reward-action></reward-action>

View File

@@ -1,7 +1,6 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core';
@@ -14,13 +13,10 @@ import {
SearchTrigger,
FilterService,
} from '@isa/shared/filter';
import { IconButtonComponent } from '@isa/ui/buttons';
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
import { logger } from '@isa/core/logging';
import { createRewardCatalogResource } from './resources';
import { EmptyStateComponent } from '@isa/ui/empty-state';
import { RewardCatalogItemComponent } from './reward-catalog-item/reward-catalog-item.component';
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';
/**
* Factory function to retrieve query settings from the activated route data.
@@ -43,10 +39,9 @@ function querySettingsFactory() {
],
imports: [
FilterControlsPanelComponent,
IconButtonComponent,
EmptyStateComponent,
RewardHeaderComponent,
RewardCatalogItemComponent,
RewardListComponent,
RewardActionComponent,
],
host: {
'[class]':
@@ -54,78 +49,14 @@ function querySettingsFactory() {
},
})
export class RewardCatalogComponent {
/**
* Signal to trigger searches in the reward catalog.
* Can be 'reload', 'initial', or a specific SearchTrigger value.
*/
searchTrigger = signal<SearchTrigger | 'reload' | 'initial'>('initial');
/**
* Logger instance for logging component events and errors.
* @private
*/
#logger = logger(() => ({
component: 'RewardCatalogComponent',
}));
/**
* FilterService instance for managing filter state and queries.
* @private
*/
#filterService = inject(FilterService);
/**
* Restores scroll position when navigating back to this component.
*/
restoreScrollPosition = injectRestoreScrollPosition();
/**
* Resource for fetching and managing the reward catalog data.
* Uses the FilterService to get the current query token and reacts to search triggers.
* @private
* @returns The reward catalog resource.
*/
rewardCatalogResource = createRewardCatalogResource(() => {
return {
queryToken: this.#filterService.query(),
searchTrigger: this.searchTrigger(),
};
});
searchTrigger = signal<SearchTrigger | 'reload' | 'initial'>('initial');
/**
* Computed signal for the current reward catalog response.
* @returns The latest reward catalog response or undefined.
*/
listResponseValue = computed(() => this.rewardCatalogResource.value());
/**
* Computed signal indicating whether the reward catalog resource is currently fetching data.
* @returns True if fetching, false otherwise.
*/
listFetching = computed(
() => this.rewardCatalogResource.status() === 'loading',
);
/**
* Computed signal for the reward catalog items to display.
* @returns Array of Item.
*/
items = computed(() => {
const value = this.listResponseValue();
return value?.result ? value.result : [];
});
/**
* Computed signal for the total number of hits in the reward catalog.
* @returns Number of hits, or 0 if unavailable.
*/
hits = computed(() => {
const value = this.listResponseValue();
return value?.hits ? value.hits : 0;
});
#filterService = inject(FilterService);
search(trigger: SearchTrigger): void {
this.searchTrigger.set(trigger); // Ist entweder 'scan', 'input', 'filter' oder 'orderBy'
this.#filterService.commit();
this.searchTrigger.set(trigger);
}
}

View File

@@ -1,3 +1,3 @@
:host {
@apply h-32 w-full flex flex-row gap-20 rounded-2xl bg-isa-neutral-400 p-6 text-isa-neutral-900;
@apply h-[9.5rem] desktop:h-32 w-full flex flex-row gap-20 rounded-2xl bg-isa-neutral-400 p-6 text-isa-neutral-900;
}

View File

@@ -1,5 +1,5 @@
:host {
@apply h-32 w-full grid grid-cols-[1fr,auto] gap-6 rounded-2xl bg-isa-neutral-400 p-6 justify-between;
@apply h-[9.5rem] desktop:h-32 w-full grid grid-cols-[1fr,auto] gap-6 rounded-2xl bg-isa-neutral-400 p-6 justify-between;
}
.reward-start-card__title-container {

View File

@@ -0,0 +1,10 @@
<ui-checkbox appearance="bullet">
<input
type="checkbox"
[ngModel]="itemSelected()"
(ngModelChange)="setSelected($event)"
(click)="$event.stopPropagation()"
data-what="reward-item-selection-checkbox"
[attr.data-which]="item()?.product?.ean"
/>
</ui-checkbox>

View File

@@ -0,0 +1,39 @@
import {
ChangeDetectionStrategy,
Component,
input,
linkedSignal,
inject,
} from '@angular/core';
import { Item } from '@isa/catalogue/data-access';
import { FormsModule } from '@angular/forms';
import { CheckboxComponent } from '@isa/ui/input-controls';
import { RewardCatalogStore } from '@isa/checkout/data-access';
@Component({
selector: 'reward-list-item-select',
templateUrl: './reward-list-item-select.component.html',
styleUrl: './reward-list-item-select.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormsModule, CheckboxComponent],
})
export class RewardListItemSelectComponent {
#store = inject(RewardCatalogStore);
item = input.required<Item>();
itemSelected = linkedSignal(() => {
const itemId = this.item()?.id;
return !!itemId && !!this.#store.selectedItems()?.[itemId];
});
setSelected(selected: boolean) {
const itemId = this.item()?.id;
if (itemId && selected) {
this.#store.selectItem(itemId, this.item());
}
if (itemId && !selected) {
this.#store.removeItem(itemId);
}
}
}

View File

@@ -0,0 +1,15 @@
:host {
@apply w-full;
}
.ui-client-row {
@apply py-6 grid grid-cols-[0.75fr,0.25fr,auto];
}
.stock-row-tablet {
@apply row-start-1 self-end;
}
.select-row-tablet {
@apply row-start-1 self-start mt-4;
}

View File

@@ -0,0 +1,28 @@
@let i = item();
<ui-client-row data-what="reward-list-item" [attr.data-which]="i.id">
<ui-client-row-content
class="flex-grow"
[class.row-start-1]="!desktopBreakpoint()"
>
<checkout-product-info-redemption
[item]="i"
[orientation]="productInfoOrientation()"
></checkout-product-info-redemption>
</ui-client-row-content>
<ui-item-row-data
class="flex-grow"
[class.stock-row-tablet]="!desktopBreakpoint()"
>
<checkout-stock-info [item]="i"></checkout-stock-info>
</ui-item-row-data>
<ui-item-row-data
class="justify-center"
[class.select-row-tablet]="!desktopBreakpoint()"
>
<reward-list-item-select
class="self-end"
[item]="i"
></reward-list-item-select>
</ui-item-row-data>
</ui-client-row>

View File

@@ -0,0 +1,34 @@
import {
ChangeDetectionStrategy,
Component,
input,
linkedSignal,
} from '@angular/core';
import { Item } from '@isa/catalogue/data-access';
import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
import { Breakpoint, breakpoint } from '@isa/ui/layout';
import { ProductInfoRedemptionComponent } from '@isa/checkout/shared/product-info';
import { StockInfoComponent } from '@isa/checkout/shared/product-info';
import { RewardListItemSelectComponent } from './reward-list-item-select/reward-list-item-select.component';
@Component({
selector: 'reward-list-item',
templateUrl: './reward-list-item.component.html',
styleUrl: './reward-list-item.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ClientRowImports,
ItemRowDataImports,
ProductInfoRedemptionComponent,
StockInfoComponent,
RewardListItemSelectComponent,
],
})
export class RewardListItemComponent {
item = input.required<Item>();
desktopBreakpoint = breakpoint([Breakpoint.DekstopL, Breakpoint.DekstopXL]);
productInfoOrientation = linkedSignal(() => {
return this.desktopBreakpoint() ? 'horizontal' : 'vertical';
});
}

View File

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

View File

@@ -0,0 +1,64 @@
<span
class="text-isa-neutral-900 isa-text-body-2-regular self-start"
data-what="result-count"
>
{{ hits() }} Einträge
</span>
@if (!!itemsLength()) {
<div
class="flex flex-col w-full items-center justify-center mb-24 bg-transparent rounded-2xl gap-4 desktop-large:gap-0 desktop-large:bg-isa-white"
>
@for (item of items(); track item.id; let isLast = $last) {
@defer (on viewport) {
<reward-list-item #listElement [item]="item"></reward-list-item>
@if (!isLast) {
<hr
class="hidden w-[calc(100%-3rem)] desktop-large:flex self-center"
/>
}
} @placeholder {
<div class="h-[7.75rem] w-full flex items-center justify-center">
<ui-icon-button
[pending]="true"
[color]="'tertiary'"
data-what="load-spinner"
data-which="item-placeholder"
></ui-icon-button>
</div>
}
}
@if (listFetching()) {
<div class="h-[7.75rem] w-full flex items-center justify-center">
<ui-icon-button
[pending]="true"
[color]="'tertiary'"
data-what="load-spinner"
data-which="pagination"
></ui-icon-button>
</div>
} @else if (renderPageTrigger()) {
<div (uiInViewport)="paging($event)"></div>
}
</div>
} @else if (renderSearchLoader()) {
<div class="h-[7.75rem] w-full flex items-center justify-center">
<ui-icon-button
[pending]="true"
[color]="'tertiary'"
data-what="load-spinner"
data-which="search"
></ui-icon-button>
</div>
} @else {
<ui-empty-state
class="w-full self-center"
title="Keine Suchergebnisse"
description="Überprüfen Sie die Lesepunkte, Filter und Suchanfrage."
>
<button uiButton type="button" size="large" (click)="resetFilter()">
Filter zurücksetzen
</button>
</ui-empty-state>
}

View File

@@ -0,0 +1,96 @@
import {
ChangeDetectionStrategy,
Component,
inject,
linkedSignal,
model,
} from '@angular/core';
import { FilterService, SearchTrigger } from '@isa/shared/filter';
import { ButtonComponent, IconButtonComponent } from '@isa/ui/buttons';
import { EmptyStateComponent } from '@isa/ui/empty-state';
import { createRewardCatalogResource } from '../resources';
import { RewardListItemComponent } from './reward-list-item/reward-list-item.component';
import { InViewportDirective } from '@isa/ui/layout';
import { RewardCatalogStore } from '@isa/checkout/data-access';
@Component({
selector: 'reward-list',
templateUrl: './reward-list.component.html',
styleUrl: './reward-list.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ButtonComponent,
IconButtonComponent,
EmptyStateComponent,
RewardListItemComponent,
InViewportDirective,
],
host: {
'[class]': '"w-full flex flex-col gap-4"',
},
})
export class RewardListComponent {
searchTrigger = model<SearchTrigger | 'reload' | 'initial'>('initial');
/**
* FilterService instance for managing filter state and queries.
* @private
*/
#filterService = inject(FilterService);
/**
* RewardCatalogStore instance for managing reward catalog state.
* @private
*/
#rewardCatalogStore = inject(RewardCatalogStore);
/**
* Resource for fetching and managing the reward catalog data.
* Uses the FilterService to get the current query token and reacts to search triggers.
* @private
* @returns The reward catalog resource.
*/
rewardCatalogResource = createRewardCatalogResource(() => {
return {
queryToken: this.#filterService.query(),
searchTrigger: this.searchTrigger(),
};
});
/**
* LinkedSignal indicating whether the reward catalog resource is currently fetching data.
* @returns True if fetching, false otherwise.
*/
listFetching = linkedSignal(
() => this.rewardCatalogResource.status() === 'loading',
);
items = linkedSignal(() => this.#rewardCatalogStore.items());
itemsLength = linkedSignal(() => this.items().length);
hits = linkedSignal(() => this.#rewardCatalogStore.hits());
renderSearchLoader = linkedSignal(() => {
return this.listFetching() && this.itemsLength() === 0;
});
renderPageTrigger = linkedSignal(() => {
if (this.listFetching()) return false;
return this.hits() > this.itemsLength();
});
paging(inViewport: boolean) {
if (!inViewport) {
return;
}
if (this.itemsLength() !== this.hits()) {
this.searchTrigger.set('reload');
}
}
resetFilter() {
this.#filterService.reset({ commit: true });
}
}

View File

@@ -12,8 +12,8 @@
@if (displayAddress()) {
{{ branchName() }} |
<shared-inline-address [address]="address()"></shared-inline-address>
} @else if (estimatedDelivery(); as delivery) {
Zustellung zwischen {{ delivery.start | date: 'E, dd.MM.' }} und
{{ delivery.stop | date: 'E, dd.MM.' }}
} @else if (estimatedDelivery()) {
Zustellung zwischen {{ estimatedDelivery().start | date: 'E, dd.MM.' }} und
{{ estimatedDelivery().stop | date: 'E, dd.MM.' }}
}
</div>

View File

@@ -1,5 +1,5 @@
:host {
@apply grid grid-flow-col gap-6 text-neutral-900;
@apply grid grid-flow-col desktop-large:grid-cols-[0.75fr,0.5fr] gap-6 text-neutral-900;
}
:host.vertical {

View File

@@ -16,7 +16,7 @@ export type ProductInfoItem = {
| 'manufacturer'
| 'publicationDate'
>;
redemptionPoints: number;
redemptionPoints?: number;
};
@Component({

View File

@@ -14,9 +14,7 @@ import { SkeletonLoaderComponent } from '@isa/ui/skeleton-loader';
export type StockInfoItem = {
id: Item['id'];
catalogAvailability: Required<
Pick<Item['catalogAvailability'], 'ssc' | 'sscText'>
>;
catalogAvailability: Pick<Item['catalogAvailability'], 'ssc' | 'sscText'>;
};
@Component({

View File

@@ -39,7 +39,5 @@ export const catchResponseArgsErrorPipe = <T>(): OperatorFunction<T, T> =>
}
}
return throwError(() =>
err instanceof Error ? err : new Error(String(err)),
);
return throwError(() => err);
});

View File

@@ -1,15 +1,15 @@
import { inject, Injectable } from '@angular/core';
import { CrmSearchService } from '../services';
import { FetchCustomerCardsInput } from '../schemas';
@Injectable({ providedIn: 'root' })
export class CustomerCardsFacade {
crmSearchService = inject(CrmSearchService);
async get(params: FetchCustomerCardsInput, abortSignal?: AbortSignal) {
return await this.crmSearchService.fetchCustomerCards(
{ ...params },
abortSignal,
);
}
}
import { inject, Injectable } from '@angular/core';
import { CrmSearchService } from '../services';
import { FetchCustomerCardsInput } from '../schemas';
@Injectable({ providedIn: 'root' })
export class CustomerCardsFacade {
#crmSearchService = inject(CrmSearchService);
async get(params: FetchCustomerCardsInput, abortSignal: AbortSignal) {
return await this.#crmSearchService.fetchCustomerCards(
{ ...params },
abortSignal,
);
}
}

View File

@@ -3,17 +3,17 @@ import { CrmTabMetadataService } from '../services';
@Injectable({ providedIn: 'root' })
export class SelectedCustomerFacade {
crmTabMetadataService = inject(CrmTabMetadataService);
#crmTabMetadataService = inject(CrmTabMetadataService);
set(tabId: number, customerId: number) {
this.crmTabMetadataService.setSelectedCustomerId(tabId, customerId);
this.#crmTabMetadataService.setSelectedCustomerId(tabId, customerId);
}
get(tab: number) {
return this.crmTabMetadataService.selectedCustomerId(tab);
return this.#crmTabMetadataService.selectedCustomerId(tab);
}
clear(tab: number) {
this.crmTabMetadataService.setSelectedCustomerId(tab, undefined);
this.#crmTabMetadataService.setSelectedCustomerId(tab, undefined);
}
}

View File

@@ -1,75 +1,86 @@
import { inject, Injectable } from '@angular/core';
import {
CustomerService,
ResponseArgsOfCustomerDTO,
ResponseArgsOfIEnumerableOfBonusCardInfoDTO,
} from '@generated/swagger/crm-api';
import {
FetchCustomerCardsInput,
FetchCustomerCardsSchema,
FetchCustomerInput,
FetchCustomerSchema,
} from '../schemas';
import {
catchResponseArgsErrorPipe,
ResponseArgs,
takeUntilAborted,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { BonusCardInfo, Customer } from '../models';
@Injectable({ providedIn: 'root' })
export class CrmSearchService {
#customerService = inject(CustomerService);
async fetchCustomer(
params: FetchCustomerInput,
abortSignal?: AbortSignal,
): Promise<ResponseArgs<Customer>> {
const { customerId, eagerLoading } = FetchCustomerSchema.parse(params);
let req$ = this.#customerService.CustomerGetCustomer({
customerId,
eagerLoading,
});
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
req$ = req$.pipe(catchResponseArgsErrorPipe<ResponseArgsOfCustomerDTO>());
const res = await firstValueFrom(req$);
if (res.error) {
throw new Error(res.message);
}
return res as ResponseArgs<Customer>;
}
async fetchCustomerCards(
params: FetchCustomerCardsInput,
abortSignal?: AbortSignal,
): Promise<ResponseArgs<BonusCardInfo[]>> {
const { customerId } = FetchCustomerCardsSchema.parse(params);
let req$ = this.#customerService.CustomerGetBonuscards(customerId);
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
req$ = req$.pipe(
catchResponseArgsErrorPipe<ResponseArgsOfIEnumerableOfBonusCardInfoDTO>(),
);
const res = await firstValueFrom(req$);
if (res.error) {
throw new Error(res.message);
}
return res as ResponseArgs<BonusCardInfo[]>;
}
}
import { inject, Injectable } from '@angular/core';
import {
CustomerService,
ResponseArgsOfCustomerDTO,
ResponseArgsOfIEnumerableOfBonusCardInfoDTO,
} from '@generated/swagger/crm-api';
import {
FetchCustomerCardsInput,
FetchCustomerCardsSchema,
FetchCustomerInput,
FetchCustomerSchema,
} from '../schemas';
import {
catchResponseArgsErrorPipe,
ResponseArgs,
ResponseArgsError,
takeUntilAborted,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { BonusCardInfo, Customer } from '../models';
import { logger } from '@isa/core/logging';
import { HttpErrorResponse } from '@angular/common/http';
@Injectable({ providedIn: 'root' })
export class CrmSearchService {
#customerService = inject(CustomerService);
#logger = logger(() => ({
service: 'CrmSearchService',
}));
async fetchCustomer(
params: FetchCustomerInput,
abortSignal: AbortSignal,
): Promise<ResponseArgs<Customer>> {
this.#logger.info('Fetching customer from API');
const { customerId, eagerLoading } = FetchCustomerSchema.parse(params);
const req$ = this.#customerService
.CustomerGetCustomer({ customerId, eagerLoading })
.pipe(
takeUntilAborted(abortSignal),
catchResponseArgsErrorPipe<ResponseArgsOfCustomerDTO>(),
);
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched customer');
return res as ResponseArgs<Customer>;
} catch (error:
| ResponseArgsError<ResponseArgsOfCustomerDTO>
| HttpErrorResponse
| Error
| unknown) {
this.#logger.error('Error fetching customer', error);
return undefined as unknown as ResponseArgs<Customer>;
}
}
async fetchCustomerCards(
params: FetchCustomerCardsInput,
abortSignal: AbortSignal,
): Promise<ResponseArgs<BonusCardInfo[]>> {
this.#logger.info('Fetching customer bonuscards from API');
const { customerId } = FetchCustomerCardsSchema.parse(params);
const req$ = this.#customerService
.CustomerGetBonuscards(customerId)
.pipe(
takeUntilAborted(abortSignal),
catchResponseArgsErrorPipe<ResponseArgsOfIEnumerableOfBonusCardInfoDTO>(),
);
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched customer bonuscards');
return res as ResponseArgs<BonusCardInfo[]>;
} catch (error:
| ResponseArgsError<ResponseArgsOfIEnumerableOfBonusCardInfoDTO>
| HttpErrorResponse
| Error
| unknown) {
this.#logger.error('Error fetching customer cards', error);
return [] as unknown as ResponseArgs<BonusCardInfo[]>;
}
}
}

View File

@@ -82,7 +82,7 @@ export class FilterControlsPanelComponent {
* Signal tracking whether the viewport is at tablet size or above.
* Used to determine responsive layout behavior for mobile vs desktop.
*/
mobileBreakpoint = breakpoint([Breakpoint.Tablet]);
mobileBreakpoint = breakpoint([Breakpoint.Tablet, Breakpoint.Desktop]);
/**
* Signal controlling the visibility of the order-by toolbar on mobile devices.