Merged PR 2031: feat(crm): add customer bon redemption feature

feat(crm): add customer bon redemption feature

- New library @isa/crm/feature/customer-bon-redemption
- Implement bon validation and redemption flow
- Add SignalStore for state management
- Add resource pattern for reactive data loading
- Add facade for business logic abstraction
- Add Zod schemas for runtime validation
- Integrate with loyalty card API endpoints
- Add accessibility and E2E test attributes
- Remove mock provider (use real facade)
- Exclude generated swagger files from linting

Components:
- BonInputFieldComponent - input with validation
- BonDetailsDisplayComponent - shows validated bon
- BonRedemptionButtonComponent - redemption action

Data Access:
- CustomerBonRedemptionFacade - business logic
- CustomerBonCheckResource - reactive validation
- BonRedemptionStore - component state
- CrmSearchService - API integration (checkBon, addBon)

Issue: 5314

Related work items: #5314
This commit is contained in:
Lorenz Hilpert
2025-11-19 12:51:58 +00:00
committed by Nino Righi
parent 8c0de558a4
commit fc6d29d62f
43 changed files with 1358 additions and 49 deletions

View File

@@ -11,10 +11,17 @@
class="mt-4"
/>
@let cardCode = firstActiveCardCode();
@if (cardCode) {
<crm-customer-bon-redemption
[cardCode]="cardCode"
class="mt-4"
(redeemed)="reloadCardTransactions()"
/>
<crm-customer-booking [cardCode]="cardCode" class="mt-4" />
<crm-customer-card-transactions [cardCode]="cardCode" class="mt-8" />
}
<crm-customer-card-transactions [cardCode]="cardCode" class="mt-8" />
<utils-scroll-top-button
[target]="hostElement"
class="flex flex-col justify-self-end fixed bottom-6 right-6"

View File

@@ -5,6 +5,7 @@ import {
computed,
effect,
ElementRef,
OnDestroy,
} from '@angular/core';
import { CustomerSearchStore } from '../store';
import { ActivatedRoute } from '@angular/router';
@@ -14,8 +15,12 @@ import { CustomerMenuComponent } from '../../components/customer-menu';
import { CustomerLoyaltyCardsComponent } from '@isa/crm/feature/customer-loyalty-cards';
import { CrmFeatureCustomerCardTransactionsComponent } from '@isa/crm/feature/customer-card-transactions';
import { toSignal } from '@angular/core/rxjs-interop';
import { CustomerBonusCardsResource } from '@isa/crm/data-access';
import {
CustomerBonusCardsResource,
CustomerCardTransactionsResource,
} from '@isa/crm/data-access';
import { CrmFeatureCustomerBookingComponent } from '@isa/crm/feature/customer-booking';
import { CrmFeatureCustomerBonRedemptionComponent } from '@isa/crm/feature/customer-bon-redemption';
import { ScrollTopButtonComponent } from '@isa/utils/scroll-position';
@Component({
@@ -30,15 +35,18 @@ import { ScrollTopButtonComponent } from '@isa/utils/scroll-position';
CustomerLoyaltyCardsComponent,
CrmFeatureCustomerCardTransactionsComponent,
CrmFeatureCustomerBookingComponent,
CrmFeatureCustomerBonRedemptionComponent,
ScrollTopButtonComponent,
],
providers: [CustomerBonusCardsResource],
providers: [CustomerBonusCardsResource, CustomerCardTransactionsResource],
})
export class KundenkarteMainViewComponent {
export class KundenkarteMainViewComponent implements OnDestroy {
#reloadTimeoutId?: ReturnType<typeof setTimeout>;
private _store = inject(CustomerSearchStore);
private _activatedRoute = inject(ActivatedRoute);
private _bonusCardsResource = inject(CustomerBonusCardsResource);
#cardTransactionsResource = inject(CustomerCardTransactionsResource);
elementRef = inject(ElementRef);
get hostElement() {
@@ -74,4 +82,16 @@ export class KundenkarteMainViewComponent {
}
});
}
reloadCardTransactions() {
this.#reloadTimeoutId = setTimeout(() => {
this.#cardTransactionsResource.resource.reload();
}, 500);
}
ngOnDestroy(): void {
if (this.#reloadTimeoutId) {
clearTimeout(this.#reloadTimeoutId);
}
}
}

View File

@@ -10,6 +10,7 @@ module.exports = [
'**/dist',
'**/vite.config.*.timestamp*',
'**/vitest.config.*.timestamp*',
'**/generated/**',
],
},
// {

View File

@@ -106,6 +106,8 @@ export { KeyValueDTOOfStringAndInteger } from './models/key-value-dtoof-string-a
export { ResponseArgsOfKeyValueDTOOfStringAndString } from './models/response-args-of-key-value-dtoof-string-and-string';
export { ResponseArgsOfLoyaltyBookingInfoDTO } from './models/response-args-of-loyalty-booking-info-dto';
export { LoyaltyBookingValues } from './models/loyalty-booking-values';
export { ResponseArgsOfLoyaltyBonResponse } from './models/response-args-of-loyalty-bon-response';
export { LoyaltyBonResponse } from './models/loyalty-bon-response';
export { LoyaltyBonValues } from './models/loyalty-bon-values';
export { ResponseArgsOfPayerDTO } from './models/response-args-of-payer-dto';
export { ResponseArgsOfShippingAddressDTO } from './models/response-args-of-shipping-address-dto';

View File

@@ -0,0 +1,13 @@
/* tslint:disable */
export interface LoyaltyBonResponse {
/**
* Bon Datum
*/
date?: string;
/**
* Summe
*/
total?: number;
}

View File

@@ -0,0 +1,6 @@
/* tslint:disable */
import { ResponseArgs } from './response-args';
import { LoyaltyBonResponse } from './loyalty-bon-response';
export interface ResponseArgsOfLoyaltyBonResponse extends ResponseArgs{
result?: LoyaltyBonResponse;
}

View File

@@ -12,8 +12,9 @@ import { ResponseArgsOfLoyaltyBookingInfoDTO } from '../models/response-args-of-
import { LoyaltyBookingValues } from '../models/loyalty-booking-values';
import { ResponseArgsOfIEnumerableOfKeyValueDTOOfStringAndInteger } from '../models/response-args-of-ienumerable-of-key-value-dtoof-string-and-integer';
import { ResponseArgsOfKeyValueDTOOfStringAndString } from '../models/response-args-of-key-value-dtoof-string-and-string';
import { ResponseArgsOfBoolean } from '../models/response-args-of-boolean';
import { ResponseArgsOfLoyaltyBonResponse } from '../models/response-args-of-loyalty-bon-response';
import { LoyaltyBonValues } from '../models/loyalty-bon-values';
import { ResponseArgsOfBoolean } from '../models/response-args-of-boolean';
import { ResponseArgsOfNullableBoolean } from '../models/response-args-of-nullable-boolean';
@Injectable({
providedIn: 'root',
@@ -206,7 +207,7 @@ class LoyaltyCardService extends __BaseService {
*
* - `locale`:
*/
LoyaltyCardLoyaltyBonCheckResponse(params: LoyaltyCardService.LoyaltyCardLoyaltyBonCheckParams): __Observable<__StrictHttpResponse<ResponseArgsOfBoolean>> {
LoyaltyCardLoyaltyBonCheckResponse(params: LoyaltyCardService.LoyaltyCardLoyaltyBonCheckParams): __Observable<__StrictHttpResponse<ResponseArgsOfLoyaltyBonResponse>> {
let __params = this.newParams();
let __headers = new HttpHeaders();
let __body: any = null;
@@ -226,7 +227,7 @@ class LoyaltyCardService extends __BaseService {
return this.http.request<any>(req).pipe(
__filter(_r => _r instanceof HttpResponse),
__map((_r) => {
return _r as __StrictHttpResponse<ResponseArgsOfBoolean>;
return _r as __StrictHttpResponse<ResponseArgsOfLoyaltyBonResponse>;
})
);
}
@@ -240,9 +241,9 @@ class LoyaltyCardService extends __BaseService {
*
* - `locale`:
*/
LoyaltyCardLoyaltyBonCheck(params: LoyaltyCardService.LoyaltyCardLoyaltyBonCheckParams): __Observable<ResponseArgsOfBoolean> {
LoyaltyCardLoyaltyBonCheck(params: LoyaltyCardService.LoyaltyCardLoyaltyBonCheckParams): __Observable<ResponseArgsOfLoyaltyBonResponse> {
return this.LoyaltyCardLoyaltyBonCheckResponse(params).pipe(
__map(_r => _r.body as ResponseArgsOfBoolean)
__map(_r => _r.body as ResponseArgsOfLoyaltyBonResponse)
);
}

View File

@@ -5,3 +5,4 @@ export * from './lib/resources';
export * from './lib/helpers';
export * from './lib/schemas';
export * from './lib/services';
export * from './lib/stores';

View File

@@ -0,0 +1,88 @@
import { Injectable } from '@angular/core';
import { ResponseArgs } from '@isa/common/data-access';
import { LoyaltyBonResponse } from '@generated/swagger/crm-api';
/**
* Mock implementation of CustomerBonRedemptionFacade for UI testing without backend.
*
* Test Bon numbers:
* - "123456789" - Valid Bon with data
* - "987654321" - Valid Bon with different data
* - "111111111" - Valid Bon with high total
* - Any other number - Returns "Keine verpunktung möglich"
*
* @example
* // In component for testing, provide the mock:
* providers: [
* { provide: CustomerBonRedemptionFacade, useClass: CustomerBonRedemptionFacadeMock }
* ]
*/
@Injectable()
export class CustomerBonRedemptionFacadeMock {
/**
* Mock Bon data for testing
*/
private readonly mockBonData: Record<string, LoyaltyBonResponse> = {
'123456789': {
date: '23.05.2025',
total: 76.12,
},
'987654321': {
date: '15.04.2025',
total: 42.5,
},
'111111111': {
date: '01.01.2025',
total: 150.0,
},
};
/**
* Simulate API delay (300ms)
*/
private async simulateDelay(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 300));
}
/**
* Mock check Bon implementation
*/
async checkBon(params: {
cardCode: string;
bonNr: string;
storeId?: string;
}): Promise<ResponseArgs<LoyaltyBonResponse>> {
await this.simulateDelay();
const bonData = this.mockBonData[params.bonNr];
if (bonData) {
return {
result: bonData,
error: false,
message: 'Bon gefunden',
};
}
// Return empty result for unknown Bon numbers (triggers "Keine verpunktung möglich")
return {
result: undefined,
error: false,
message: 'Keine verpunktung möglich',
};
}
/**
* Mock add Bon implementation
*/
async addBon(_params: {
cardCode: string;
bonNr: string;
storeId?: string;
}): Promise<boolean> {
await this.simulateDelay();
// Always succeed for mock
return true;
}
}

View File

@@ -0,0 +1,56 @@
import { inject, Injectable } from '@angular/core';
import { CrmSearchService } from '../services/crm-search.service';
import { CheckBonInput, AddBonInput } from '../schemas';
import { LoyaltyBonResponse } from '@generated/swagger/crm-api';
import { ResponseArgs } from '@isa/common/data-access';
/**
* Facade for customer Bon redemption operations.
*
* Provides a simplified API for validating and redeeming customer receipts (Bons)
* for loyalty points.
*/
@Injectable({
providedIn: 'root',
})
export class CustomerBonRedemptionFacade {
#crmSearchService = inject(CrmSearchService);
/**
* Check/validate a Bon number
*
* @param params - Bon validation parameters
* @param abortSignal - Optional abort signal for cancellation
* @returns Response with Bon details (date, total)
*/
async checkBon(
params: CheckBonInput,
abortSignal?: AbortSignal,
): Promise<ResponseArgs<LoyaltyBonResponse>> {
// Fetch store ID if not provided
if (!params.storeId) {
const store =
await this.#crmSearchService.fetchCurrentBookingPartnerStore();
params = { ...params, storeId: store?.key };
}
return this.#crmSearchService.checkBon(params, abortSignal);
}
/**
* Redeem/add a Bon for customer points
*
* @param params - Bon redemption parameters
* @returns True if redemption successful, false otherwise
*/
async addBon(params: AddBonInput): Promise<boolean> {
// Fetch store ID if not provided
if (!params.storeId) {
const store =
await this.#crmSearchService.fetchCurrentBookingPartnerStore();
params = { ...params, storeId: store?.key };
}
return this.#crmSearchService.addBon(params);
}
}

View File

@@ -1,3 +1,5 @@
export * from './customer-cards.facade';
export * from './customer.facade';
export * from './customer-card-booking.facade';
export * from './customer-bon-redemption.facade';
export * from './customer-bon-redemption.facade.mock';

View File

@@ -0,0 +1,72 @@
import { Injectable, inject, resource, signal, computed } from '@angular/core';
import { logger } from '@isa/core/logging';
import { CustomerBonRedemptionFacade } from '../facades/customer-bon-redemption.facade';
import { LoyaltyBonResponse } from '@generated/swagger/crm-api';
/**
* Resource for checking/validating Bon numbers.
*
* Provides reactive loading of Bon details for validation.
* Parameters can be updated via `params()` method to trigger validation.
*
* **Note:** Provide at component level, not root.
*/
@Injectable()
export class CustomerBonCheckResource {
readonly #bonFacade = inject(CustomerBonRedemptionFacade);
readonly #logger = logger(() => ({ context: 'CustomerBonCheckResource' }));
readonly #cardCode = signal<string | undefined>(undefined);
readonly #bonNr = signal<string | undefined>(undefined);
/**
* Resource that validates Bon based on current parameters.
*/
readonly resource = resource({
params: computed(() => ({
cardCode: this.#cardCode(),
bonNr: this.#bonNr(),
})),
loader: async ({
params,
abortSignal,
}): Promise<LoyaltyBonResponse | undefined> => {
const { cardCode, bonNr } = params;
if (!cardCode || !bonNr) {
return undefined;
}
this.#logger.debug('Checking Bon', () => ({ cardCode, bonNr }));
const response = await this.#bonFacade.checkBon(
{ cardCode, bonNr },
abortSignal,
);
this.#logger.debug('Bon checked', () => ({
bonNr,
found: !!response?.result,
}));
return response?.result;
},
defaultValue: undefined,
});
/**
* Update parameters to trigger Bon validation.
*/
params(params: { cardCode: string; bonNr: string }): void {
this.#cardCode.set(params.cardCode);
this.#bonNr.set(params.bonNr);
}
/**
* Reset the resource state.
*/
reset(): void {
this.#cardCode.set(undefined);
this.#bonNr.set(undefined);
}
}

View File

@@ -27,7 +27,7 @@ import { LoyaltyBookingInfoDTO } from '@generated/swagger/crm-api';
* }
* ```
*/
@Injectable({ providedIn: 'root' })
@Injectable()
export class CustomerCardTransactionsResource {
readonly #crmSearchService = inject(CrmSearchService);
readonly #logger = logger(() => ({

View File

@@ -8,3 +8,4 @@ export * from './customer-shipping-addresses.resource';
export * from './customer.resource';
export * from './payer.resource';
export * from './customer-booking-reasons.resource';
export * from './customer-bon-check.resource';

View File

@@ -0,0 +1,25 @@
import { z } from 'zod';
/**
* Schema for checking/validating a Bon
*/
export const CheckBonSchema = z.object({
cardCode: z.string().min(1, 'Karten-Code ist erforderlich'),
bonNr: z.string().min(1, 'Bon-Nummer ist erforderlich'),
storeId: z.string().optional(),
});
export type CheckBon = z.infer<typeof CheckBonSchema>;
export type CheckBonInput = z.input<typeof CheckBonSchema>;
/**
* Schema for redeeming a Bon
*/
export const AddBonSchema = z.object({
cardCode: z.string().min(1, 'Karten-Code ist erforderlich'),
bonNr: z.string().min(1, 'Bon-Nummer ist erforderlich'),
storeId: z.string().optional(),
});
export type AddBon = z.infer<typeof AddBonSchema>;
export type AddBonInput = z.input<typeof AddBonSchema>;

View File

@@ -18,3 +18,4 @@ export * from './payment-settings.schema';
export * from './shipping-address.schema';
export * from './user.schema';
export * from './add-booking.schema';
export * from './bon-redemption.schema';

View File

@@ -5,11 +5,15 @@ import {
LoyaltyBookingInfoDTO,
KeyValueDTOOfStringAndString,
KeyValueDTOOfStringAndInteger,
LoyaltyBonResponse,
} from '@generated/swagger/crm-api';
import {
AddBooking,
AddBookingInput,
AddBookingSchema,
CheckBonInput,
CheckBonSchema,
AddBonInput,
AddBonSchema,
Customer,
FetchCustomerCardsInput,
FetchCustomerCardsSchema,
@@ -22,6 +26,7 @@ import {
ResponseArgsError,
takeUntilAborted,
} from '@isa/common/data-access';
import { Cache, CacheTimeToLive } from '@isa/common/decorators';
import { firstValueFrom } from 'rxjs';
import { BonusCardInfo } from '../models';
import { logger } from '@isa/core/logging';
@@ -135,6 +140,7 @@ export class CrmSearchService {
}
}
@Cache({ ttl: CacheTimeToLive.oneHour })
async fetchCurrentBookingPartnerStore(
abortSignal?: AbortSignal,
): Promise<KeyValueDTOOfStringAndString | undefined> {
@@ -183,4 +189,66 @@ export class CrmSearchService {
return res?.result;
}
/**
* Check/validate a Bon number
*/
async checkBon(
params: CheckBonInput,
abortSignal?: AbortSignal,
): Promise<ResponseArgs<LoyaltyBonResponse>> {
this.#logger.info('Checking Bon from API');
const { cardCode, bonNr, storeId } = CheckBonSchema.parse(params);
let req$ = this.#loyaltyCardService
.LoyaltyCardLoyaltyBonCheck({
cardCode,
payload: { bonNr, storeId },
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully checked Bon');
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Bon check failed', err);
throw err;
}
return res as ResponseArgs<LoyaltyBonResponse>;
} catch (error) {
this.#logger.error('Error checking Bon', error);
throw error;
}
}
/**
* Redeem/add a Bon for customer points
*/
async addBon(params: AddBonInput): Promise<boolean> {
this.#logger.info('Redeeming Bon from API');
const { cardCode, bonNr, storeId } = AddBonSchema.parse(params);
const req$ = this.#loyaltyCardService.LoyaltyCardLoyaltyBonAdd({
cardCode,
payload: { bonNr, storeId },
});
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Bon redemption failed', err);
throw err;
}
this.#logger.debug('Successfully redeemed Bon');
return res?.result ?? false;
}
}

View File

@@ -0,0 +1,169 @@
import { computed } from '@angular/core';
import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
import { patchState } from '@ngrx/signals';
/**
* Validated Bon data structure
*
* Maps to LoyaltyBonResponse from @generated/swagger/crm-api
*/
export interface ValidatedBon {
bonNumber: string;
date: string;
total: number;
}
/**
* State for Bon redemption feature
*/
interface BonRedemptionState {
/**
* Current Bon number input by user
*/
bonNumber: string;
/**
* Whether Bon validation is in progress
*/
isValidating: boolean;
/**
* Whether Bon redemption is in progress
*/
isRedeeming: boolean;
/**
* Tracks if validation was attempted (button clicked)
*/
validationAttempted: boolean;
/**
* Validated Bon data after successful validation
*/
validatedBon: ValidatedBon | undefined;
/**
* Error message from validation or redemption
*/
errorMessage: string | undefined;
}
const initialState: BonRedemptionState = {
bonNumber: '',
isValidating: false,
isRedeeming: false,
validationAttempted: false,
validatedBon: undefined,
errorMessage: undefined,
};
/**
* SignalStore for managing Bon redemption state.
*
* Component-scoped store (provided in component providers).
*
* @example
* ```typescript
* @Component({
* providers: [BonRedemptionStore]
* })
* export class CrmFeatureCustomerBonRedemptionComponent {
* store = inject(BonRedemptionStore);
* }
* ```
*/
export const BonRedemptionStore = signalStore(
withState(initialState),
withComputed((store) => ({
/**
* Whether the search button should be disabled
*/
disableSearch: computed(
() =>
store.isValidating() ||
store.isRedeeming() ||
!store.bonNumber().trim(),
),
/**
* Whether the redemption button should be disabled
*/
disableRedemption: computed(() => {
const bon = store.validatedBon();
return store.isRedeeming() || store.isValidating() || !bon;
}),
/**
* Whether a valid Bon is currently loaded
*/
hasValidBon: computed(() => {
const bon = store.validatedBon();
return !!bon;
}),
/**
* Whether there is an error message
*/
hasError: computed(() => {
const error = store.errorMessage();
return !!error;
}),
})),
withMethods((store) => ({
/**
* Update the Bon number input
*/
setBonNumber(bonNumber: string): void {
patchState(store, {
bonNumber,
errorMessage: undefined,
validatedBon: undefined,
validationAttempted: false,
});
},
/**
* Set validation loading state
*/
setValidating(isValidating: boolean): void {
patchState(store, { isValidating });
},
/**
* Set redemption loading state
*/
setRedeeming(isRedeeming: boolean): void {
patchState(store, { isRedeeming });
},
/**
* Mark that validation was attempted
*/
setValidationAttempted(validationAttempted: boolean): void {
patchState(store, { validationAttempted });
},
/**
* Set validated Bon data
*/
setValidatedBon(validatedBon: ValidatedBon | undefined): void {
patchState(store, { validatedBon, errorMessage: undefined });
},
/**
* Set error message
*/
setError(errorMessage: string | undefined): void {
patchState(store, { errorMessage, validatedBon: undefined });
},
/**
* Reset store to initial state
*/
reset(): void {
patchState(store, initialState);
},
})),
);

View File

@@ -0,0 +1 @@
export * from './bon-redemption.store';

View File

@@ -0,0 +1,7 @@
# crm-feature-customer-bon-redemption
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test crm-feature-customer-bon-redemption` to execute the unit tests.

View File

@@ -0,0 +1,34 @@
const nx = require('@nx/eslint-plugin');
const baseConfig = require('../../../../eslint.config.js');
module.exports = [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'crm',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'crm',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,20 @@
{
"name": "crm-feature-customer-bon-redemption",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/crm/feature/customer-bon-redemption/src",
"prefix": "crm",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../../../coverage/libs/crm/feature/customer-bon-redemption"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1 @@
export * from './lib/crm-feature-customer-bon-redemption/crm-feature-customer-bon-redemption.component';

View File

@@ -0,0 +1,32 @@
<!-- Validated Bon Details -->
@if (store.validatedBon(); as bon) {
<div
class="mb-4 max-w-56"
data-what="bon-details"
[attr.data-which]="bon.bonNumber"
role="region"
aria-label="Bon Details"
>
<div class="flex justify-between items-center py-1">
<span class="isa-text-body-2-regular text-isa-neutral-600">Bon Datum</span>
<span
class="isa-text-body-2-bold text-isa-black"
data-what="bon-date"
[attr.data-which]="bon.bonNumber"
>
{{ bon.date }}
</span>
</div>
<div class="flex justify-between items-center py-1">
<span class="isa-text-body-2-regular text-isa-neutral-600">Summe</span>
<span
class="isa-text-body-2-bold text-isa-black"
data-what="bon-total"
[attr.data-which]="bon.bonNumber"
>
{{ bon.total | number: '1.2-2' : 'de-DE' }} EUR
</span>
</div>
</div>
}

View File

@@ -0,0 +1,28 @@
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { DecimalPipe } from '@angular/common';
import { BonRedemptionStore } from '@isa/crm/data-access';
/**
* Smart component for displaying validated Bon details.
*
* Injects BonRedemptionStore to access validated Bon data.
*
* Shows:
* - Bon date
* - Total amount
*
* @example
* <crm-bon-details-display />
*/
@Component({
selector: 'crm-bon-details-display',
imports: [DecimalPipe],
templateUrl: './bon-details-display.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BonDetailsDisplayComponent {
/**
* Store for accessing validated Bon data
*/
readonly store = inject(BonRedemptionStore);
}

View File

@@ -0,0 +1,65 @@
<!-- Input Section -->
<div class="mb-4">
<ui-text-field size="large" class="w-full bg-isa-neutral-200">
<input
class="bg-transparent"
#bonInput
#bonInputControl="ngModel"
type="text"
uiInputControl
[ngModel]="store.bonNumber()"
(ngModelChange)="onBonNumberChange($event)"
required
placeholder="Bon Nummer / Rechnungs-Nummer (HUG.de)*"
data-what="bon-number-input"
data-which="customer-loyalty"
aria-label="Bon Nummer eingeben"
aria-required="true"
[attr.aria-invalid]="store.errorMessage() ? 'true' : null"
[attr.aria-describedby]="store.errorMessage() ? 'bon-error' : null"
(keydown.enter)="onValidate()"
/>
@if (store.bonNumber().trim()) {
<ui-text-field-clear (click)="onReset()"></ui-text-field-clear>
}
<button
uiTextButton
color="strong"
size="small"
(click)="onValidate()"
[pending]="store.isValidating()"
[disabled]="bonInputControl.invalid || !cardCode()"
data-what="validate-bon-button"
data-which="customer-loyalty"
aria-label="Bon suchen und validieren"
>
Bon suchen
</button>
</ui-text-field>
</div>
<!-- Success or Error Message -->
@if (store.hasValidBon()) {
<div
class="flex items-center gap-2 py-2 mb-4 text-isa-accent-green isa-text-body-2-bold"
role="status"
aria-live="polite"
data-what="bon-success-message"
data-which="customer-loyalty"
>
<span aria-hidden="true"></span>
<span>Bon gefunden</span>
</div>
} @else if (store.errorMessage()) {
<div
id="bon-error"
class="py-2 mb-4 text-isa-accent-red isa-text-body-2-bold"
role="alert"
aria-live="polite"
data-what="bon-error-message"
data-which="customer-loyalty"
>
{{ store.errorMessage() }}
</div>
}

View File

@@ -0,0 +1,110 @@
import {
Component,
input,
output,
viewChild,
ElementRef,
effect,
inject,
ChangeDetectionStrategy,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TextButtonComponent } from '@isa/ui/buttons';
import {
TextFieldComponent,
InputControlDirective,
TextFieldClearComponent,
} from '@isa/ui/input-controls';
import { BonRedemptionStore } from '@isa/crm/data-access';
/**
* Smart component for Bon number input field.
*
* Injects BonRedemptionStore for state management.
*
* Features:
* - Input field with validation
* - Search button (when no result) or clear button (when result/error)
* - Success/error message display
* - Enter key support for validation
*
* @example
* <crm-bon-input-field
* [cardCode]="cardCode"
* (validate)="onValidate()"
* (clearForm)="onClear()" />
*/
@Component({
selector: 'crm-bon-input-field',
imports: [
FormsModule,
TextButtonComponent,
TextFieldComponent,
TextFieldClearComponent,
InputControlDirective,
],
templateUrl: './bon-input-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BonInputFieldComponent {
/**
* Store for accessing state
*/
readonly store = inject(BonRedemptionStore);
/**
* Active card code (required for validation)
*/
readonly cardCode = input<string | undefined>(undefined);
/**
* Emits when validation should be triggered
*/
readonly validate = output<void>();
/**
* Emits when form should be cleared/reset
*/
readonly clearForm = output<void>();
/**
* Reference to the input element for focusing
*/
inputRef = viewChild.required<ElementRef<HTMLInputElement>>('bonInput');
/**
* Effect to focus input when reset is called
*/
constructor() {
effect(() => {
// Focus when both error and validBon are false (after reset)
if (!this.store.hasError() && !this.store.hasValidBon()) {
const inputEl = this.inputRef()?.nativeElement;
if (inputEl && document.activeElement !== inputEl) {
setTimeout(() => inputEl.focus(), 0);
}
}
});
}
/**
* Handle Bon number input changes
*/
onBonNumberChange(value: string): void {
this.store.setBonNumber(value);
}
/**
* Handle validation trigger
*/
onValidate(): void {
this.validate.emit();
}
/**
* Handle reset/clear action
*/
onReset(): void {
this.clearForm.emit();
}
}

View File

@@ -0,0 +1,14 @@
<!-- Redemption Button (Always Visible) -->
<button
uiButton
color="primary"
size="large"
(click)="onRedeem()"
[pending]="store.isRedeeming()"
[disabled]="disabled()"
data-what="redeem-bon-button"
[attr.data-which]="store.validatedBon()?.bonNumber"
aria-label="Bon für Kunden verbuchen"
>
Für Kunden verbuchen
</button>

View File

@@ -0,0 +1,51 @@
import {
Component,
input,
output,
inject,
ChangeDetectionStrategy,
} from '@angular/core';
import { ButtonComponent } from '@isa/ui/buttons';
import { BonRedemptionStore } from '@isa/crm/data-access';
/**
* Smart component for Bon redemption action button.
*
* Injects BonRedemptionStore for state management.
*
* Displays "Für Kunden verbuchen" button with pending state.
*
* @example
* <crm-bon-redemption-button
* [disabled]="disabled"
* (redeem)="onRedeem()" />
*/
@Component({
selector: 'crm-bon-redemption-button',
imports: [ButtonComponent],
templateUrl: './bon-redemption-button.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BonRedemptionButtonComponent {
/**
* Store for accessing state
*/
readonly store = inject(BonRedemptionStore);
/**
* Whether the button should be disabled (from parent)
*/
readonly disabled = input<boolean>(false);
/**
* Emits when redemption should be triggered
*/
readonly redeem = output<void>();
/**
* Handle redemption button click
*/
onRedeem(): void {
this.redeem.emit();
}
}

View File

@@ -0,0 +1,4 @@
/* Scoped element selector for text field */
.bon-input-section ui-text-field {
flex: 1;
}

View File

@@ -0,0 +1,23 @@
<div
class="bg-isa-neutral-200 rounded-2xl p-6 mt-4"
data-what="bon-redemption-container"
data-which="customer-loyalty"
>
<h3 class="isa-text-body-1-bold mb-4">Bon nachträglich bepunkten</h3>
<!-- Input Field with Search/Clear Button and Validation Messages -->
<crm-bon-input-field
[cardCode]="cardCode()"
(validate)="validateBon()"
(clearForm)="resetForm()"
/>
<!-- Validated Bon Details -->
<crm-bon-details-display />
<!-- Redemption Button (Always Visible) -->
<crm-bon-redemption-button
[disabled]="disableRedemption()"
(redeem)="redeemBon()"
/>
</div>

View File

@@ -0,0 +1,21 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CrmFeatureCustomerBonRedemptionComponent } from './crm-feature-customer-bon-redemption.component';
describe('CrmFeatureCustomerBonRedemptionComponent', () => {
let component: CrmFeatureCustomerBonRedemptionComponent;
let fixture: ComponentFixture<CrmFeatureCustomerBonRedemptionComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CrmFeatureCustomerBonRedemptionComponent],
}).compileComponents();
fixture = TestBed.createComponent(CrmFeatureCustomerBonRedemptionComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,225 @@
import {
ChangeDetectionStrategy,
Component,
input,
inject,
computed,
effect,
output,
} from '@angular/core';
import {
injectFeedbackDialog,
injectFeedbackErrorDialog,
} from '@isa/ui/dialog';
import { logger } from '@isa/core/logging';
import {
CustomerBonRedemptionFacade,
CustomerBonCheckResource,
BonRedemptionStore,
} from '@isa/crm/data-access';
import { ResponseArgsError } from '@isa/common/data-access';
import { BonInputFieldComponent } from './components/bon-input-field/bon-input-field.component';
import { BonDetailsDisplayComponent } from './components/bon-details-display/bon-details-display.component';
import { BonRedemptionButtonComponent } from './components/bon-redemption-button/bon-redemption-button.component';
/**
* Component for redeeming customer receipts (Bon) for loyalty points.
*
* Allows users to:
* 1. Enter a Bon number
* 2. Validate the Bon exists and is valid
* 3. View Bon summary (date, total)
* 4. Redeem the Bon for customer points
*
* @example
* <crm-customer-bon-redemption [cardCode]="activeCardCode()" />
*/
@Component({
selector: 'crm-customer-bon-redemption',
imports: [
BonInputFieldComponent,
BonDetailsDisplayComponent,
BonRedemptionButtonComponent,
],
providers: [BonRedemptionStore, CustomerBonCheckResource],
templateUrl: './crm-feature-customer-bon-redemption.component.html',
styleUrl: './crm-feature-customer-bon-redemption.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CrmFeatureCustomerBonRedemptionComponent {
#logger = logger(() => ({
component: 'CrmFeatureCustomerBonRedemptionComponent',
cardCode: this.cardCode(),
}));
#bonCheckResource = inject(CustomerBonCheckResource);
#bonFacade = inject(CustomerBonRedemptionFacade);
#errorFeedbackDialog = injectFeedbackErrorDialog();
#feedbackDialog = injectFeedbackDialog();
/**
* Store for managing Bon redemption state
*/
readonly store = inject(BonRedemptionStore);
/**
* Active loyalty card code for the customer
*/
readonly cardCode = input<string | undefined>(undefined);
/**
* Computed: Disable redemption if no card code
*/
readonly disableRedemption = computed(
() => this.store.disableRedemption() || !this.cardCode(),
);
readonly redeemed = output<void>();
/**
* Constructor - sets up effects to sync resource state with store
*/
constructor() {
// Sync resource loading state with store
effect(() => {
const isLoading = this.#bonCheckResource.resource.isLoading();
this.store.setValidating(isLoading);
});
// Sync resource results with store
effect(() => {
const result = this.#bonCheckResource.resource.value();
const error = this.#bonCheckResource.resource.error();
const isLoading = this.#bonCheckResource.resource.isLoading();
const validationAttempted = this.store.validationAttempted();
// Only process results if validation was attempted
if (!validationAttempted || isLoading) {
return;
}
// Handle validation result
if (result) {
this.store.setValidatedBon({
bonNumber: this.store.bonNumber(),
date: result.date ?? '',
total: result.total ?? 0,
});
}
// Check if validation returned no result
else if (!error && !result) {
this.store.setError('Keine verpunktung möglich');
}
// Handle API errors
else if (error) {
let errorMsg = 'Bon-Validierung fehlgeschlagen';
if (error instanceof ResponseArgsError) {
errorMsg = error.message || errorMsg;
} else if (error instanceof Error) {
errorMsg = error.message;
}
this.store.setError(errorMsg);
}
});
}
/**
* Validate the entered Bon number
*/
validateBon(): void {
const cardCode = this.cardCode();
const bonNr = this.store.bonNumber().trim();
if (!cardCode || !bonNr) {
this.#logger.warn(
'Cannot validate Bon: missing required parameters',
() => ({
hasCardCode: !!cardCode,
hasBonNr: !!bonNr,
}),
);
return;
}
this.#logger.debug('Triggering Bon validation', () => ({
cardCode,
bonNr,
}));
this.store.setValidationAttempted(true);
this.#bonCheckResource.params({ cardCode, bonNr });
}
/**
* Redeem the validated Bon for customer points
*/
async redeemBon() {
this.store.setRedeeming(true);
try {
const cardCode = this.cardCode();
const bonNr = this.store.bonNumber().trim();
const validatedBon = this.store.validatedBon();
if (!cardCode) {
throw new Error('Kein Karten-Code vorhanden');
}
if (!bonNr || !validatedBon) {
throw new Error('Bon muss zuerst validiert werden');
}
this.#logger.debug('Redeeming Bon', () => ({ cardCode, bonNr }));
const success = await this.#bonFacade.addBon({ cardCode, bonNr });
if (!success) {
throw new Error('Bon-Einlösung fehlgeschlagen');
}
this.#logger.info('Bon redeemed successfully', () => ({
bonNr,
total: validatedBon.total,
}));
this.#feedbackDialog({
data: {
message: 'Bon wurde erfolgreich gebucht',
autoClose: true,
autoCloseDelay: 10000,
},
});
// Reset form
this.resetForm();
this.redeemed.emit();
} catch (error: unknown) {
this.#logger.error('Bon redemption failed', error as Error, () => ({
bonNr: this.store.bonNumber(),
}));
let errorMsg = 'Bon-Einlösung fehlgeschlagen';
if (error instanceof ResponseArgsError) {
errorMsg = error.message || errorMsg;
} else if (error instanceof Error) {
errorMsg = error.message;
}
this.#errorFeedbackDialog({
data: {
errorMessage: errorMsg,
},
});
} finally {
this.store.setRedeeming(false);
}
}
/**
* Reset the form to initial state
*/
resetForm(): void {
this.store.reset();
this.#bonCheckResource.reset();
}
}

View File

@@ -0,0 +1,13 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
import { getTestBed } from '@angular/core/testing';
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting(),
);

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"importHelpers": true,
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -0,0 +1,35 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default
// @ts-expect-error - Vitest reporter tuple types have complex inference issues
defineConfig(() => ({
root: __dirname,
cacheDir:
'../../../../node_modules/.vite/libs/crm/feature/customer-bon-redemption',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: [
'default',
['junit', { outputFile: '../../../../testresults/junit-crm-feature-customer-bon-redemption.xml' }],
],
coverage: {
reportsDirectory:
'../../../../coverage/libs/crm/feature/customer-bon-redemption',
provider: 'v8' as const,
reporter: ['text', 'cobertura'],
},
},
}));

View File

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

View File

@@ -28,7 +28,7 @@
/>
<!-- Cards carousel -->
<crm-customer-cards-carousel [cards]="cardList" class="max-w-[657px]" />
<crm-customer-cards-carousel [cards]="cardList" class="w-full" />
<!-- Action buttons (TODO: implement) -->
<div class="flex gap-4" data-what="card-actions">

View File

@@ -1,5 +1,5 @@
.ui-text-field-clear {
ui-icon-button {
@apply text-isa-neutral-900;
}
}
.ui-text-field-clear {
ui-icon-button {
@apply text-isa-neutral-900 bg-transparent;
}
}

View File

@@ -1,31 +1,31 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { TextFieldComponent } from './text-field.component';
import { provideIcons } from '@ng-icons/core';
import { isaActionClose } from '@isa/icons';
import { IconButtonComponent } from '@isa/ui/buttons';
@Component({
selector: 'ui-text-field-clear',
templateUrl: './text-field-clear.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
host: {
'[class]': '["ui-text-field-clear", sizeClass()]',
},
providers: [provideIcons({ isaActionClose })],
imports: [IconButtonComponent],
})
export class TextFieldClearComponent {
hostComponent = inject(TextFieldComponent, { host: true });
size = this.hostComponent.size;
sizeClass = computed(() => {
return `ui-text-field-clear__${this.size()}`;
});
}
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { TextFieldComponent } from './text-field.component';
import { provideIcons } from '@ng-icons/core';
import { isaActionClose } from '@isa/icons';
import { IconButtonComponent } from '@isa/ui/buttons';
@Component({
selector: 'ui-text-field-clear',
templateUrl: './text-field-clear.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
host: {
'[class]': '["ui-text-field-clear", sizeClass()]',
},
providers: [provideIcons({ isaActionClose })],
imports: [IconButtonComponent],
})
export class TextFieldClearComponent {
hostComponent = inject(TextFieldComponent, { host: true });
size = this.hostComponent.size;
sizeClass = computed(() => {
return `ui-text-field-clear__${this.size()}`;
});
}

View File

@@ -69,6 +69,9 @@
"@isa/core/storage": ["libs/core/storage/src/index.ts"],
"@isa/core/tabs": ["libs/core/tabs/src/index.ts"],
"@isa/crm/data-access": ["libs/crm/data-access/src/index.ts"],
"@isa/crm/feature/customer-bon-redemption": [
"libs/crm/feature/customer-bon-redemption/src/index.ts"
],
"@isa/crm/feature/customer-booking": [
"libs/crm/feature/customer-booking/src/index.ts"
],