diff --git a/apps/isa-app/src/page/customer/customer-search/kundenkarte-main-view/kundenkarte-main-view.component.html b/apps/isa-app/src/page/customer/customer-search/kundenkarte-main-view/kundenkarte-main-view.component.html index cf248e4ee..1d9b19668 100644 --- a/apps/isa-app/src/page/customer/customer-search/kundenkarte-main-view/kundenkarte-main-view.component.html +++ b/apps/isa-app/src/page/customer/customer-search/kundenkarte-main-view/kundenkarte-main-view.component.html @@ -1,34 +1,12 @@ -
- -
-

Kundenkarte

-@if (!(noDataFound$ | async)) { -

- Alle Infos zu Ihrer Kundenkarte -
- und allen Partnerkarten. -

-} -@if (noDataFound$ | async) { -

Keine Kundenkarte gefunden.

-} -@for (karte of primaryKundenkarte$ | async; track karte) { - -} - -@if ((partnerKundenkarte$ | async)?.length) { -

Partnerkarten

-} - -@for (karte of partnerKundenkarte$ | async; track karte) { - -} +
+ +
+ diff --git a/apps/isa-app/src/page/customer/customer-search/kundenkarte-main-view/kundenkarte-main-view.component.ts b/apps/isa-app/src/page/customer/customer-search/kundenkarte-main-view/kundenkarte-main-view.component.ts index de39588b7..c6c65d22e 100644 --- a/apps/isa-app/src/page/customer/customer-search/kundenkarte-main-view/kundenkarte-main-view.component.ts +++ b/apps/isa-app/src/page/customer/customer-search/kundenkarte-main-view/kundenkarte-main-view.component.ts @@ -1,63 +1,26 @@ -import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, inject } from '@angular/core'; -import { CustomerSearchStore } from '../store'; -import { ActivatedRoute } from '@angular/router'; -import { Subject, combineLatest, of } from 'rxjs'; -import { catchError, map, share, switchMap } from 'rxjs/operators'; -import { CrmCustomerService } from '@domain/crm'; -import { KundenkarteComponent } from '../../components/kundenkarte'; -import { AsyncPipe } from '@angular/common'; -import { CustomerSearchNavigation } from '@shared/services/navigation'; -import { BonusCardInfoDTO } from '@generated/swagger/crm-api'; -import { CustomerMenuComponent } from '../../components/customer-menu'; - -@Component({ - selector: 'page-customer-kundenkarte-main-view', - templateUrl: 'kundenkarte-main-view.component.html', - styleUrls: ['kundenkarte-main-view.component.css'], - changeDetection: ChangeDetectionStrategy.OnPush, - host: { class: 'page-customer-kundenkarte-main-view' }, - imports: [CustomerMenuComponent, KundenkarteComponent, AsyncPipe], -}) -export class KundenkarteMainViewComponent implements OnInit, OnDestroy { - private _store = inject(CustomerSearchStore); - private _activatedRoute = inject(ActivatedRoute); - private _customerService = inject(CrmCustomerService); - private _navigation = inject(CustomerSearchNavigation); - - private _onDestroy$ = new Subject(); - - 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([])), - ), - ), - 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(); - } -} +import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; +import { CustomerSearchStore } from '../store'; +import { ActivatedRoute } from '@angular/router'; +import { map } from 'rxjs/operators'; +import { AsyncPipe } from '@angular/common'; +import { CustomerMenuComponent } from '../../components/customer-menu'; +import { CustomerLoyaltyCardsComponent } from '@isa/crm/feature/customer-loyalty-cards'; + +@Component({ + selector: 'page-customer-kundenkarte-main-view', + templateUrl: 'kundenkarte-main-view.component.html', + styleUrls: ['kundenkarte-main-view.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'page-customer-kundenkarte-main-view' }, + imports: [CustomerMenuComponent, AsyncPipe, CustomerLoyaltyCardsComponent], +}) +export class KundenkarteMainViewComponent { + private _store = inject(CustomerSearchStore); + private _activatedRoute = inject(ActivatedRoute); + + customerId$ = this._activatedRoute.params.pipe( + map((params) => params.customerId), + ); + + processId$ = this._store.processId$; +} diff --git a/libs/checkout/feature/reward-order-confirmation/src/lib/order-confirmation-item-list/order-confirmation-item-list-item/order-confirmation-item-list-item.component.ts b/libs/checkout/feature/reward-order-confirmation/src/lib/order-confirmation-item-list/order-confirmation-item-list-item/order-confirmation-item-list-item.component.ts index 2b9f21488..e90a79192 100644 --- a/libs/checkout/feature/reward-order-confirmation/src/lib/order-confirmation-item-list/order-confirmation-item-list-item/order-confirmation-item-list-item.component.ts +++ b/libs/checkout/feature/reward-order-confirmation/src/lib/order-confirmation-item-list/order-confirmation-item-list-item/order-confirmation-item-list-item.component.ts @@ -10,8 +10,7 @@ import { DisplayOrderDestinationInfoComponent, } from '@isa/checkout/shared/product-info'; import { DisplayOrderItemDTO } from '@generated/swagger/oms-api'; -import { Product } from '@isa/common/data-access'; -import { type OrderItemGroup } from '@isa/checkout/data-access'; +import { type OrderItemGroup, type Product } from '@isa/checkout/data-access'; @Component({ selector: 'checkout-order-confirmation-item-list-item', diff --git a/libs/checkout/shared/product-info/src/lib/product-info/product-info.component.ts b/libs/checkout/shared/product-info/src/lib/product-info/product-info.component.ts index 23fa61bac..5e30ceb1b 100644 --- a/libs/checkout/shared/product-info/src/lib/product-info/product-info.component.ts +++ b/libs/checkout/shared/product-info/src/lib/product-info/product-info.component.ts @@ -4,11 +4,14 @@ import { computed, input, } from '@angular/core'; -import { Product } from '@isa/common/data-access'; import { ProductImageDirective } from '@isa/shared/product-image'; import { ProductRouterLinkDirective } from '@isa/shared/product-router-link'; -export type ProductInfoItem = Pick; +export type ProductInfoItem = { + ean?: string; + name?: string; + contributors?: string; +}; export type ProductNameSize = 'small' | 'medium' | 'large'; diff --git a/libs/common/data-access/src/lib/models/index.ts b/libs/common/data-access/src/lib/models/index.ts index bd065c5e2..bc73dceb1 100644 --- a/libs/common/data-access/src/lib/models/index.ts +++ b/libs/common/data-access/src/lib/models/index.ts @@ -9,7 +9,6 @@ export * from './order-type-feature'; export * from './payer-type'; export * from './price-value'; export * from './price'; -export * from './product'; export * from './response-args'; export * from './return-value'; export * from './vat-type'; diff --git a/libs/common/data-access/src/lib/models/product.ts b/libs/common/data-access/src/lib/models/product.ts deleted file mode 100644 index a5f551f3d..000000000 --- a/libs/common/data-access/src/lib/models/product.ts +++ /dev/null @@ -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; diff --git a/libs/crm/data-access/src/lib/resources/customer-bonus-cards.resource.ts b/libs/crm/data-access/src/lib/resources/customer-bonus-cards.resource.ts new file mode 100644 index 000000000..0144912ee --- /dev/null +++ b/libs/crm/data-access/src/lib/resources/customer-bonus-cards.resource.ts @@ -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(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 => { + 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); + } +} diff --git a/libs/crm/data-access/src/lib/resources/index.ts b/libs/crm/data-access/src/lib/resources/index.ts index 14c0fe36b..a74710f64 100644 --- a/libs/crm/data-access/src/lib/resources/index.ts +++ b/libs/crm/data-access/src/lib/resources/index.ts @@ -1,4 +1,5 @@ export * from './country.resource'; +export * from './customer-bonus-cards.resource'; export * from './customer-payer-address.resource'; export * from './primary-customer-card.resource'; export * from './customer-shipping-address.resource'; diff --git a/libs/crm/data-access/src/lib/schemas/customer.schema.ts b/libs/crm/data-access/src/lib/schemas/customer.schema.ts index 052d0d6d7..e49ea741b 100644 --- a/libs/crm/data-access/src/lib/schemas/customer.schema.ts +++ b/libs/crm/data-access/src/lib/schemas/customer.schema.ts @@ -91,6 +91,7 @@ export const CustomerSchema = z .describe('User information') .optional(), }) - .extend(EntitySchema.shape); + .extend(EntitySchema.shape) + .describe('Customer'); export type Customer = z.infer; diff --git a/libs/crm/feature/customer-loyalty-cards/README.md b/libs/crm/feature/customer-loyalty-cards/README.md new file mode 100644 index 000000000..c79463991 --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/README.md @@ -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. diff --git a/libs/crm/feature/customer-loyalty-cards/eslint.config.cjs b/libs/crm/feature/customer-loyalty-cards/eslint.config.cjs new file mode 100644 index 000000000..86877dc4b --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/eslint.config.cjs @@ -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: {}, + }, +]; diff --git a/libs/crm/feature/customer-loyalty-cards/project.json b/libs/crm/feature/customer-loyalty-cards/project.json new file mode 100644 index 000000000..02edc572b --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/project.json @@ -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" + } + } +} diff --git a/libs/crm/feature/customer-loyalty-cards/src/index.ts b/libs/crm/feature/customer-loyalty-cards/src/index.ts new file mode 100644 index 000000000..bcb8466f9 --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/src/index.ts @@ -0,0 +1 @@ +export * from './lib/customer-loyalty-cards.component'; diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card-points-summary/customer-card-points-summary.component.css b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card-points-summary/customer-card-points-summary.component.css new file mode 100644 index 000000000..eadbb9078 --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card-points-summary/customer-card-points-summary.component.css @@ -0,0 +1 @@ +/* Points summary styles - using Tailwind, no additional CSS needed */ diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card-points-summary/customer-card-points-summary.component.html b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card-points-summary/customer-card-points-summary.component.html new file mode 100644 index 000000000..dea052825 --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card-points-summary/customer-card-points-summary.component.html @@ -0,0 +1,20 @@ +
+ +

+ Lesepunkte: {{ formattedPoints() }} +

+ + + +
diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card-points-summary/customer-card-points-summary.component.spec.ts b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card-points-summary/customer-card-points-summary.component.spec.ts new file mode 100644 index 000000000..4e95679ea --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card-points-summary/customer-card-points-summary.component.spec.ts @@ -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; + + 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); + }); + }); +}); diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card-points-summary/customer-card-points-summary.component.ts b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card-points-summary/customer-card-points-summary.component.ts new file mode 100644 index 000000000..b99d3c513 --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card-points-summary/customer-card-points-summary.component.ts @@ -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 + * + * ``` + */ +@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(); + + /** + * Emitted when user clicks "Zum Prämienshop" button. + */ + readonly navigateToPraemienshop = output(); + + /** + * 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(); + } +} diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card-points-summary/index.ts b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card-points-summary/index.ts new file mode 100644 index 000000000..4d7797376 --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card-points-summary/index.ts @@ -0,0 +1 @@ +export * from './customer-card-points-summary.component'; diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/customer-card.component.css b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/customer-card.component.css new file mode 100644 index 000000000..70302ac13 --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/customer-card.component.css @@ -0,0 +1 @@ +/* Customer card styles - using Tailwind, no additional CSS needed */ diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/customer-card.component.html b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/customer-card.component.html new file mode 100644 index 000000000..ed1a9ba21 --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/customer-card.component.html @@ -0,0 +1,57 @@ + +
+ +
+
+ +
+ {{ card().isPrimary ? 'Kundenkarte Nr.:' : 'Mitarbeitendenkarte Nr.:' }} +
+ + +
+ {{ card().code }} +
+
+ + +
+ +
+ Barcode: {{ card().code }} +
+
+
+ + +
+
+ {{ card().firstName }} {{ card().lastName }} Carsten Sievers +
+
+ + + @if (!card().isActive) { +
+ } +
diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/customer-card.component.spec.ts b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/customer-card.component.spec.ts new file mode 100644 index 000000000..05a51a7f7 --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/customer-card.component.spec.ts @@ -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; + + 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'); + }); + }); +}); diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/customer-card.component.ts b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/customer-card.component.ts new file mode 100644 index 000000000..4c5d4d8a4 --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/customer-card.component.ts @@ -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 + * + * ``` + */ +@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(); +} diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/index.ts b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/index.ts new file mode 100644 index 000000000..60cdce787 --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/index.ts @@ -0,0 +1 @@ +export * from './customer-card.component'; diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.css b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.css new file mode 100644 index 000000000..8c8927774 --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.css @@ -0,0 +1 @@ +/* Carousel container styles - using Tailwind and ui-carousel, no additional CSS needed */ diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.html b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.html new file mode 100644 index 000000000..4686bfe7d --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.html @@ -0,0 +1,12 @@ +
+ + + @for (card of sortedCards(); track card.code) { + + } + +
diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.spec.ts b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.spec.ts new file mode 100644 index 000000000..cefca2179 --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.spec.ts @@ -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; + + 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 + }); + }); +}); diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.ts b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.ts new file mode 100644 index 000000000..bb84a6bc2 --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.ts @@ -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 + * + * ``` + */ +@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(); + + /** + * 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; + }); + }); +} diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/index.ts b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/index.ts new file mode 100644 index 000000000..8bec4db87 --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/index.ts @@ -0,0 +1 @@ +export * from './customer-cards-carousel.component'; diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/customer-loyalty-cards.component.css b/libs/crm/feature/customer-loyalty-cards/src/lib/customer-loyalty-cards.component.css new file mode 100644 index 000000000..e69de29bb diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/customer-loyalty-cards.component.html b/libs/crm/feature/customer-loyalty-cards/src/lib/customer-loyalty-cards.component.html new file mode 100644 index 000000000..71902195b --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/customer-loyalty-cards.component.html @@ -0,0 +1,54 @@ +
+ + @if (isLoading()) { +
+ Kundenkarten werden geladen... +
+ } + + + @if (error(); as error) { +
+ Fehler beim Laden der Karten: {{ error.message }} +
+ } + + + @if (cards(); as cardList) { + + + + + + + +
+ + + +
+ } +
diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/customer-loyalty-cards.component.ts b/libs/crm/feature/customer-loyalty-cards/src/lib/customer-loyalty-cards.component.ts new file mode 100644 index 000000000..fabf05a2e --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/customer-loyalty-cards.component.ts @@ -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 + * + * ``` + */ +@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({ + transform: coerceNumberProperty, + }); + + /** + * Tab ID where this component is used. + * Used for telemetry/logging purposes. + */ + readonly tabId = input.required({ + 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`]); + } +} diff --git a/libs/crm/feature/customer-loyalty-cards/src/test-setup.ts b/libs/crm/feature/customer-loyalty-cards/src/test-setup.ts new file mode 100644 index 000000000..707451f37 --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/src/test-setup.ts @@ -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 {} +}; diff --git a/libs/crm/feature/customer-loyalty-cards/tsconfig.json b/libs/crm/feature/customer-loyalty-cards/tsconfig.json new file mode 100644 index 000000000..06f8b89a6 --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/tsconfig.json @@ -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" + } + ] +} diff --git a/libs/crm/feature/customer-loyalty-cards/tsconfig.lib.json b/libs/crm/feature/customer-loyalty-cards/tsconfig.lib.json new file mode 100644 index 000000000..9259117c2 --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/tsconfig.lib.json @@ -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"] +} diff --git a/libs/crm/feature/customer-loyalty-cards/tsconfig.spec.json b/libs/crm/feature/customer-loyalty-cards/tsconfig.spec.json new file mode 100644 index 000000000..b2f92f3ec --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/tsconfig.spec.json @@ -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"] +} diff --git a/libs/crm/feature/customer-loyalty-cards/vite.config.mts b/libs/crm/feature/customer-loyalty-cards/vite.config.mts new file mode 100644 index 000000000..04f11498e --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/vite.config.mts @@ -0,0 +1,41 @@ +/// +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'], + }, + }, +})); diff --git a/libs/ui/carousel/src/lib/_carousel.scss b/libs/ui/carousel/src/lib/_carousel.scss index e6fdfa0b9..02ad71663 100644 --- a/libs/ui/carousel/src/lib/_carousel.scss +++ b/libs/ui/carousel/src/lib/_carousel.scss @@ -27,6 +27,7 @@ overflow-y: visible; // Allow vertical overflow to be visible scroll-behavior: smooth; width: 100%; + box-sizing: border-box; // Padding is inset, maintains original size // Hide scrollbar while maintaining scroll functionality scrollbar-width: none; // Firefox diff --git a/libs/ui/carousel/src/lib/carousel.component.html b/libs/ui/carousel/src/lib/carousel.component.html index c7a6ed128..21f2df0af 100644 --- a/libs/ui/carousel/src/lib/carousel.component.html +++ b/libs/ui/carousel/src/lib/carousel.component.html @@ -18,6 +18,7 @@ #scrollContainer class="ui-carousel__container" [style.gap]="gap()" + [style.padding]="padding()" > diff --git a/libs/ui/carousel/src/lib/carousel.component.ts b/libs/ui/carousel/src/lib/carousel.component.ts index e209928d8..68376beb9 100644 --- a/libs/ui/carousel/src/lib/carousel.component.ts +++ b/libs/ui/carousel/src/lib/carousel.component.ts @@ -31,6 +31,7 @@ export class CarouselComponent { // Input signals gap = input('1rem'); arrowAutoHide = input(true); + padding = input('0'); // View child for scroll container scrollContainer = diff --git a/tsconfig.base.json b/tsconfig.base.json index b6a004658..a68ceace0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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-loyalty-cards": [ + "libs/crm/feature/customer-loyalty-cards/src/index.ts" + ], "@isa/icons": ["libs/icons/src/index.ts"], "@isa/oms/data-access": ["libs/oms/data-access/src/index.ts"], "@isa/oms/feature/return-details": [