From a5bb8b289562e6579fa121b5f385971ed5a59670 Mon Sep 17 00:00:00 2001 From: Lorenz Hilpert Date: Thu, 27 Nov 2025 16:28:06 +0000 Subject: [PATCH] Merged PR 2058: feat(crm): customer card copy-to-clipboard and carousel improvements Customer Card Copy-to-Clipboard (#5508) - Click on card number copies it to clipboard using Angular CDK Clipboard - Shows success tooltip confirmation positioned on the right - Tooltip auto-dismisses after 3 seconds Card Stack Carousel Improvements (#5509) - Fix card centering by using afterNextRender instead of AfterViewInit - Add ResizeObserver to handle dynamic size changes - Disable transforms until natural position is measured (prevents initial jump) - Center single card in carousel view Tooltip Enhancements - Add success variant with green styling (isa-accent-green) - Add position input (left | right | top | bottom) - Add fade in/out CSS keyframes animations (150ms) - Respect prefers-reduced-motion for accessibility Related Tasks - Closes #5508 - Refs #5509 --- .../customer-card.component.html | 15 ++- .../customer-card/customer-card.component.ts | 24 +++- .../card-stack-distance.directive.ts | 39 +++--- .../customer-cards-carousel.component.css | 3 +- .../customer-cards-carousel.component.html | 3 +- .../customer-cards-carousel.component.spec.ts | 27 ++++ .../customer-cards-carousel.component.ts | 16 ++- libs/ui/tooltip/src/lib/tooltip.component.ts | 17 ++- libs/ui/tooltip/src/lib/tooltip.directive.ts | 121 +++++++++++------- libs/ui/tooltip/src/tooltip.scss | 59 ++++++++- 10 files changed, 250 insertions(+), 74 deletions(-) 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 index 138b169ba..f706af651 100644 --- 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 @@ -15,8 +15,19 @@ Kundenkarte Nr.: - -
+ +
{{ card().code }}
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 index 910fba544..f2c90ec99 100644 --- 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 @@ -1,11 +1,15 @@ +import { Clipboard } from '@angular/cdk/clipboard'; import { ChangeDetectionStrategy, Component, + inject, input, output, + viewChild, } from '@angular/core'; import { BonusCardInfo } from '@isa/crm/data-access'; import { BarcodeComponent } from '@isa/shared/barcode'; +import { TooltipDirective } from '@isa/ui/tooltip'; import { LockCustomerCardComponent } from '../lock-customer-card/lock-customer-card.component'; /** @@ -29,12 +33,16 @@ import { LockCustomerCardComponent } from '../lock-customer-card/lock-customer-c */ @Component({ selector: 'crm-customer-card', + standalone: true, templateUrl: './customer-card.component.html', styleUrl: './customer-card.component.css', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [BarcodeComponent, LockCustomerCardComponent], + imports: [BarcodeComponent, LockCustomerCardComponent, TooltipDirective], }) export class CustomerCardComponent { + readonly #clipboard = inject(Clipboard); + private readonly copyTooltip = viewChild('copyTooltip'); + /** * Bonus card data to display. */ @@ -53,4 +61,18 @@ export class CustomerCardComponent { protected readonly barcodeHeight = 4.5 * 16; // 4.5rem = 72px protected readonly barcodeWidth = 0.125 * 16; // 0.125rem = 2px protected readonly barcodeMargin = 0.5 * 16; // 0.5rem = 8px + + /** + * Copies the card code to the clipboard and shows a tooltip confirmation. + */ + copyCode(event: Event): void { + event.stopPropagation(); + this.#clipboard.copy(this.card().code); + + const tooltip = this.copyTooltip(); + if (tooltip) { + tooltip.show(); + setTimeout(() => tooltip.hide(), 3000); + } + } } diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/card-stack-distance.directive.ts b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/card-stack-distance.directive.ts index e52bafce3..946b18348 100644 --- a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/card-stack-distance.directive.ts +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/card-stack-distance.directive.ts @@ -1,10 +1,10 @@ import { - AfterViewInit, + afterNextRender, computed, Directive, - effect, ElementRef, inject, + OnDestroy, signal, } from '@angular/core'; import { CardStackContainerDirective } from './card-stack-container.directive'; @@ -13,12 +13,17 @@ import { CardStackContainerDirective } from './card-stack-container.directive'; selector: '[crmCardStackDistance]', host: { '[style.--distance-to-center.px]': 'distanceToCenter()', + // Disable ALL transforms until we've measured the natural position + '[style.transform]': 'ready() ? null : "none"', }, }) -export class CardStackDistanceDirective implements AfterViewInit { +export class CardStackDistanceDirective implements OnDestroy { readonly container = inject(CardStackContainerDirective, { host: true }); readonly elementRef = inject(ElementRef); + /** True once we've measured the card's natural position */ + readonly ready = signal(false); + distanceToCenter = computed(() => { const containerCenterX = this.container.centerX(); const centerX = this.centerX(); @@ -26,26 +31,26 @@ export class CardStackDistanceDirective implements AfterViewInit { }); centerX = signal(0); + private resizeObserver?: ResizeObserver; constructor() { - // Recalculate position when container center changes - effect(() => { - // React to container center changes - const containerCenter = this.container.centerX(); + // Wait for Angular to complete rendering and layout before calculating position + afterNextRender(() => { + // Measure natural position (with --distance-to-center: 0, card is in natural spot) + this.centerX.set(this.getHorizontalCenter()); + // Now enable the transform + this.ready.set(true); - // Recalculate this card's center position - if (containerCenter !== 0) { - // Use requestAnimationFrame to ensure DOM is stable - requestAnimationFrame(() => { - this.centerX.set(this.getHorizontalCenter()); - }); - } + // Watch for this card's size changes + this.resizeObserver = new ResizeObserver(() => { + this.centerX.set(this.getHorizontalCenter()); + }); + this.resizeObserver.observe(this.elementRef.nativeElement); }); } - ngAfterViewInit(): void { - // Initial calculation - this.centerX.set(this.getHorizontalCenter()); + ngOnDestroy(): void { + this.resizeObserver?.disconnect(); } getHorizontalCenter(): number { 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 index 053e67ba8..f051f4639 100644 --- 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 @@ -35,7 +35,8 @@ crm-customer-card { } /* Stacked state transformations */ -.stacked crm-customer-card { +.stacked crm-customer-card, +.single-card crm-customer-card { transform: translateX(var(--distance-to-center)); } 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 index e7823d5d7..7b74ce2cc 100644 --- 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 @@ -2,10 +2,11 @@ @for (card of sortedCards(); track card.code; let idx = $index) { { }); }); + describe('singleCard computed signal', () => { + it('should return true when there is exactly one 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(); + + expect(component.singleCard()).toBe(true); + }); + + it('should return false when there are multiple cards', () => { + fixture.componentRef.setInput('cards', mockCards); + fixture.detectChanges(); + + expect(component.singleCard()).toBe(false); + }); + + it('should return false when there are no cards', () => { + fixture.componentRef.setInput('cards', []); + fixture.detectChanges(); + + expect(component.singleCard()).toBe(false); + }); + }); + describe('sortedCards computed signal', () => { it('should sort cards with active cards first', () => { fixture.componentRef.setInput('cards', mockCards); 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 index ed60a0481..0797c477e 100644 --- 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 @@ -38,13 +38,25 @@ import { CardStackDistanceDirective } from './card-stack-distance.directive'; }, }) export class CustomerCardsCarouselComponent { - stacked = signal(true); + #stacked = signal(true); /** * All bonus cards to display in carousel. */ readonly cards = input.required(); + stacked = computed(() => { + const cards = this.cards(); + const stacked = this.#stacked(); + + return stacked && cards.length > 1; + }); + + /** + * True when there is exactly one card - disables carousel scrolling. + */ + readonly singleCard = computed(() => this.cards().length === 1); + /** * Event emitted when a card has been successfully locked or unlocked. * Parent should reload cards and transactions. @@ -74,6 +86,6 @@ export class CustomerCardsCarouselComponent { }); unStack(): void { - this.stacked.set(false); + this.#stacked.set(false); } } diff --git a/libs/ui/tooltip/src/lib/tooltip.component.ts b/libs/ui/tooltip/src/lib/tooltip.component.ts index 2661accab..715260674 100644 --- a/libs/ui/tooltip/src/lib/tooltip.component.ts +++ b/libs/ui/tooltip/src/lib/tooltip.component.ts @@ -36,15 +36,26 @@ export class TooltipComponent { /** * The visual variant of the tooltip. */ - variant = signal<'default' | 'warning'>('default'); + variant = signal<'default' | 'warning' | 'success'>('default'); /** - * Computed classes for the tooltip based on variant. + * Whether the tooltip is in the leaving state (playing exit animation). + */ + leaving = signal(false); + + /** + * Computed classes for the tooltip based on variant and leaving state. */ tooltipClasses = computed(() => { const classes = ['ui-tooltip']; - if (this.variant() === 'warning') { + const v = this.variant(); + if (v === 'warning') { classes.push('ui-tooltip--warning'); + } else if (v === 'success') { + classes.push('ui-tooltip--success'); + } + if (this.leaving()) { + classes.push('ui-tooltip--leaving'); } return classes.join(' '); }); diff --git a/libs/ui/tooltip/src/lib/tooltip.directive.ts b/libs/ui/tooltip/src/lib/tooltip.directive.ts index cddfff2d3..894cdb72c 100644 --- a/libs/ui/tooltip/src/lib/tooltip.directive.ts +++ b/libs/ui/tooltip/src/lib/tooltip.directive.ts @@ -88,9 +88,12 @@ export class TooltipDirective implements OnDestroy { #tooltipInstance: TooltipComponent | null = null; #openTrigger: TooltipTrigger | null = null; // Tracks which trigger opened the tooltip #isOpen = signal(false); // Internal signal to track open state for CloseOnScrollDirective + #isHiding = false; // Tracks if tooltip is currently animating out // Distance between tooltip and anchor element readonly #offset = 8; // 0.5rem = 8px + // Animation duration in ms (must match CSS) + readonly #animationDuration = 150; /** Optional title for the tooltip. */ title = input(); @@ -111,7 +114,13 @@ export class TooltipDirective implements OnDestroy { * Visual variant of the tooltip. * Defaults to 'default'. */ - variant = input<'default' | 'warning'>('default'); + variant = input<'default' | 'warning' | 'success'>('default'); + + /** + * Preferred position of the tooltip relative to the host element. + * Defaults to 'left'. Falls back to other positions if preferred doesn't fit. + */ + position = input<'left' | 'right' | 'top' | 'bottom'>('left'); constructor() { // Set up effects to update tooltip instance when inputs change @@ -155,45 +164,54 @@ export class TooltipDirective implements OnDestroy { /** * Calculates the connected positions for the tooltip overlay. - * The preferred position is to the left-center of the host element. - * Fallback positions are right-center, bottom, and top. + * Orders positions based on the preferred position input. * @returns An array of `ConnectedPosition` objects. */ #getPositions(): ConnectedPosition[] { - return [ - { - // Left-center position (default/preferred) - originX: 'start', - originY: 'center', - overlayX: 'end', - overlayY: 'center', - offsetX: -this.#offset, - }, - { - // Right-center position - originX: 'end', - originY: 'center', - overlayX: 'start', - overlayY: 'center', - offsetX: this.#offset, - }, - { - // Bottom position - originX: 'start', - originY: 'bottom', - overlayX: 'start', - overlayY: 'top', - offsetY: this.#offset, - }, - { - // Top position - originX: 'start', - originY: 'top', - overlayX: 'start', - overlayY: 'bottom', - offsetY: -this.#offset, - }, - ]; + const left: ConnectedPosition = { + originX: 'start', + originY: 'center', + overlayX: 'end', + overlayY: 'center', + offsetX: -this.#offset, + }; + + const right: ConnectedPosition = { + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center', + offsetX: this.#offset, + }; + + const bottom: ConnectedPosition = { + originX: 'center', + originY: 'bottom', + overlayX: 'center', + overlayY: 'top', + offsetY: this.#offset, + }; + + const top: ConnectedPosition = { + originX: 'center', + originY: 'top', + overlayX: 'center', + overlayY: 'bottom', + offsetY: -this.#offset, + }; + + // Order positions based on preferred position + switch (this.position()) { + case 'right': + return [right, left, bottom, top]; + case 'top': + return [top, bottom, left, right]; + case 'bottom': + return [bottom, top, left, right]; + case 'left': + default: + return [left, right, bottom, top]; + } } /** * Shows the tooltip. @@ -259,9 +277,10 @@ export class TooltipDirective implements OnDestroy { /** * Hides the tooltip only if the trigger matches the open trigger. + * Plays fade-out animation before detaching. */ hide(trigger?: TooltipTrigger) { - if (!this.#overlayRef) { + if (!this.#overlayRef || this.#isHiding) { return; } @@ -284,14 +303,26 @@ export class TooltipDirective implements OnDestroy { } } - this.#overlayRef.detach(); - this.#overlayRef.dispose(); - this.#overlayRef = null; - this.#tooltipInstance = null; - this.#openTrigger = null; + // Start exit animation + this.#isHiding = true; + if (this.#tooltipInstance) { + this.#tooltipInstance.leaving.set(true); + } - // Update open state for CloseOnScrollDirective - this.#isOpen.set(false); + // Wait for animation to complete before detaching + setTimeout(() => { + if (this.#overlayRef) { + this.#overlayRef.detach(); + this.#overlayRef.dispose(); + this.#overlayRef = null; + } + this.#tooltipInstance = null; + this.#openTrigger = null; + this.#isHiding = false; + + // Update open state for CloseOnScrollDirective + this.#isOpen.set(false); + }, this.#animationDuration); } /** diff --git a/libs/ui/tooltip/src/tooltip.scss b/libs/ui/tooltip/src/tooltip.scss index 84981f3a5..35243ed84 100644 --- a/libs/ui/tooltip/src/tooltip.scss +++ b/libs/ui/tooltip/src/tooltip.scss @@ -23,6 +23,17 @@ shadow-[0px_2px_8px_0px_rgba(223,0,27,0.15)]; } +.ui-tooltip--success { + @apply w-auto + max-w-[12rem] + p-3 + rounded-lg + bg-green-50 + border + border-isa-accent-green + shadow-[0px_2px_8px_0px_rgba(0,128,0,0.15)]; +} + .ui-tooltip-title { @apply isa-text-body-2-bold text-isa-neutral-900; } @@ -39,6 +50,14 @@ @apply text-isa-accent-red text-sm; } +.ui-tooltip--success .ui-tooltip-title { + @apply text-isa-accent-green; +} + +.ui-tooltip--success .ui-tooltip-content { + @apply text-isa-accent-green text-sm; +} + /* Global styles for tooltip overlay container These styles will be injected into the global stylesheet @@ -48,10 +67,46 @@ pointer-events: none; /* Allow clicks to pass through */ } +/* Keyframes for tooltip animations */ +@keyframes tooltipFadeIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes tooltipFadeOut { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.95); + } +} + /* Animation for tooltip appearance */ .ui-tooltip { - opacity: 1; - transition: opacity 150ms ease-in-out; + animation: tooltipFadeIn 0.15s ease-out; +} + +.ui-tooltip--leaving { + animation: tooltipFadeOut 0.15s ease-in forwards; +} + +/* Respect reduced motion preferences */ +@media (prefers-reduced-motion: reduce) { + .ui-tooltip { + animation: none; + } + .ui-tooltip--leaving { + animation: none; + } } .ui-tooltip-icon {