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

@@ -1,213 +1,224 @@
<shared-loader [loading]="fetching$ | async" background="true" spinnerSize="32">
<div class="overflow-scroll max-h-[calc(100vh-15rem)]">
<div class="customer-details-header grid grid-flow-row pb-6">
<div
class="customer-details-header-actions flex flex-row justify-end pt-4 px-4"
>
<page-customer-menu
[customerId]="customerId$ | async"
[processId]="processId$ | async"
[hasCustomerCard]="hasKundenkarte$ | async"
[showCustomerDetails]="false"
></page-customer-menu>
</div>
<div class="customer-details-header-body text-center -mt-3">
<h1 class="text-[1.625rem] font-bold">
{{ (isBusinessKonto$ | async) ? 'Firmendetails' : 'Kundendetails' }}
</h1>
<p>Sind Ihre Kundendaten korrekt?</p>
</div>
</div>
<div
class="customer-details-customer-type flex flex-row justify-between items-center bg-surface-2 text-surface-2-content h-14"
>
<div
class="pl-4 font-bold grid grid-flow-col justify-start items-center gap-2"
>
<shared-icon [icon]="customerType$ | async"></shared-icon>
<span>
{{ customerType$ | async }}
</span>
</div>
@if (showEditButton$ | async) {
@if (editRoute$ | async; as editRoute) {
<a
[routerLink]="editRoute.path"
[queryParams]="editRoute.queryParams"
[queryParamsHandling]="'merge'"
class="btn btn-label font-bold text-brand"
>
Bearbeiten
</a>
}
}
</div>
<div
class="customer-details-customer-main-data px-5 py-3 grid grid-flow-row gap-3"
>
<div class="flex flex-row">
<div class="data-label">Erstellungsdatum</div>
@if (created$ | async; as created) {
<div class="data-value">
{{ created | date: 'dd.MM.yyyy' }} |
{{ created | date: 'HH:mm' }} Uhr
</div>
}
</div>
<div class="flex flex-row">
<div class="data-label">Kundennummer</div>
<div class="data-value">{{ customerNumber$ | async }}</div>
</div>
@if (customerNumberDig$ | async; as customerNumberDig) {
<div class="flex flex-row">
<div class="data-label">Kundennummer-DIG</div>
<div class="data-value">{{ customerNumberDig }}</div>
</div>
}
@if (customerNumberBeeline$ | async; as customerNumberBeeline) {
<div class="flex flex-row">
<div class="data-label">Kundennummer-BEELINE</div>
<div class="data-value">{{ customerNumberBeeline }}</div>
</div>
}
</div>
@if (isBusinessKonto$ | async) {
<div class="customer-details-customer-main-row">
<div class="data-label">Firmenname</div>
<div class="data-value">{{ organisationName$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Abteilung</div>
<div class="data-value">{{ department$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">USt-ID</div>
<div class="data-value">{{ vatId$ | async }}</div>
</div>
}
<div class="customer-details-customer-main-row">
<div class="data-label">Anrede</div>
<div class="data-value">{{ gender$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Titel</div>
<div class="data-value">{{ title$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Nachname</div>
<div class="data-value">{{ lastName$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Vorname</div>
<div class="data-value">{{ firstName$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">E-Mail</div>
<div class="data-value">{{ email$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Straße</div>
<div class="data-value">{{ street$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Hausnr.</div>
<div class="data-value">{{ streetNumber$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">PLZ</div>
<div class="data-value">{{ zipCode$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Ort</div>
<div class="data-value">{{ city$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Adresszusatz</div>
<div class="data-value">{{ info$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Land</div>
@if (country$ | async; as country) {
<div class="data-value">{{ country | country }}</div>
}
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Festnetznr.</div>
<div class="data-value">{{ landline$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Mobilnr.</div>
<div class="data-value">{{ mobile$ | async }}</div>
</div>
@if (!(isBusinessKonto$ | async)) {
<div class="customer-details-customer-main-row">
<div class="data-label">Geburtstag</div>
<div class="data-value">
{{ dateOfBirth$ | async | date: 'dd.MM.yyyy' }}
</div>
</div>
}
@if (!(isBusinessKonto$ | async) && (organisationName$ | async)) {
<div class="customer-details-customer-main-row">
<div class="data-label">Firmenname</div>
<div class="data-value">{{ organisationName$ | async }}</div>
</div>
@if (!(isOnlineOrCustomerCardUser$ | async)) {
<div class="customer-details-customer-main-row">
<div class="data-label">Abteilung</div>
<div class="data-value">{{ department$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">USt-ID</div>
<div class="data-value">{{ vatId$ | async }}</div>
</div>
}
}
<page-details-main-view-billing-addresses></page-details-main-view-billing-addresses>
<page-details-main-view-delivery-addresses></page-details-main-view-delivery-addresses>
<div class="h-24"></div>
</div>
</shared-loader>
@if (!isRewardTab()) {
@if (shoppingCartHasNoItems$ | async) {
<button
type="button"
(click)="continue()"
class="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]="showLoader$ | async"
>
<shared-loader [loading]="showLoader$ | async" spinnerSize="32"
>Weiter zur Artikelsuche</shared-loader
>
</button>
}
@if (shoppingCartHasItems$ | async) {
<button
type="button"
(click)="continue()"
class="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]="showLoader$ | async"
>
<shared-loader [loading]="showLoader$ | async" spinnerSize="32"
>Weiter zum Warenkorb</shared-loader
>
</button>
}
} @else {
<button
type="button"
(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)"
>
<shared-loader [loading]="showLoader$ | async" spinnerSize="32"
>Auswählen</shared-loader
>
</button>
}
<shared-loader [loading]="fetching$ | async" background="true" spinnerSize="32">
<div class="overflow-scroll max-h-[calc(100vh-15rem)]">
<div class="customer-details-header grid grid-flow-row pb-6">
<div
class="customer-details-header-actions flex flex-row justify-end pt-4 px-4"
>
<page-customer-menu
[customerId]="customerId$ | async"
[processId]="processId$ | async"
[hasCustomerCard]="hasKundenkarte$ | async"
[showCustomerDetails]="false"
></page-customer-menu>
</div>
<div class="customer-details-header-body text-center -mt-3">
<h1 class="text-[1.625rem] font-bold">
{{ (isBusinessKonto$ | async) ? 'Firmendetails' : 'Kundendetails' }}
</h1>
<p>Sind Ihre Kundendaten korrekt?</p>
</div>
</div>
<div
class="customer-details-customer-type flex flex-row justify-between items-center bg-surface-2 text-surface-2-content h-14"
>
<div
class="pl-4 font-bold grid grid-flow-col justify-start items-center gap-2"
>
<shared-icon [icon]="customerType$ | async"></shared-icon>
<span>
{{ customerType$ | async }}
</span>
</div>
@if (showEditButton$ | async) {
@if (editRoute$ | async; as editRoute) {
<a
[routerLink]="editRoute.path"
[queryParams]="editRoute.queryParams"
[queryParamsHandling]="'merge'"
class="btn btn-label font-bold text-brand"
>
Bearbeiten
</a>
}
}
</div>
<div
class="customer-details-customer-main-data px-5 py-3 grid grid-flow-row gap-3"
>
<div class="flex flex-row">
<div class="data-label">Erstellungsdatum</div>
@if (created$ | async; as created) {
<div class="data-value">
{{ created | date: 'dd.MM.yyyy' }} |
{{ created | date: 'HH:mm' }} Uhr
</div>
}
</div>
<div class="flex flex-row">
<div class="data-label">Kundennummer</div>
<div class="data-value">{{ customerNumber$ | async }}</div>
</div>
@if (customerNumberDig$ | async; as customerNumberDig) {
<div class="flex flex-row">
<div class="data-label">Kundennummer-DIG</div>
<div class="data-value">{{ customerNumberDig }}</div>
</div>
}
@if (customerNumberBeeline$ | async; as customerNumberBeeline) {
<div class="flex flex-row">
<div class="data-label">Kundennummer-BEELINE</div>
<div class="data-value">{{ customerNumberBeeline }}</div>
</div>
}
</div>
@if (isBusinessKonto$ | async) {
<div class="customer-details-customer-main-row">
<div class="data-label">Firmenname</div>
<div class="data-value">{{ organisationName$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Abteilung</div>
<div class="data-value">{{ department$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">USt-ID</div>
<div class="data-value">{{ vatId$ | async }}</div>
</div>
}
<div class="customer-details-customer-main-row">
<div class="data-label">Anrede</div>
<div class="data-value">{{ gender$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Titel</div>
<div class="data-value">{{ title$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Nachname</div>
<div class="data-value">{{ lastName$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Vorname</div>
<div class="data-value">{{ firstName$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">E-Mail</div>
<div class="data-value">{{ email$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Straße</div>
<div class="data-value">{{ street$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Hausnr.</div>
<div class="data-value">{{ streetNumber$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">PLZ</div>
<div class="data-value">{{ zipCode$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Ort</div>
<div class="data-value">{{ city$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Adresszusatz</div>
<div class="data-value">{{ info$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Land</div>
@if (country$ | async; as country) {
<div class="data-value">{{ country | country }}</div>
}
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Festnetznr.</div>
<div class="data-value">{{ landline$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Mobilnr.</div>
<div class="data-value">{{ mobile$ | async }}</div>
</div>
@if (!(isBusinessKonto$ | async)) {
<div class="customer-details-customer-main-row">
<div class="data-label">Geburtstag</div>
<div class="data-value">
{{ dateOfBirth$ | async | date: 'dd.MM.yyyy' }}
</div>
</div>
}
@if (!(isBusinessKonto$ | async) && (organisationName$ | async)) {
<div class="customer-details-customer-main-row">
<div class="data-label">Firmenname</div>
<div class="data-value">{{ organisationName$ | async }}</div>
</div>
@if (!(isOnlineOrCustomerCardUser$ | async)) {
<div class="customer-details-customer-main-row">
<div class="data-label">Abteilung</div>
<div class="data-value">{{ department$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">USt-ID</div>
<div class="data-value">{{ vatId$ | async }}</div>
</div>
}
}
<page-details-main-view-billing-addresses></page-details-main-view-billing-addresses>
<page-details-main-view-delivery-addresses></page-details-main-view-delivery-addresses>
<div class="h-24"></div>
</div>
</shared-loader>
@if (hasReturnUrl()) {
<button
type="button"
(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)"
>
<shared-loader [loading]="showLoader$ | async" spinnerSize="32"
>Auswählen</shared-loader
>
</button>
} @else if (!isRewardTab()) {
@if (shoppingCartHasNoItems$ | async) {
<button
type="button"
(click)="continue()"
class="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]="showLoader$ | async"
>
<shared-loader [loading]="showLoader$ | async" spinnerSize="32"
>Weiter zur Artikelsuche</shared-loader
>
</button>
}
@if (shoppingCartHasItems$ | async) {
<button
type="button"
(click)="continue()"
class="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]="showLoader$ | async"
>
<shared-loader [loading]="showLoader$ | async" spinnerSize="32"
>Weiter zum Warenkorb</shared-loader
>
</button>
}
} @else {
<button
type="button"
(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)"
>
<shared-loader [loading]="showLoader$ | async" spinnerSize="32"
>Auswählen</shared-loader
>
</button>
}

View File

@@ -5,6 +5,7 @@ import {
OnDestroy,
inject,
linkedSignal,
signal,
} from '@angular/core';
import { Subject, combineLatest } from 'rxjs';
import { first, map, switchMap, takeUntil } from 'rxjs/operators';
@@ -39,6 +40,7 @@ import { injectTab } from '@isa/core/tabs';
import { toSignal } from '@angular/core/rxjs-interop';
import { CrmTabMetadataService, Customer } from '@isa/crm/data-access';
import { CustomerAdapter } from '@isa/checkout/data-access';
import { NavigationStateService } from '@isa/core/navigation';
export interface CustomerDetailsViewMainState {
isBusy: boolean;
@@ -68,12 +70,16 @@ export class CustomerDetailsViewMainComponent
private _router = inject(Router);
private _activatedRoute = inject(ActivatedRoute);
private _genderSettings = inject(GenderSettingsService);
private _navigationState = inject(NavigationStateService);
private _onDestroy$ = new Subject<void>();
customerService = inject(CrmCustomerService);
crmTabMetadataService = inject(CrmTabMetadataService);
tab = injectTab();
// Signal to track if return URL exists
hasReturnUrlSignal = signal(false);
fetching$ = combineLatest([
this._store.fetchingCustomer$,
this._store.fetchingCustomerList$,
@@ -81,6 +87,24 @@ export class CustomerDetailsViewMainComponent
map(([fetchingCustomer, fetchingList]) => fetchingCustomer || fetchingList),
);
async getReturnUrlFromContext(): Promise<string | null> {
// Get from preserved context (survives intermediate navigations, auto-scoped to tab)
const context = await this._navigationState.restoreContext<{
returnUrl?: string;
}>('select-customer');
return context?.returnUrl ?? null;
}
async checkHasReturnUrl(): Promise<void> {
const hasContext = await this._navigationState.hasPreservedContext('select-customer');
this.hasReturnUrlSignal.set(hasContext);
}
hasReturnUrl(): boolean {
return this.hasReturnUrlSignal();
}
isBusy$ = this.select((s) => s.isBusy);
showLoader$ = combineLatest([this.fetching$, this.isBusy$]).pipe(
@@ -294,6 +318,9 @@ export class CustomerDetailsViewMainComponent
});
ngOnInit() {
// Check if we have a return URL context
this.checkHasReturnUrl();
this.processId$
.pipe(
takeUntil(this._onDestroy$),
@@ -339,6 +366,18 @@ export class CustomerDetailsViewMainComponent
this.processId,
this.customer.id,
);
// Restore from preserved context (auto-scoped to current tab) and clean up
const context = await this._navigationState.restoreAndClearContext<{
returnUrl?: string;
}>('select-customer');
if (context?.returnUrl) {
await this._router.navigateByUrl(context.returnUrl);
return;
}
// No returnUrl found, navigate to default reward page
await this._router.navigate(['/', this.processId, 'reward']);
}
}
@@ -402,15 +441,7 @@ export class CustomerDetailsViewMainComponent
this._router.navigate(path);
}
try {
} catch (error) {
this._modalService.error(
'Warenkorb kann dem Kunden nicht zugewiesen werden',
error,
);
} finally {
this.setIsBusy(false);
}
this.setIsBusy(false);
}
@logAsync
@@ -475,7 +506,7 @@ export class CustomerDetailsViewMainComponent
}
return true;
} catch (error) {
} catch {
this._modalService.open({
content: MessageModalComponent,
title: 'Warenkorb kann dem Kunden nicht zugewiesen werden',
@@ -563,6 +594,10 @@ export class CustomerDetailsViewMainComponent
processId: this.processId,
customerDto: this.customer,
});
this.crmTabMetadataService.setSelectedCustomerId(
this.processId,
this.customer.id,
);
}
@log

View File

@@ -1,73 +1,84 @@
<div class="searchbox-input-wrapper">
<div class="searchbox-hint-wrapper">
<input
id="searchbox"
class="searchbox-input"
autocomplete="off"
#input
type="text"
[placeholder]="placeholder"
[(ngModel)]="query"
(ngModelChange)="setQuery($event, true, true)"
(focus)="clearHint(); focused.emit(true)"
(blur)="focused.emit(false)"
(keyup)="onKeyup($event)"
(keyup.enter)="
tracker.trackEvent({ action: 'keyup enter', name: 'search' })
"
matomoTracker
#tracker="matomo"
matomoCategory="searchbox"
/>
@if (showHint) {
<div class="searchbox-hint" (click)="focus()">
{{ hint }}
</div>
}
</div>
@if (input.value) {
<button
(click)="clear(); focus()"
tabindex="-1"
class="searchbox-clear-btn"
type="button"
>
<shared-icon icon="close" [size]="32"></shared-icon>
</button>
}
@if (!loading) {
@if (!showScannerButton) {
<button
tabindex="0"
class="searchbox-search-btn"
type="button"
(click)="emitSearch()"
[disabled]="completeValue !== query"
matomoClickAction="click"
matomoClickCategory="searchbox"
matomoClickName="search"
>
<ui-icon icon="search" size="1.5rem"></ui-icon>
</button>
}
@if (showScannerButton) {
<button
tabindex="0"
class="searchbox-scan-btn"
type="button"
(click)="startScan()"
matomoClickAction="open"
matomoClickCategory="searchbox"
matomoClickName="scanner"
>
<shared-icon icon="barcode-scan" [size]="32"></shared-icon>
</button>
}
}
@if (loading) {
<div class="searchbox-load-indicator">
<ui-icon icon="spinner" size="32px"></ui-icon>
</div>
}
</div>
<ng-content select="ui-autocomplete"></ng-content>
<div class="searchbox-input-wrapper" role="search">
<div class="searchbox-hint-wrapper">
<input
id="searchbox"
class="searchbox-input"
autocomplete="off"
#input
type="text"
[placeholder]="placeholder"
[(ngModel)]="query"
(ngModelChange)="setQuery($event, true, true)"
(focus)="clearHint(); focused.emit(true)"
(blur)="focused.emit(false)"
(keyup)="onKeyup($event)"
(keyup.enter)="
tracker.trackEvent({ action: 'keyup enter', name: 'search' })
"
matomoTracker
#tracker="matomo"
matomoCategory="searchbox"
aria-label="Search input"
aria-autocomplete="list"
[attr.aria-expanded]="autocomplete?.opend || null"
[attr.aria-controls]="autocomplete?.opend ? 'searchbox-autocomplete' : null"
[attr.aria-activedescendant]="autocomplete?.activeItem ? 'searchbox-item-' + autocomplete?.listKeyManager?.activeItemIndex : null"
[attr.aria-busy]="loading || null"
[attr.aria-describedby]="showHint ? 'searchbox-hint' : null"
/>
@if (showHint) {
<div id="searchbox-hint" class="searchbox-hint" (click)="focus()" aria-hidden="true">
{{ hint }}
</div>
}
</div>
@if (input.value) {
<button
(click)="clear(); focus()"
tabindex="-1"
class="searchbox-clear-btn"
type="button"
aria-label="Clear"
>
<shared-icon icon="close" [size]="32" aria-hidden="true"></shared-icon>
</button>
}
@if (!loading) {
@if (!showScannerButton) {
<button
tabindex="0"
class="searchbox-search-btn"
type="button"
(click)="emitSearch()"
[disabled]="completeValue !== query"
[attr.aria-disabled]="completeValue !== query || null"
matomoClickAction="click"
matomoClickCategory="searchbox"
matomoClickName="search"
aria-label="Search"
>
<ui-icon icon="search" size="1.5rem" aria-hidden="true"></ui-icon>
</button>
}
@if (showScannerButton) {
<button
tabindex="0"
class="searchbox-scan-btn"
type="button"
(click)="startScan()"
matomoClickAction="open"
matomoClickCategory="searchbox"
matomoClickName="scanner"
aria-label="Scan barcode"
>
<shared-icon icon="barcode-scan" [size]="32" aria-hidden="true"></shared-icon>
</button>
}
}
@if (loading) {
<div class="searchbox-load-indicator" role="status" aria-live="polite" aria-label="Loading search results">
<ui-icon icon="spinner" size="32px" aria-hidden="true"></ui-icon>
</div>
}
</div>
<ng-content select="ui-autocomplete"></ng-content>