Merged PR 1969: Reward Shopping Cart Implementation with Navigation State Management and Shipping Address Integration

1. Reward Shopping Cart Implementation
  - New shopping cart with quantity control and availability checking
  - Responsive shopping cart item component with improved CSS styling
  - Shipping address integration in cart
  - Customer reward card and billing/shipping address components

  2. Navigation State Management Library (@isa/core/navigation)
  - New library with type-safe navigation context service (373 lines)
  - Navigation state service (287 lines) for temporary state between routes
  - Comprehensive test coverage (668 + 227 lines of tests)
  - Documentation (792 lines in README.md)
  - Replaces query parameters for passing temporary navigation context

  3. CRM Shipping Address Services
  - New ShippingAddressService with fetching and validation
  - CustomerShippingAddressResource and CustomerShippingAddressesResource
  - Zod schemas for data validation

  4. Additional Improvements
  - Enhanced searchbox accessibility with ARIA support
  - Availability data access rework for better fetching/mapping
  - Storybook tooltip variant support
  - Vitest JUnit and Cobertura reporting configuration

Related work items: #5382, #5383, #5384
This commit is contained in:
Lorenz Hilpert
2025-10-15 14:59:34 +00:00
committed by Nino Righi
parent f15848d5c0
commit 596ae1da1b
45 changed files with 3793 additions and 344 deletions

View File

@@ -4,3 +4,4 @@ export * from './country';
export * from './customer-type';
export * from './customer.model';
export * from './payer';
export * from './shipping-address.model';

View File

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

View File

@@ -0,0 +1,54 @@
import { effect, inject, Injectable, resource, signal } from '@angular/core';
import { CrmTabMetadataService, ShippingAddressService } from '../services';
import { TabService } from '@isa/core/tabs';
import { ShippingAddress } from '../models';
@Injectable()
export class CustomerShippingAddressResource {
#shippingAddressService = inject(ShippingAddressService);
#params = signal<{
shippingAddressId: number | undefined;
}>({
shippingAddressId: undefined,
});
params(params: { shippingAddressId?: number }) {
this.#params.update((p) => ({ ...p, ...params }));
}
readonly resource = resource({
params: () => this.#params(),
loader: async ({ params, abortSignal }): Promise<ShippingAddress | undefined> => {
if (!params.shippingAddressId) {
return undefined;
}
const res = await this.#shippingAddressService.fetchShippingAddress(
{
shippingAddressId: params.shippingAddressId,
},
abortSignal,
);
return res.result as ShippingAddress;
},
});
}
@Injectable()
export class SelectedCustomerShippingAddressResource extends CustomerShippingAddressResource {
#tabId = inject(TabService).activatedTabId;
#customerMetadata = inject(CrmTabMetadataService);
constructor() {
super();
effect(() => {
const tabId = this.#tabId();
const shippingAddressId = tabId
? this.#customerMetadata.selectedShippingAddressId(tabId)
: undefined;
this.params({ shippingAddressId });
});
}
}

View File

@@ -0,0 +1,58 @@
import { effect, inject, Injectable, resource, signal } from '@angular/core';
import { CrmTabMetadataService, ShippingAddressService } from '../services';
import { TabService } from '@isa/core/tabs';
import { ShippingAddress } from '../models';
@Injectable()
export class CustomerShippingAddressesResource {
#shippingAddressService = inject(ShippingAddressService);
#params = signal<{
customerId: number | undefined;
take?: number | null;
skip?: number | null;
}>({
customerId: undefined,
});
params(params: { customerId?: number; take?: number | null; skip?: number | null }) {
this.#params.update((p) => ({ ...p, ...params }));
}
readonly resource = resource({
params: () => this.#params(),
loader: async ({ params, abortSignal }): Promise<ShippingAddress[] | undefined> => {
if (!params.customerId) {
return undefined;
}
const res = await this.#shippingAddressService.fetchCustomerShippingAddresses(
{
customerId: params.customerId,
take: params.take,
skip: params.skip,
},
abortSignal,
);
return res.result as ShippingAddress[];
},
});
}
@Injectable()
export class SelectedCustomerShippingAddressesResource extends CustomerShippingAddressesResource {
#tabId = inject(TabService).activatedTabId;
#customerMetadata = inject(CrmTabMetadataService);
constructor() {
super();
effect(() => {
const tabId = this.#tabId();
const customerId = tabId
? this.#customerMetadata.selectedCustomerId(tabId)
: undefined;
this.params({ customerId });
});
}
}

View File

@@ -1,3 +1,5 @@
export * from './country.resource';
export * from './customer-bonus-cards.resource';
export * from './customer-shipping-address.resource';
export * from './customer-shipping-addresses.resource';
export * from './customer.resource';

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const FetchCustomerShippingAddressesSchema = z.object({
customerId: z.number().int(),
take: z.number().int().optional().nullable(),
skip: z.number().int().optional().nullable(),
});
export type FetchCustomerShippingAddresses = z.infer<typeof FetchCustomerShippingAddressesSchema>;
export type FetchCustomerShippingAddressesInput = z.input<typeof FetchCustomerShippingAddressesSchema>;

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const FetchShippingAddressSchema = z.object({
shippingAddressId: z.number().int(),
});
export type FetchShippingAddress = z.infer<typeof FetchShippingAddressSchema>;
export type FetchShippingAddressInput = z.input<typeof FetchShippingAddressSchema>;

View File

@@ -1,2 +1,4 @@
export * from './fetch-customer-cards.schema';
export * from './fetch-customer-shipping-addresses.schema';
export * from './fetch-customer.schema';
export * from './fetch-shipping-address.schema';

View File

@@ -1,3 +1,4 @@
export * from './country.service';
export * from './crm-search.service';
export * from './crm-tab-metadata.service';
export * from './shipping-address.service';

View File

@@ -0,0 +1,79 @@
import { inject, Injectable } from '@angular/core';
import { ShippingAddressService as GeneratedShippingAddressService } from '@generated/swagger/crm-api';
import {
FetchCustomerShippingAddressesInput,
FetchCustomerShippingAddressesSchema,
FetchShippingAddressInput,
FetchShippingAddressSchema,
} from '../schemas';
import {
catchResponseArgsErrorPipe,
ListResponseArgs,
ResponseArgs,
takeUntilAborted,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { ShippingAddress } from '../models';
import { logger } from '@isa/core/logging';
@Injectable({ providedIn: 'root' })
export class ShippingAddressService {
#shippingAddressService = inject(GeneratedShippingAddressService);
#logger = logger(() => ({
service: 'ShippingAddressService',
}));
async fetchCustomerShippingAddresses(
params: FetchCustomerShippingAddressesInput,
abortSignal?: AbortSignal,
): Promise<ListResponseArgs<ShippingAddress>> {
this.#logger.info('Fetching customer shipping addresses from API');
const { customerId, take, skip } =
FetchCustomerShippingAddressesSchema.parse(params);
let req$ = this.#shippingAddressService
.ShippingAddressGetShippingAddresses({ customerId, take, skip })
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched customer shipping addresses');
return res as ListResponseArgs<ShippingAddress>;
} catch (error) {
this.#logger.error('Error fetching customer shipping addresses', error);
return {
result: [],
totalCount: 0,
} as unknown as ListResponseArgs<ShippingAddress>;
}
}
async fetchShippingAddress(
params: FetchShippingAddressInput,
abortSignal?: AbortSignal,
): Promise<ResponseArgs<ShippingAddress>> {
this.#logger.info('Fetching shipping address from API');
const { shippingAddressId } = FetchShippingAddressSchema.parse(params);
let req$ = this.#shippingAddressService
.ShippingAddressGetShippingaddress(shippingAddressId)
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched shipping address');
return res as ResponseArgs<ShippingAddress>;
} catch (error) {
this.#logger.error('Error fetching shipping address', error);
return undefined as unknown as ResponseArgs<ShippingAddress>;
}
}
}