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.:
-
-
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 {