mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
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:
committed by
Lorenz Hilpert
parent
d9ccf68314
commit
334436c737
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from './item';
|
||||
export * from './price-value';
|
||||
export * from './price';
|
||||
export * from './product';
|
||||
export * from './query-settings';
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './models';
|
||||
export * from './services';
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './reward-checkout.service';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
1
libs/checkout/data-access/src/lib/store/index.ts
Normal file
1
libs/checkout/data-access/src/lib/store/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './reward-catalog.store';
|
||||
165
libs/checkout/data-access/src/lib/store/reward-catalog.store.ts
Normal file
165
libs/checkout/data-access/src/lib/store/reward-catalog.store.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
},
|
||||
})),
|
||||
);
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
@apply w-full;
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -16,7 +16,7 @@ export type ProductInfoItem = {
|
||||
| 'manufacturer'
|
||||
| 'publicationDate'
|
||||
>;
|
||||
redemptionPoints: number;
|
||||
redemptionPoints?: number;
|
||||
};
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -39,7 +39,5 @@ export const catchResponseArgsErrorPipe = <T>(): OperatorFunction<T, T> =>
|
||||
}
|
||||
}
|
||||
|
||||
return throwError(() =>
|
||||
err instanceof Error ? err : new Error(String(err)),
|
||||
);
|
||||
return throwError(() => err);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user