Merged PR 2026: feat(crm): add customer loyalty cards feature with points summary

Related work items: #5312
This commit is contained in:
Lorenz Hilpert
2025-11-14 12:59:02 +00:00
committed by Nino Righi
parent 70ded96858
commit 5057d56532
40 changed files with 1176 additions and 108 deletions

View File

@@ -0,0 +1,7 @@
# crm-feature-customer-loyalty-cards
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test crm-feature-customer-loyalty-cards` 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: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

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

View File

@@ -0,0 +1 @@
export * from './lib/customer-loyalty-cards.component';

View File

@@ -0,0 +1 @@
/* Points summary styles - using Tailwind, no additional CSS needed */

View File

@@ -0,0 +1,20 @@
<div
class="flex w-[419px] flex-col items-center justify-center gap-4"
data-what="customer-points-summary"
>
<!-- Points heading -->
<h2 class="isa-text-subtitle-1-bold text-center text-isa-black">
Lesepunkte: {{ formattedPoints() }}
</h2>
<!-- CTA to Prämienshop -->
<button
uiButton
type="button"
color="primary"
data-what="praemienshop-button"
(click)="onNavigateToPraemienshop()"
>
Zum Prämienshop
</button>
</div>

View File

@@ -0,0 +1,168 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CustomerCardPointsSummaryComponent } from './customer-card-points-summary.component';
import { BonusCardInfo } from '@isa/crm/data-access';
describe('CustomerCardPointsSummaryComponent', () => {
let component: CustomerCardPointsSummaryComponent;
let fixture: ComponentFixture<CustomerCardPointsSummaryComponent>;
const mockCards: BonusCardInfo[] = [
{
code: 'CARD-1',
firstName: 'John',
lastName: 'Doe',
isActive: true,
isPrimary: true,
totalPoints: 1500,
cardNumber: '1234-5678-9012-3456',
} as BonusCardInfo,
{
code: 'CARD-2',
firstName: 'John',
lastName: 'Doe',
isActive: true,
isPrimary: false,
totalPoints: 500,
cardNumber: '9876-5432-1098-7654',
} as BonusCardInfo,
];
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CustomerCardPointsSummaryComponent],
});
fixture = TestBed.createComponent(CustomerCardPointsSummaryComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeDefined();
});
describe('Inputs', () => {
it('should accept cards input', () => {
fixture.componentRef.setInput('cards', mockCards);
fixture.detectChanges();
expect(component.cards()).toEqual(mockCards);
});
});
describe('formattedPoints computed signal', () => {
it('should display points from primary card', () => {
fixture.componentRef.setInput('cards', mockCards);
fixture.detectChanges();
expect(component.formattedPoints()).toBe('1.500');
});
it('should format points with German thousands separator', () => {
const cardsWithHighPoints: BonusCardInfo[] = [
{
code: 'CARD-1',
firstName: 'John',
lastName: 'Doe',
isActive: true,
isPrimary: true,
totalPoints: 123456,
cardNumber: '1234-5678-9012-3456',
} as BonusCardInfo,
];
fixture.componentRef.setInput('cards', cardsWithHighPoints);
fixture.detectChanges();
expect(component.formattedPoints()).toBe('123.456');
});
it('should display 0 when no primary card exists', () => {
const cardsWithoutPrimary: BonusCardInfo[] = [
{
code: 'CARD-1',
firstName: 'John',
lastName: 'Doe',
isActive: true,
isPrimary: false,
totalPoints: 1500,
cardNumber: '1234-5678-9012-3456',
} as BonusCardInfo,
];
fixture.componentRef.setInput('cards', cardsWithoutPrimary);
fixture.detectChanges();
expect(component.formattedPoints()).toBe('0');
});
it('should display 0 when cards array is empty', () => {
fixture.componentRef.setInput('cards', []);
fixture.detectChanges();
expect(component.formattedPoints()).toBe('0');
});
it('should handle points value of 0', () => {
const cardsWithZeroPoints: BonusCardInfo[] = [
{
code: 'CARD-1',
firstName: 'John',
lastName: 'Doe',
isActive: true,
isPrimary: true,
totalPoints: 0,
cardNumber: '1234-5678-9012-3456',
} as BonusCardInfo,
];
fixture.componentRef.setInput('cards', cardsWithZeroPoints);
fixture.detectChanges();
expect(component.formattedPoints()).toBe('0');
});
it('should only use primary card points, not sum of all cards', () => {
const multipleCards: BonusCardInfo[] = [
{
code: 'CARD-1',
firstName: 'John',
lastName: 'Doe',
isActive: true,
isPrimary: true,
totalPoints: 1000,
cardNumber: '1234-5678-9012-3456',
} as BonusCardInfo,
{
code: 'CARD-2',
firstName: 'John',
lastName: 'Doe',
isActive: true,
isPrimary: false,
totalPoints: 500,
cardNumber: '9876-5432-1098-7654',
} as BonusCardInfo,
];
fixture.componentRef.setInput('cards', multipleCards);
fixture.detectChanges();
// Should be 1000, not 1500
expect(component.formattedPoints()).toBe('1.000');
});
});
describe('Output: navigateToPraemienshop', () => {
it('should emit navigateToPraemienshop event when onNavigateToPraemienshop is called', () => {
const emitSpy = vi.fn();
component.navigateToPraemienshop.subscribe(emitSpy);
fixture.componentRef.setInput('cards', mockCards);
fixture.detectChanges();
component.onNavigateToPraemienshop();
expect(emitSpy).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,56 @@
import { Component, computed, input, output } from '@angular/core';
import { BonusCardInfo } from '@isa/crm/data-access';
import { ButtonComponent } from '@isa/ui/buttons';
/**
* Displays customer loyalty points summary with CTA to Prämienshop.
*
* Shows:
* - Total Lesepunkte (reading points) from primary card
* - "Zum Prämienshop" button
*
* Based on Figma design with centered layout.
*
* @example
* ```html
* <customer-card-points-summary
* [cards]="bonusCards"
* (navigateToPraemienshop)="handleNavigation()" />
* ```
*/
@Component({
selector: 'crm-customer-card-points-summary',
templateUrl: './customer-card-points-summary.component.html',
styleUrl: './customer-card-points-summary.component.css',
imports: [ButtonComponent],
})
export class CustomerCardPointsSummaryComponent {
/**
* All bonus cards for the customer.
*/
readonly cards = input.required<BonusCardInfo[]>();
/**
* Emitted when user clicks "Zum Prämienshop" button.
*/
readonly navigateToPraemienshop = output<void>();
/**
* Total points from primary card, formatted with thousands separator.
*/
readonly formattedPoints = computed(() => {
const cards = this.cards();
const primaryCard = cards.find((c) => c.isPrimary);
const points = primaryCard?.totalPoints ?? 0;
// Format with German thousands separator (dot)
return points.toLocaleString('de-DE');
});
/**
* Handle Prämienshop button click.
*/
onNavigateToPraemienshop(): void {
this.navigateToPraemienshop.emit();
}
}

View File

@@ -0,0 +1 @@
export * from './customer-card-points-summary.component';

View File

@@ -0,0 +1 @@
/* Customer card styles - using Tailwind, no additional CSS needed */

View File

@@ -0,0 +1,57 @@
<!-- Card container: 337×213px, rounded-2xl, shadow -->
<div
class="relative flex h-[13.3125rem] w-[21.0625rem] flex-col overflow-hidden rounded-2xl bg-isa-black shadow-card"
[attr.data-what]="'customer-card'"
[attr.data-which]="card().code"
>
<!-- Black header section: card type + number + barcode -->
<div
class="flex flex-col items-center gap-[0.62rem] bg-isa-black px-0 pb-4 pt-[0.5rem]"
>
<div>
<!-- Card type label (grey) -->
<div class="isa-text-body-2-bold text-center text-isa-neutral-500">
{{ card().isPrimary ? 'Kundenkarte Nr.:' : 'Mitarbeitendenkarte Nr.:' }}
</div>
<!-- Card number (white, large) -->
<div
class="isa-text-subtitle-1-bold w-[150px] text-center text-isa-white"
>
{{ card().code }}
</div>
</div>
<!-- Barcode placeholder -->
<div
class="h-[5.35156rem] w-[12.3125rem] aspect-[197.00/85.63]"
[attr.data-what]="'card-barcode'"
[attr.data-which]="card().code"
>
<!-- TODO: Replace with actual barcode component -->
<div
class="isa-text-caption-regular flex h-full items-center justify-center bg-isa-white text-isa-neutral-500 rounded-[0.25rem]"
>
Barcode: {{ card().code }}
</div>
</div>
</div>
<!-- White footer section: customer name -->
<div
class="min-h-[2.09rem] bg-isa-white isa-text-body-2-bold text-isa-black flex flex-col items-start justify-center"
>
<div class="isa-text-body-2-bold px-4">
{{ card().firstName }} {{ card().lastName }} Carsten Sievers
</div>
</div>
<!-- Blocked overlay (if card is not active) -->
@if (!card().isActive) {
<div
class="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-isa-black/90"
[attr.data-what]="'blocked-card-overlay'"
[attr.data-which]="card().code"
></div>
}
</div>

View File

@@ -0,0 +1,90 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CustomerCardComponent } from './customer-card.component';
import { BonusCardInfo } from '@isa/crm/data-access';
describe('CustomerCardComponent', () => {
let component: CustomerCardComponent;
let fixture: ComponentFixture<CustomerCardComponent>;
const mockCard: BonusCardInfo = {
code: 'CARD-1',
firstName: 'John',
lastName: 'Doe',
isActive: true,
isPrimary: true,
totalPoints: 1500,
cardNumber: '1234-5678-9012-3456',
} as BonusCardInfo;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CustomerCardComponent],
});
fixture = TestBed.createComponent(CustomerCardComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeDefined();
});
describe('Inputs', () => {
it('should accept card input', () => {
fixture.componentRef.setInput('card', mockCard);
fixture.detectChanges();
expect(component.card()).toEqual(mockCard);
});
it('should display active card', () => {
const activeCard: BonusCardInfo = {
...mockCard,
isActive: true,
};
fixture.componentRef.setInput('card', activeCard);
fixture.detectChanges();
expect(component.card().isActive).toBe(true);
});
it('should display blocked card', () => {
const blockedCard: BonusCardInfo = {
...mockCard,
isActive: false,
};
fixture.componentRef.setInput('card', blockedCard);
fixture.detectChanges();
expect(component.card().isActive).toBe(false);
});
it('should display primary card', () => {
const primaryCard: BonusCardInfo = {
...mockCard,
isPrimary: true,
};
fixture.componentRef.setInput('card', primaryCard);
fixture.detectChanges();
expect(component.card().isPrimary).toBe(true);
});
it('should display customer name', () => {
fixture.componentRef.setInput('card', mockCard);
fixture.detectChanges();
const card = component.card();
expect(card.firstName).toBe('John');
expect(card.lastName).toBe('Doe');
});
it('should display card number', () => {
fixture.componentRef.setInput('card', mockCard);
fixture.detectChanges();
expect(component.card().cardNumber).toBe('1234-5678-9012-3456');
});
});
});

View File

@@ -0,0 +1,33 @@
import { Component, input, output } from '@angular/core';
import { BonusCardInfo } from '@isa/crm/data-access';
/**
* Individual customer loyalty card display component.
*
* Displays a customer bonus card with:
* - Card type (Kundenkarte/Mitarbeitendenkarte)
* - Card number
* - Barcode
* - Customer name
* - Blocked state overlay (if applicable)
*
* Based on Figma design: 337×213px card with black header and white footer.
*
* @example
* ```html
* <customer-card
* [card]="bonusCard"
* (unblock)="handleUnblock($event)" />
* ```
*/
@Component({
selector: 'crm-customer-card',
templateUrl: './customer-card.component.html',
styleUrl: './customer-card.component.css',
})
export class CustomerCardComponent {
/**
* Bonus card data to display.
*/
readonly card = input.required<BonusCardInfo>();
}

View File

@@ -0,0 +1 @@
export * from './customer-card.component';

View File

@@ -0,0 +1 @@
/* Carousel container styles - using Tailwind and ui-carousel, no additional CSS needed */

View File

@@ -0,0 +1,12 @@
<div class="relative" data-what="customer-cards-carousel">
<!-- Carousel with navigation arrows -->
<ui-carousel
[gap]="'1rem'"
[arrowAutoHide]="true"
[padding]="'0.75rem 0.5rem'"
>
@for (card of sortedCards(); track card.code) {
<crm-customer-card [card]="card" />
}
</ui-carousel>
</div>

View File

@@ -0,0 +1,181 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CustomerCardsCarouselComponent } from './customer-cards-carousel.component';
import { BonusCardInfo } from '@isa/crm/data-access';
describe('CustomerCardsCarouselComponent', () => {
let component: CustomerCardsCarouselComponent;
let fixture: ComponentFixture<CustomerCardsCarouselComponent>;
const mockCards: BonusCardInfo[] = [
{
code: 'CARD-1',
firstName: 'John',
lastName: 'Doe',
isActive: true,
isPrimary: true,
totalPoints: 1500,
cardNumber: '1234-5678-9012-3456',
} as BonusCardInfo,
{
code: 'CARD-2',
firstName: 'John',
lastName: 'Doe',
isActive: false,
isPrimary: false,
totalPoints: 500,
cardNumber: '9876-5432-1098-7654',
} as BonusCardInfo,
{
code: 'CARD-3',
firstName: 'John',
lastName: 'Doe',
isActive: true,
isPrimary: false,
totalPoints: 800,
cardNumber: '1111-2222-3333-4444',
} as BonusCardInfo,
];
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CustomerCardsCarouselComponent],
});
fixture = TestBed.createComponent(CustomerCardsCarouselComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeDefined();
});
describe('Inputs', () => {
it('should accept cards input', () => {
fixture.componentRef.setInput('cards', mockCards);
fixture.detectChanges();
expect(component.cards()).toEqual(mockCards);
});
});
describe('sortedCards computed signal', () => {
it('should sort cards with active cards first', () => {
fixture.componentRef.setInput('cards', mockCards);
fixture.detectChanges();
const sorted = component.sortedCards();
// First two should be active (CARD-1 and CARD-3)
expect(sorted[0].isActive).toBe(true);
expect(sorted[1].isActive).toBe(true);
// Last one should be blocked (CARD-2)
expect(sorted[2].isActive).toBe(false);
});
it('should place blocked cards at the end', () => {
fixture.componentRef.setInput('cards', mockCards);
fixture.detectChanges();
const sorted = component.sortedCards();
// Blocked card should be last
expect(sorted[sorted.length - 1].code).toBe('CARD-2');
expect(sorted[sorted.length - 1].isActive).toBe(false);
});
it('should handle all active cards', () => {
const allActiveCards: BonusCardInfo[] = [
{ code: 'CARD-1', isActive: true, isPrimary: true, firstName: 'John', lastName: 'Doe', totalPoints: 100 } as BonusCardInfo,
{ code: 'CARD-2', isActive: true, isPrimary: false, firstName: 'John', lastName: 'Doe', totalPoints: 200 } as BonusCardInfo,
{ code: 'CARD-3', isActive: true, isPrimary: false, firstName: 'John', lastName: 'Doe', totalPoints: 300 } as BonusCardInfo,
];
fixture.componentRef.setInput('cards', allActiveCards);
fixture.detectChanges();
const sorted = component.sortedCards();
// All should remain active
expect(sorted.every(card => card.isActive)).toBe(true);
expect(sorted.length).toBe(3);
});
it('should handle all blocked cards', () => {
const allBlockedCards: BonusCardInfo[] = [
{ code: 'CARD-1', isActive: false, isPrimary: false, firstName: 'John', lastName: 'Doe', totalPoints: 100 } as BonusCardInfo,
{ code: 'CARD-2', isActive: false, isPrimary: false, firstName: 'John', lastName: 'Doe', totalPoints: 200 } as BonusCardInfo,
];
fixture.componentRef.setInput('cards', allBlockedCards);
fixture.detectChanges();
const sorted = component.sortedCards();
// All should remain blocked
expect(sorted.every(card => !card.isActive)).toBe(true);
expect(sorted.length).toBe(2);
});
it('should handle empty cards array', () => {
fixture.componentRef.setInput('cards', []);
fixture.detectChanges();
const sorted = component.sortedCards();
expect(sorted).toEqual([]);
});
it('should handle single card', () => {
const singleCard: BonusCardInfo[] = [
{ code: 'CARD-1', isActive: true, isPrimary: true, firstName: 'John', lastName: 'Doe', totalPoints: 100 } as BonusCardInfo,
];
fixture.componentRef.setInput('cards', singleCard);
fixture.detectChanges();
const sorted = component.sortedCards();
expect(sorted.length).toBe(1);
expect(sorted[0].code).toBe('CARD-1');
});
it('should not mutate original cards array', () => {
const originalCards = [...mockCards];
fixture.componentRef.setInput('cards', mockCards);
fixture.detectChanges();
component.sortedCards();
// Original array should remain unchanged
expect(mockCards).toEqual(originalCards);
});
it('should maintain sorting when cards input changes', () => {
const initialCards: BonusCardInfo[] = [
{ code: 'CARD-1', isActive: false, isPrimary: false, firstName: 'John', lastName: 'Doe', totalPoints: 100 } as BonusCardInfo,
{ code: 'CARD-2', isActive: true, isPrimary: true, firstName: 'John', lastName: 'Doe', totalPoints: 200 } as BonusCardInfo,
];
fixture.componentRef.setInput('cards', initialCards);
fixture.detectChanges();
let sorted = component.sortedCards();
expect(sorted[0].code).toBe('CARD-2'); // Active first
expect(sorted[1].code).toBe('CARD-1'); // Blocked last
// Update with different cards
const updatedCards: BonusCardInfo[] = [
{ code: 'CARD-3', isActive: true, isPrimary: false, firstName: 'Jane', lastName: 'Doe', totalPoints: 300 } as BonusCardInfo,
{ code: 'CARD-4', isActive: false, isPrimary: false, firstName: 'Jane', lastName: 'Doe', totalPoints: 400 } as BonusCardInfo,
];
fixture.componentRef.setInput('cards', updatedCards);
fixture.detectChanges();
sorted = component.sortedCards();
expect(sorted[0].code).toBe('CARD-3'); // Active first
expect(sorted[1].code).toBe('CARD-4'); // Blocked last
});
});
});

View File

@@ -0,0 +1,48 @@
import { Component, computed, input, output } from '@angular/core';
import { BonusCardInfo } from '@isa/crm/data-access';
import { CarouselComponent } from '@isa/ui/carousel';
import { CustomerCardComponent } from '../customer-card';
/**
* Carousel container for displaying multiple customer loyalty cards.
*
* Features:
* - Horizontal scrolling with navigation arrows
* - Blocked cards sorted to the end
* - Gap spacing between cards
* - Uses @isa/ui/carousel for smooth scrolling
*
* Based on Figma design with carousel navigation.
*
* @example
* ```html
* <customer-cards-carousel
* [cards]="bonusCards"
* (unblockCard)="handleUnblock($event)" />
* ```
*/
@Component({
selector: 'crm-customer-cards-carousel',
imports: [CarouselComponent, CustomerCardComponent],
templateUrl: './customer-cards-carousel.component.html',
styleUrl: './customer-cards-carousel.component.css',
})
export class CustomerCardsCarouselComponent {
/**
* All bonus cards to display in carousel.
*/
readonly cards = input.required<BonusCardInfo[]>();
/**
* Cards sorted with blocked cards at the end.
* Per Figma annotation: "gesperrte Karte immer nach hinten"
*/
readonly sortedCards = computed(() => {
const cards = this.cards();
return [...cards].sort((a, b) => {
// Active cards first, blocked cards last
if (a.isActive === b.isActive) return 0;
return a.isActive ? -1 : 1;
});
});
}

View File

@@ -0,0 +1 @@
export * from './customer-cards-carousel.component';

View File

@@ -0,0 +1,54 @@
<div
class="flex flex-col items-center gap-8"
data-what="customer-loyalty-cards"
>
<!-- Loading state -->
@if (isLoading()) {
<div class="isa-text-body-1-regular text-isa-neutral-700">
Kundenkarten werden geladen...
</div>
}
<!-- Error state -->
@if (error(); as error) {
<div
class="isa-text-body-1-regular rounded-lg bg-isa-accent-red/10 p-4 text-isa-accent-red"
data-what="error-message"
>
Fehler beim Laden der Karten: {{ error.message }}
</div>
}
<!-- Success state -->
@if (cards(); as cardList) {
<!-- Points summary with CTA -->
<crm-customer-card-points-summary
[cards]="cardList"
(navigateToPraemienshop)="onNavigateToPraemienshop()"
/>
<!-- Cards carousel -->
<crm-customer-cards-carousel [cards]="cardList" class="max-w-[657px]" />
<!-- Action buttons (TODO: implement) -->
<div class="flex gap-4" data-what="card-actions">
<button
uiTextButton
type="button"
data-what="add-card-button"
[color]="'strong'"
>
Karte hinzufügen
</button>
<button
uiTextButton
type="button"
data-what="block-card-button"
[color]="'strong'"
>
Karte sperren
</button>
</div>
}
</div>

View File

@@ -0,0 +1,96 @@
import { Component, effect, inject, input } from '@angular/core';
import { CustomerBonusCardsResource } from '@isa/crm/data-access';
import { logger } from '@isa/core/logging';
import { CustomerCardPointsSummaryComponent } from './components/customer-card-points-summary';
import { CustomerCardsCarouselComponent } from './components/customer-cards-carousel';
import { coerceNumberProperty, NumberInput } from '@angular/cdk/coercion';
import { TextButtonComponent } from '@isa/ui/buttons';
import { Router } from '@angular/router';
/**
* Main container component for displaying customer loyalty cards.
*
* Manages the loading and display of all bonus cards (Kundenkarten) for a customer,
* including:
* - Points summary with CTA to Prämienshop
* - Carousel of customer cards with barcodes
* - Card management actions (add, block/unblock)
* - Blocked card overlay states
*
* Provides scoped CustomerBonusCardsResource for all child components.
*
* @example
* ```html
* <customer-loyalty-cards [customerId]="selectedCustomerId()" />
* ```
*/
@Component({
selector: 'crm-customer-loyalty-cards',
imports: [
CustomerCardPointsSummaryComponent,
CustomerCardsCarouselComponent,
TextButtonComponent,
],
providers: [CustomerBonusCardsResource],
templateUrl: './customer-loyalty-cards.component.html',
styleUrl: './customer-loyalty-cards.component.css',
})
export class CustomerLoyaltyCardsComponent {
#router = inject(Router);
#bonusCardsResource = inject(CustomerBonusCardsResource);
#logger = logger(() => ({
context: 'CustomerLoyaltyCardsComponent',
}));
/**
* Customer ID to load loyalty cards for.
* When changed, triggers automatic reload of bonus cards.
*/
readonly customerId = input.required<number, NumberInput>({
transform: coerceNumberProperty,
});
/**
* Tab ID where this component is used.
* Used for telemetry/logging purposes.
*/
readonly tabId = input.required<number, NumberInput>({
transform: coerceNumberProperty,
});
/**
* All bonus cards for the selected customer.
*/
readonly cards = this.#bonusCardsResource.resource.value;
/**
* Loading state for bonus cards.
*/
readonly isLoading = this.#bonusCardsResource.resource.isLoading;
/**
* Error state from loading bonus cards.
*/
readonly error = this.#bonusCardsResource.resource.error;
constructor() {
// React to customerId changes and load bonus cards
effect(() => {
const customerId = this.customerId();
this.#logger.debug('Customer ID changed, loading cards', () => ({
customerId,
}));
this.#bonusCardsResource.params({ customerId });
});
}
/**
* Handle navigation to Prämienshop.
*/
onNavigateToPraemienshop(): void {
this.#logger.info('Navigate to Prämienshop requested');
this.#router.navigate([`${this.tabId()}/reward`]);
}
}

View File

@@ -0,0 +1,24 @@
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(),
);
// Mock ResizeObserver for tests
global.ResizeObserver = class ResizeObserver {
// eslint-disable-next-line @typescript-eslint/no-empty-function
observe(): void {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
unobserve(): void {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
disconnect(): void {}
};

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,41 @@
/// <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-loyalty-cards',
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-loyalty-cards.xml',
},
],
],
coverage: {
reportsDirectory:
'../../../../coverage/libs/crm/feature/customer-loyalty-cards',
provider: 'v8' as const,
reporter: ['text', 'cobertura'],
},
},
}));