mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-31 09:37:15 +01:00
Merged PR 2026: feat(crm): add customer loyalty cards feature with points summary
Related work items: #5312
This commit is contained in:
committed by
Nino Righi
parent
70ded96858
commit
5057d56532
@@ -1,34 +1,12 @@
|
|||||||
<div class="flex flex-row justify-end -mt-2">
|
<div class="flex flex-row justify-end -mt-2">
|
||||||
<page-customer-menu [customerId]="customerId$ | async" [processId]="processId$ | async" [showCustomerCard]="false"></page-customer-menu>
|
<page-customer-menu
|
||||||
</div>
|
[customerId]="customerId$ | async"
|
||||||
<h1 class="text-center text-2xl font-bold">Kundenkarte</h1>
|
[processId]="processId$ | async"
|
||||||
@if (!(noDataFound$ | async)) {
|
[showCustomerCard]="false"
|
||||||
<p class="text-center text-xl">
|
/>
|
||||||
Alle Infos zu Ihrer Kundenkarte
|
</div>
|
||||||
<br />
|
<crm-customer-loyalty-cards
|
||||||
und allen Partnerkarten.
|
[customerId]="customerId$ | async"
|
||||||
</p>
|
[tabId]="processId$ | async"
|
||||||
}
|
class="mt-4"
|
||||||
@if (noDataFound$ | async) {
|
/>
|
||||||
<p class="text-center text-xl">Keine Kundenkarte gefunden.</p>
|
|
||||||
}
|
|
||||||
@for (karte of primaryKundenkarte$ | async; track karte) {
|
|
||||||
<page-customer-kundenkarte
|
|
||||||
class="justify-self-center"
|
|
||||||
[cardDetails]="karte"
|
|
||||||
[isCustomerCard]="true"
|
|
||||||
[customerId]="customerId$ | async"
|
|
||||||
></page-customer-kundenkarte>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if ((partnerKundenkarte$ | async)?.length) {
|
|
||||||
<p class="text-center text-xl font-bold">Partnerkarten</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
@for (karte of partnerKundenkarte$ | async; track karte) {
|
|
||||||
<page-customer-kundenkarte
|
|
||||||
class="justify-self-center"
|
|
||||||
[cardDetails]="karte"
|
|
||||||
[isCustomerCard]="false"
|
|
||||||
></page-customer-kundenkarte>
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,63 +1,26 @@
|
|||||||
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, inject } from '@angular/core';
|
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||||
import { CustomerSearchStore } from '../store';
|
import { CustomerSearchStore } from '../store';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { Subject, combineLatest, of } from 'rxjs';
|
import { map } from 'rxjs/operators';
|
||||||
import { catchError, map, share, switchMap } from 'rxjs/operators';
|
import { AsyncPipe } from '@angular/common';
|
||||||
import { CrmCustomerService } from '@domain/crm';
|
import { CustomerMenuComponent } from '../../components/customer-menu';
|
||||||
import { KundenkarteComponent } from '../../components/kundenkarte';
|
import { CustomerLoyaltyCardsComponent } from '@isa/crm/feature/customer-loyalty-cards';
|
||||||
import { AsyncPipe } from '@angular/common';
|
|
||||||
import { CustomerSearchNavigation } from '@shared/services/navigation';
|
@Component({
|
||||||
import { BonusCardInfoDTO } from '@generated/swagger/crm-api';
|
selector: 'page-customer-kundenkarte-main-view',
|
||||||
import { CustomerMenuComponent } from '../../components/customer-menu';
|
templateUrl: 'kundenkarte-main-view.component.html',
|
||||||
|
styleUrls: ['kundenkarte-main-view.component.css'],
|
||||||
@Component({
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
selector: 'page-customer-kundenkarte-main-view',
|
host: { class: 'page-customer-kundenkarte-main-view' },
|
||||||
templateUrl: 'kundenkarte-main-view.component.html',
|
imports: [CustomerMenuComponent, AsyncPipe, CustomerLoyaltyCardsComponent],
|
||||||
styleUrls: ['kundenkarte-main-view.component.css'],
|
})
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
export class KundenkarteMainViewComponent {
|
||||||
host: { class: 'page-customer-kundenkarte-main-view' },
|
private _store = inject(CustomerSearchStore);
|
||||||
imports: [CustomerMenuComponent, KundenkarteComponent, AsyncPipe],
|
private _activatedRoute = inject(ActivatedRoute);
|
||||||
})
|
|
||||||
export class KundenkarteMainViewComponent implements OnInit, OnDestroy {
|
customerId$ = this._activatedRoute.params.pipe(
|
||||||
private _store = inject(CustomerSearchStore);
|
map((params) => params.customerId),
|
||||||
private _activatedRoute = inject(ActivatedRoute);
|
);
|
||||||
private _customerService = inject(CrmCustomerService);
|
|
||||||
private _navigation = inject(CustomerSearchNavigation);
|
processId$ = this._store.processId$;
|
||||||
|
}
|
||||||
private _onDestroy$ = new Subject<void>();
|
|
||||||
|
|
||||||
customerId$ = this._activatedRoute.params.pipe(map((params) => params.customerId));
|
|
||||||
|
|
||||||
processId$ = this._store.processId$;
|
|
||||||
|
|
||||||
kundenkarte$ = this.customerId$.pipe(
|
|
||||||
switchMap((customerId) =>
|
|
||||||
this._customerService.getCustomerCard(customerId).pipe(
|
|
||||||
map((response) => response.result?.filter((f) => f.isActive)),
|
|
||||||
catchError(() => of<BonusCardInfoDTO[]>([])),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
share(),
|
|
||||||
);
|
|
||||||
|
|
||||||
noDataFound$ = this.kundenkarte$.pipe(map((kundenkarte) => kundenkarte?.length == 0));
|
|
||||||
|
|
||||||
primaryKundenkarte$ = this.kundenkarte$.pipe(map((kundenkarte) => kundenkarte?.filter((k) => k.isPrimary)));
|
|
||||||
|
|
||||||
partnerKundenkarte$ = this.kundenkarte$.pipe(map((kundenkarte) => kundenkarte?.filter((k) => !k.isPrimary)));
|
|
||||||
|
|
||||||
detailsRoute$ = combineLatest([this._store.processId$, this._store.customerId$]).pipe(
|
|
||||||
map(([processId, customerId]) => this._navigation.detailsRoute({ processId, customerId })),
|
|
||||||
);
|
|
||||||
|
|
||||||
ngOnInit() {
|
|
||||||
this.customerId$.subscribe((customerId) => {
|
|
||||||
this._store.selectCustomer(customerId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy() {
|
|
||||||
this._onDestroy$.next();
|
|
||||||
this._onDestroy$.complete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
DisplayOrderDestinationInfoComponent,
|
DisplayOrderDestinationInfoComponent,
|
||||||
} from '@isa/checkout/shared/product-info';
|
} from '@isa/checkout/shared/product-info';
|
||||||
import { DisplayOrderItemDTO } from '@generated/swagger/oms-api';
|
import { DisplayOrderItemDTO } from '@generated/swagger/oms-api';
|
||||||
import { Product } from '@isa/common/data-access';
|
import { type OrderItemGroup, type Product } from '@isa/checkout/data-access';
|
||||||
import { type OrderItemGroup } from '@isa/checkout/data-access';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'checkout-order-confirmation-item-list-item',
|
selector: 'checkout-order-confirmation-item-list-item',
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
input,
|
input,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Product } from '@isa/common/data-access';
|
|
||||||
import { ProductImageDirective } from '@isa/shared/product-image';
|
import { ProductImageDirective } from '@isa/shared/product-image';
|
||||||
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
|
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
|
||||||
|
|
||||||
export type ProductInfoItem = Pick<Product, 'ean' | 'name' | 'contributors'>;
|
export type ProductInfoItem = {
|
||||||
|
ean?: string;
|
||||||
|
name?: string;
|
||||||
|
contributors?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProductNameSize = 'small' | 'medium' | 'large';
|
export type ProductNameSize = 'small' | 'medium' | 'large';
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export * from './order-type-feature';
|
|||||||
export * from './payer-type';
|
export * from './payer-type';
|
||||||
export * from './price-value';
|
export * from './price-value';
|
||||||
export * from './price';
|
export * from './price';
|
||||||
export * from './product';
|
|
||||||
export * from './response-args';
|
export * from './response-args';
|
||||||
export * from './return-value';
|
export * from './return-value';
|
||||||
export * from './vat-type';
|
export * from './vat-type';
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { ProductDTO as CatProductDTO } from '@generated/swagger/cat-search-api';
|
|
||||||
import { ProductDTO as CheckoutProductDTO } from '@generated/swagger/checkout-api';
|
|
||||||
import { ProductDTO as OmsProductDTO } from '@generated/swagger/oms-api';
|
|
||||||
|
|
||||||
export type Product = CatProductDTO | CheckoutProductDTO | OmsProductDTO;
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Injectable, inject, resource, signal, computed } from '@angular/core';
|
||||||
|
import { logger } from '@isa/core/logging';
|
||||||
|
import { CrmSearchService } from '../services/crm-search.service';
|
||||||
|
import { BonusCardInfo } from '../models';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resource for loading customer bonus cards (Kundenkarten).
|
||||||
|
*
|
||||||
|
* Provides reactive loading of all bonus cards for a given customer ID.
|
||||||
|
* Customer ID can be changed dynamically via `params()` method.
|
||||||
|
*
|
||||||
|
* **Note:** This resource should be provided at the component level,
|
||||||
|
* not in root. Provide it in the `providers` array of the component
|
||||||
|
* that needs scoped access to customer bonus cards.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @Component({
|
||||||
|
* providers: [CustomerBonusCardsResource],
|
||||||
|
* })
|
||||||
|
* export class MyFeatureComponent {
|
||||||
|
* #bonusCardsResource = inject(CustomerBonusCardsResource);
|
||||||
|
*
|
||||||
|
* cards = this.#bonusCardsResource.resource.value;
|
||||||
|
* isLoading = this.#bonusCardsResource.resource.isLoading;
|
||||||
|
*
|
||||||
|
* loadCards(customerId: number) {
|
||||||
|
* this.#bonusCardsResource.params({ customerId });
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CustomerBonusCardsResource {
|
||||||
|
readonly #crmSearchService = inject(CrmSearchService);
|
||||||
|
readonly #logger = logger(() => ({ context: 'CustomerBonusCardsResource' }));
|
||||||
|
|
||||||
|
readonly #customerId = signal<number | undefined>(undefined);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resource that loads bonus cards based on current parameters.
|
||||||
|
*
|
||||||
|
* Exposes:
|
||||||
|
* - `value()` - Array of bonus cards or undefined
|
||||||
|
* - `isLoading()` - Loading state
|
||||||
|
* - `error()` - Error state
|
||||||
|
* - `status()` - Current status ('idle' | 'loading' | 'resolved' | 'error')
|
||||||
|
*/
|
||||||
|
readonly resource = resource({
|
||||||
|
params: computed(() => ({ customerId: this.#customerId() })),
|
||||||
|
loader: async ({
|
||||||
|
params,
|
||||||
|
abortSignal,
|
||||||
|
}): Promise<BonusCardInfo[] | undefined> => {
|
||||||
|
const { customerId } = params;
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
this.#logger.debug('No customerId provided, skipping load');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#logger.debug('Loading bonus cards', () => ({ customerId }));
|
||||||
|
|
||||||
|
const response = await this.#crmSearchService.fetchCustomerCards(
|
||||||
|
{ customerId },
|
||||||
|
abortSignal,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.#logger.debug('Bonus cards loaded', () => ({
|
||||||
|
customerId,
|
||||||
|
count: response?.result?.length ?? 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return response?.result;
|
||||||
|
},
|
||||||
|
defaultValue: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update resource parameters to trigger a reload.
|
||||||
|
*
|
||||||
|
* @param params - Parameters for loading bonus cards
|
||||||
|
* @param params.customerId - Customer ID to load cards for (undefined clears data)
|
||||||
|
*/
|
||||||
|
params(params: { customerId?: number }): void {
|
||||||
|
this.#logger.debug('Updating params', () => params);
|
||||||
|
this.#customerId.set(params.customerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './country.resource';
|
export * from './country.resource';
|
||||||
|
export * from './customer-bonus-cards.resource';
|
||||||
export * from './customer-payer-address.resource';
|
export * from './customer-payer-address.resource';
|
||||||
export * from './primary-customer-card.resource';
|
export * from './primary-customer-card.resource';
|
||||||
export * from './customer-shipping-address.resource';
|
export * from './customer-shipping-address.resource';
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export const CustomerSchema = z
|
|||||||
.describe('User information')
|
.describe('User information')
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.extend(EntitySchema.shape);
|
.extend(EntitySchema.shape)
|
||||||
|
.describe('Customer');
|
||||||
|
|
||||||
export type Customer = z.infer<typeof CustomerSchema>;
|
export type Customer = z.infer<typeof CustomerSchema>;
|
||||||
|
|||||||
7
libs/crm/feature/customer-loyalty-cards/README.md
Normal file
7
libs/crm/feature/customer-loyalty-cards/README.md
Normal 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.
|
||||||
34
libs/crm/feature/customer-loyalty-cards/eslint.config.cjs
Normal file
34
libs/crm/feature/customer-loyalty-cards/eslint.config.cjs
Normal 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: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
20
libs/crm/feature/customer-loyalty-cards/project.json
Normal file
20
libs/crm/feature/customer-loyalty-cards/project.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
libs/crm/feature/customer-loyalty-cards/src/index.ts
Normal file
1
libs/crm/feature/customer-loyalty-cards/src/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './lib/customer-loyalty-cards.component';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* Points summary styles - using Tailwind, no additional CSS needed */
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './customer-card-points-summary.component';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* Customer card styles - using Tailwind, no additional CSS needed */
|
||||||
@@ -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>
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>();
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './customer-card.component';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* Carousel container styles - using Tailwind and ui-carousel, no additional CSS needed */
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './customer-cards-carousel.component';
|
||||||
@@ -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>
|
||||||
@@ -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`]);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
libs/crm/feature/customer-loyalty-cards/src/test-setup.ts
Normal file
24
libs/crm/feature/customer-loyalty-cards/src/test-setup.ts
Normal 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 {}
|
||||||
|
};
|
||||||
30
libs/crm/feature/customer-loyalty-cards/tsconfig.json
Normal file
30
libs/crm/feature/customer-loyalty-cards/tsconfig.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
27
libs/crm/feature/customer-loyalty-cards/tsconfig.lib.json
Normal file
27
libs/crm/feature/customer-loyalty-cards/tsconfig.lib.json
Normal 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"]
|
||||||
|
}
|
||||||
29
libs/crm/feature/customer-loyalty-cards/tsconfig.spec.json
Normal file
29
libs/crm/feature/customer-loyalty-cards/tsconfig.spec.json
Normal 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"]
|
||||||
|
}
|
||||||
41
libs/crm/feature/customer-loyalty-cards/vite.config.mts
Normal file
41
libs/crm/feature/customer-loyalty-cards/vite.config.mts
Normal 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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
overflow-y: visible; // Allow vertical overflow to be visible
|
overflow-y: visible; // Allow vertical overflow to be visible
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
box-sizing: border-box; // Padding is inset, maintains original size
|
||||||
|
|
||||||
// Hide scrollbar while maintaining scroll functionality
|
// Hide scrollbar while maintaining scroll functionality
|
||||||
scrollbar-width: none; // Firefox
|
scrollbar-width: none; // Firefox
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
#scrollContainer
|
#scrollContainer
|
||||||
class="ui-carousel__container"
|
class="ui-carousel__container"
|
||||||
[style.gap]="gap()"
|
[style.gap]="gap()"
|
||||||
|
[style.padding]="padding()"
|
||||||
>
|
>
|
||||||
<ng-content></ng-content>
|
<ng-content></ng-content>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export class CarouselComponent {
|
|||||||
// Input signals
|
// Input signals
|
||||||
gap = input<string>('1rem');
|
gap = input<string>('1rem');
|
||||||
arrowAutoHide = input<boolean>(true);
|
arrowAutoHide = input<boolean>(true);
|
||||||
|
padding = input<string>('0');
|
||||||
|
|
||||||
// View child for scroll container
|
// View child for scroll container
|
||||||
scrollContainer =
|
scrollContainer =
|
||||||
|
|||||||
@@ -69,6 +69,9 @@
|
|||||||
"@isa/core/storage": ["libs/core/storage/src/index.ts"],
|
"@isa/core/storage": ["libs/core/storage/src/index.ts"],
|
||||||
"@isa/core/tabs": ["libs/core/tabs/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/data-access": ["libs/crm/data-access/src/index.ts"],
|
||||||
|
"@isa/crm/feature/customer-loyalty-cards": [
|
||||||
|
"libs/crm/feature/customer-loyalty-cards/src/index.ts"
|
||||||
|
],
|
||||||
"@isa/icons": ["libs/icons/src/index.ts"],
|
"@isa/icons": ["libs/icons/src/index.ts"],
|
||||||
"@isa/oms/data-access": ["libs/oms/data-access/src/index.ts"],
|
"@isa/oms/data-access": ["libs/oms/data-access/src/index.ts"],
|
||||||
"@isa/oms/feature/return-details": [
|
"@isa/oms/feature/return-details": [
|
||||||
|
|||||||
Reference in New Issue
Block a user