Compare commits

...

7 Commits

Author SHA1 Message Date
Nino
14be1365bd fix(crm-loyalty-cards): Show always Points of first Card 2025-12-03 15:59:08 +01:00
Nino Righi
d5324675ef Merged PR 2067: fix(ui-layout): Ipad Dropdown Scrolling Fix
fix(ui-layout): Ipad Dropdown Scrolling Fix
2025-12-03 14:16:21 +00:00
Nino
aa57d27924 fix(oms-return-details): Label Unknown Fix
Ref: #5513
2025-12-02 17:20:56 +01:00
Nino
5f94549539 fix(oms-return-details): Dropdown Label and Select Bullet Styling Adjustments
Ref: #5513
2025-12-02 16:32:57 +01:00
Nino Righi
aee63711e4 Merged PR 2066: fix(ui-layout, input-controls-dropdown, oms-return-details): prevent stale sc...
fix(ui-layout, input-controls-dropdown, oms-return-details): prevent stale scroll events from closing dropdown on open

Delay scroll listener registration using requestAnimationFrame when
activating CloseOnScrollDirective. This prevents stale scroll events
still in the event queue from immediately triggering closeOnScroll
when opening the dropdown after scrolling.

Also adds conditional rendering for product format and publication date
in return-details-order-group-item component.

Refs: #5513
2025-12-02 14:02:32 +00:00
Nino Righi
ee9f030a99 Merged PR 2062: fix(isa-app-customer): Clear Navigation State Context if Customer Area gets d...
fix(isa-app-customer): Clear Navigation State Context if Customer Area gets destroyed

Ref: #5512
2025-12-01 11:25:11 +00:00
Nino Righi
7884e1af32 Merged PR 2059: feature(ui-modal): add QR code display for URLs in dialog modals
feature(ui-modal): add QR code display for URLs in dialog modals

Add automatic URL detection and QR code rendering in dialog modals:
- Parse dialog content to extract URLs (http/https)
- Display extracted URLs as QR codes using angularx-qrcode library
- Split content around URL to show text before and after the QR code
- Auto-detect URLs by default, with optional showUrlAsQrCode override
- Add comprehensive unit tests for URL parsing helpers

Ref: #5511
2025-11-27 16:41:51 +00:00
19 changed files with 613 additions and 121 deletions

3
.gitignore vendored
View File

@@ -80,3 +80,6 @@ CLAUDE.md
*.pyc
.vite
reports/
# Local iPad dev setup (proxy)
/local-dev/

View File

@@ -1,5 +1,11 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import {
Component,
ChangeDetectionStrategy,
OnDestroy,
inject,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { NavigationStateService } from '@isa/core/navigation';
import { map } from 'rxjs/operators';
@Component({
@@ -9,8 +15,14 @@ import { map } from 'rxjs/operators';
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class CustomerComponent {
export class CustomerComponent implements OnDestroy {
private _navigationState = inject(NavigationStateService);
processId$ = this._activatedRoute.data.pipe(map((data) => data.processId));
constructor(private _activatedRoute: ActivatedRoute) {}
async ngOnDestroy() {
// #5512 Always clear preserved select-customer context if navigating out of customer area
await this._navigationState.clearPreservedContext('select-customer');
}
}

View File

@@ -1,16 +1,43 @@
@if (modalRef.data.subtitle; as subtitle) {
<h2 class="subtitle">{{ subtitle }}</h2>
}
@if (modalRef.data.content; as content) {
<p class="content">
{{ content }}
</p>
<!-- QR Code Display Mode -->
@if (shouldShowQrCode(); as showQr) {
@if (parsedContent(); as parsed) {
@if (parsed.textBefore) {
<p class="content">{{ parsed.textBefore }}</p>
}
<div class="qr-code-container">
<qrcode
[qrdata]="parsed.url!"
[width]="200"
[errorCorrectionLevel]="'M'"
[margin]="2"
></qrcode>
</div>
@if (parsed.textAfter) {
<p class="content">{{ parsed.textAfter }}</p>
}
}
} @else {
<!-- Default Text Display Mode -->
@if (modalRef.data.content; as content) {
<p class="content">
{{ content }}
</p>
}
}
@if (modalRef.data.actions; as actions) {
<div class="actions">
@for (action of actions; track action) {
<button [class.selected]="action.selected" (click)="handleCommand(action.command)">
<button
[class.selected]="action.selected"
(click)="handleCommand(action.command)"
>
{{ action.label }}
</button>
}

View File

@@ -15,11 +15,15 @@
@apply text-lg text-center whitespace-pre-wrap mb-8 px-16;
}
.qr-code-container {
@apply flex flex-col items-center justify-center mb-8;
}
.actions {
@apply text-center mb-8;
button {
@apply border-2 border-solid border-brand bg-white text-brand rounded-full py-3 px-6 font-bold text-lg outline-none self-end whitespace-nowrap ml-4;
@apply border-2 border-solid border-brand bg-white text-brand rounded-full py-3 px-6 font-bold text-lg outline-none self-end whitespace-nowrap;
&.selected {
@apply bg-brand text-white;

View File

@@ -1,7 +1,8 @@
import { Component, OnInit } from '@angular/core';
import { Component, computed, OnInit } from '@angular/core';
import { CommandService } from '@core/command';
import { UiModalRef } from '../defs/modal-ref';
import { DialogModel } from './dialog.model';
import { parseDialogContentForUrl } from './dialog.helper';
@Component({
selector: 'ui-dialog-modal',
@@ -10,6 +11,26 @@ import { DialogModel } from './dialog.model';
standalone: false,
})
export class UiDialogModalComponent implements OnInit {
/**
* Parsed content with URL extracted for QR code display.
* Only relevant when showUrlAsQrCode is true.
*/
readonly parsedContent = computed(() => {
const data = this.modalRef.data;
if (!data.showUrlAsQrCode) {
return null;
}
return parseDialogContentForUrl(data.content);
});
/**
* Whether to show the QR code instead of the URL text.
*/
readonly shouldShowQrCode = computed(() => {
const parsed = this.parsedContent();
return parsed !== null && parsed.url !== null;
});
constructor(
public modalRef: UiModalRef<any, DialogModel<any>>,
private _command: CommandService,

View File

@@ -0,0 +1,48 @@
import { ParsedDialogContent } from './dialog.model';
/**
* Regular expression to match URLs in text.
* Matches http:// and https:// URLs.
*/
const URL_REGEX = /https?:\/\/[^\s]+/i;
/**
* Parses the dialog content and extracts the first URL.
* Splits the content into text before the URL, the URL itself, and text after.
*
* @param content - The dialog content string to parse
* @returns ParsedDialogContent with the split content
*/
export const parseDialogContentForUrl = (
content: string | undefined,
): ParsedDialogContent => {
if (!content) {
return { textBefore: '', url: null, textAfter: '' };
}
const match = content.match(URL_REGEX);
if (!match || match.index === undefined) {
return { textBefore: content, url: null, textAfter: '' };
}
const url = match[0];
const urlIndex = match.index;
const textBefore = content.substring(0, urlIndex).trim();
const textAfter = content.substring(urlIndex + url.length).trim();
return { textBefore, url, textAfter };
};
/**
* Checks if the given content contains a URL.
*
* @param content - The content string to check
* @returns true if a URL is found, false otherwise
*/
export const contentHasUrl = (content: string | undefined): boolean => {
if (!content) {
return false;
}
return URL_REGEX.test(content);
};

View File

@@ -0,0 +1,152 @@
import { contentHasUrl, parseDialogContentForUrl } from './dialog.helper';
import { ParsedDialogContent } from './dialog.model';
describe('parseDialogContentForUrl', () => {
it('should return empty result for undefined content', () => {
const result = parseDialogContentForUrl(undefined);
expect(result).toEqual<ParsedDialogContent>({
textBefore: '',
url: null,
textAfter: '',
});
});
it('should return empty result for empty string', () => {
const result = parseDialogContentForUrl('');
expect(result).toEqual<ParsedDialogContent>({
textBefore: '',
url: null,
textAfter: '',
});
});
it('should return content as textBefore when no URL is found', () => {
const content = 'This is some text without a URL';
const result = parseDialogContentForUrl(content);
expect(result).toEqual<ParsedDialogContent>({
textBefore: content,
url: null,
textAfter: '',
});
});
it('should extract https URL from content', () => {
const content = 'Text before https://example.com text after';
const result = parseDialogContentForUrl(content);
expect(result).toEqual<ParsedDialogContent>({
textBefore: 'Text before',
url: 'https://example.com',
textAfter: 'text after',
});
});
it('should extract http URL from content', () => {
const content = 'Text before http://example.com text after';
const result = parseDialogContentForUrl(content);
expect(result).toEqual<ParsedDialogContent>({
textBefore: 'Text before',
url: 'http://example.com',
textAfter: 'text after',
});
});
it('should handle URL at the beginning of content', () => {
const content = 'https://example.com/path text after';
const result = parseDialogContentForUrl(content);
expect(result).toEqual<ParsedDialogContent>({
textBefore: '',
url: 'https://example.com/path',
textAfter: 'text after',
});
});
it('should handle URL at the end of content', () => {
const content = 'Text before https://example.com/path';
const result = parseDialogContentForUrl(content);
expect(result).toEqual<ParsedDialogContent>({
textBefore: 'Text before',
url: 'https://example.com/path',
textAfter: '',
});
});
it('should handle real-world content with newlines', () => {
const content = `Punkte: 80500
Um alle Vorteile der Kundenkarte nutzen zu können, ist eine Verknüpfung zu einem Online-Konto notwendig. Kund:innen können sich über den QR-Code selbstständig anmelden oder die Kundenkarte dem bestehendem Konto hinzufügen. Bereits gesammelte Punkte werden übernommen.
https://h-k.me/QOHNTFVA`;
const result = parseDialogContentForUrl(content);
expect(result.url).toBe('https://h-k.me/QOHNTFVA');
expect(result.textBefore).toContain('Punkte: 80500');
expect(result.textBefore).toContain(
'Bereits gesammelte Punkte werden übernommen.',
);
expect(result.textAfter).toBe('');
});
it('should extract only the first URL when multiple URLs are present', () => {
const content = 'First https://first.com then https://second.com';
const result = parseDialogContentForUrl(content);
expect(result).toEqual<ParsedDialogContent>({
textBefore: 'First',
url: 'https://first.com',
textAfter: 'then https://second.com',
});
});
it('should handle URLs with paths and query parameters', () => {
const content =
'Visit https://example.com/path?query=value&foo=bar for more';
const result = parseDialogContentForUrl(content);
expect(result.url).toBe('https://example.com/path?query=value&foo=bar');
expect(result.textBefore).toBe('Visit');
expect(result.textAfter).toBe('for more');
});
});
describe('contentHasUrl', () => {
it('should return false for undefined content', () => {
expect(contentHasUrl(undefined)).toBe(false);
});
it('should return false for empty string', () => {
expect(contentHasUrl('')).toBe(false);
});
it('should return false for content without URL', () => {
expect(contentHasUrl('This is text without a URL')).toBe(false);
});
it('should return true for content with https URL', () => {
expect(contentHasUrl('Check out https://example.com')).toBe(true);
});
it('should return true for content with http URL', () => {
expect(contentHasUrl('Check out http://example.com')).toBe(true);
});
it('should return true for real-world content', () => {
const content = `Punkte: 80500
Um alle Vorteile der Kundenkarte nutzen zu können...
https://h-k.me/QOHNTFVA`;
expect(contentHasUrl(content)).toBe(true);
});
});

View File

@@ -1,4 +1,7 @@
import { DialogSettings, KeyValueDTOOfStringAndString } from '@generated/swagger/crm-api';
import {
DialogSettings,
KeyValueDTOOfStringAndString,
} from '@generated/swagger/crm-api';
export interface DialogModel<T = any> {
actions?: Array<KeyValueDTOOfStringAndString>;
@@ -14,4 +17,21 @@ export interface DialogModel<T = any> {
* default: true
*/
handleCommand?: boolean;
/**
* If true, URLs in the content will be displayed as QR codes.
* default: false
*/
showUrlAsQrCode?: boolean;
}
/**
* Result of parsing content for URLs
*/
export interface ParsedDialogContent {
/** Text before the URL */
textBefore: string;
/** The extracted URL (if any) */
url: string | null;
/** Text after the URL */
textAfter: string;
}

View File

@@ -13,6 +13,7 @@ import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { DialogModel } from './dialog.model';
import { ToasterService } from '@shared/shell';
import { contentHasUrl } from './dialog.helper';
@Injectable()
export class OpenDialogInterceptor implements HttpInterceptor {
@@ -21,7 +22,10 @@ export class OpenDialogInterceptor implements HttpInterceptor {
private _toast: ToasterService,
) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
intercept(
req: HttpRequest<any>,
next: HttpHandler,
): Observable<HttpEvent<any>> {
return next.handle(req).pipe(
tap((response) => {
if (response instanceof HttpResponse) {
@@ -59,9 +63,17 @@ export class OpenDialogInterceptor implements HttpInterceptor {
}
openDialog(model: DialogModel<any>) {
// Auto-detect URLs and enable QR code display if URL is found
// Can be overridden by explicitly setting showUrlAsQrCode in the model
const showUrlAsQrCode =
model.showUrlAsQrCode ?? contentHasUrl(model.content);
this._modal.open({
content: UiDialogModalComponent,
data: model,
data: {
...model,
showUrlAsQrCode,
},
title: model.title,
config: {
canClose: (model.settings & 1) === 1,

View File

@@ -2,7 +2,6 @@ import { OverlayModule } from '@angular/cdk/overlay';
import { CommonModule } from '@angular/common';
import { ModuleWithProviders, NgModule } from '@angular/core';
import { UiModalComponent } from './modal.component';
import { UiModalService } from './modal.service';
import { UiDebugModalComponent } from './debug-modal/debug-modal.component';
import { UiMessageModalComponent } from './message-modal.component';
import { UiIconModule } from '@ui/icon';
@@ -10,9 +9,10 @@ import { UiDialogModalComponent } from './dialog/dialog-modal.component';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { OpenDialogInterceptor } from './dialog/open-dialog.interceptor';
import { UiPromptModalComponent } from './prompt-modal';
import { QRCodeComponent } from 'angularx-qrcode';
@NgModule({
imports: [CommonModule, OverlayModule, UiIconModule],
imports: [CommonModule, OverlayModule, UiIconModule, QRCodeComponent],
declarations: [
UiModalComponent,
UiDebugModalComponent,

View File

@@ -51,7 +51,7 @@ describe('CustomerCardPointsSummaryComponent', () => {
});
describe('formattedPoints computed signal', () => {
it('should display points from primary card', () => {
it('should display points from first card', () => {
fixture.componentRef.setInput('cards', mockCards);
fixture.detectChanges();
@@ -77,7 +77,7 @@ describe('CustomerCardPointsSummaryComponent', () => {
expect(component.formattedPoints()).toBe('123.456');
});
it('should display 0 when no primary card exists', () => {
it('should display points from first card regardless of isPrimary flag', () => {
const cardsWithoutPrimary: BonusCardInfo[] = [
{
code: 'CARD-1',
@@ -93,7 +93,7 @@ describe('CustomerCardPointsSummaryComponent', () => {
fixture.componentRef.setInput('cards', cardsWithoutPrimary);
fixture.detectChanges();
expect(component.formattedPoints()).toBe('0');
expect(component.formattedPoints()).toBe('1.500');
});
it('should display 0 when cards array is empty', () => {
@@ -122,14 +122,14 @@ describe('CustomerCardPointsSummaryComponent', () => {
expect(component.formattedPoints()).toBe('0');
});
it('should only use primary card points, not sum of all cards', () => {
it('should only use first card points, not sum of all cards', () => {
const multipleCards: BonusCardInfo[] = [
{
code: 'CARD-1',
firstName: 'John',
lastName: 'Doe',
isActive: true,
isPrimary: true,
isPrimary: false,
totalPoints: 1000,
cardNumber: '1234-5678-9012-3456',
} as BonusCardInfo,
@@ -138,7 +138,7 @@ describe('CustomerCardPointsSummaryComponent', () => {
firstName: 'John',
lastName: 'Doe',
isActive: true,
isPrimary: false,
isPrimary: true,
totalPoints: 500,
cardNumber: '9876-5432-1098-7654',
} as BonusCardInfo,
@@ -147,7 +147,7 @@ describe('CustomerCardPointsSummaryComponent', () => {
fixture.componentRef.setInput('cards', multipleCards);
fixture.detectChanges();
// Should be 1000, not 1500
// Should be 1000 (first card), not 500 (primary) or 1500 (sum)
expect(component.formattedPoints()).toBe('1.000');
});
});

View File

@@ -36,12 +36,11 @@ export class CustomerCardPointsSummaryComponent {
readonly navigateToPraemienshop = output<void>();
/**
* Total points from primary card, formatted with thousands separator.
* Total points from first card, formatted with thousands separator.
*/
readonly formattedPoints = computed(() => {
const cards = this.cards();
const primaryCard = cards.find((c) => c.isPrimary);
const points = primaryCard?.totalPoints ?? 0;
const points = cards?.[0]?.totalPoints ?? 0;
// Format with German thousands separator (dot)
return points.toLocaleString('de-DE');

View File

@@ -5,8 +5,8 @@
<ui-dropdown
class="quantity-dropdown"
[disabled]="!canReturnReceiptItem()"
[value]="selectedQuantity()"
(valueChange)="setQuantity($event)"
[ngModel]="selectedQuantity()"
(ngModelChange)="setQuantity($event)"
>
@for (quantity of quantityDropdownValues(); track quantity) {
<ui-dropdown-option [value]="quantity">{{
@@ -17,11 +17,11 @@
}
<ui-dropdown
label="Produktart"
[label]="dropdownLabel()"
class="product-dropdown"
[disabled]="!canReturnReceiptItem()"
[value]="productCategory()"
(valueChange)="setProductCategory($event)"
[ngModel]="productCategory()"
(ngModelChange)="setProductCategory($event)"
>
@for (kv of availableCategories; track kv.key) {
<ui-dropdown-option [value]="kv.key">{{ kv.value }}</ui-dropdown-option>
@@ -31,7 +31,7 @@
@if (canReturnReceiptItem()) {
@if (!canReturnResource.isLoading() && selectable()) {
<ui-checkbox appearance="bullet">
<ui-checkbox class="min-w-12" appearance="bullet">
<input
type="checkbox"
[ngModel]="selected()"

View File

@@ -5,27 +5,27 @@ import {
inject,
input,
signal,
} from "@angular/core";
import { provideLoggerContext } from "@isa/core/logging";
} from '@angular/core';
import { provideLoggerContext } from '@isa/core/logging';
import {
canReturnReceiptItem,
ProductCategory,
ReceiptItem,
ReturnDetailsService,
ReturnDetailsStore,
} from "@isa/oms/data-access";
import { IconButtonComponent } from "@isa/ui/buttons";
} from '@isa/oms/data-access';
import { IconButtonComponent } from '@isa/ui/buttons';
import {
CheckboxComponent,
DropdownButtonComponent,
DropdownOptionComponent,
} from "@isa/ui/input-controls";
import { FormsModule } from "@angular/forms";
} from '@isa/ui/input-controls';
import { FormsModule } from '@angular/forms';
@Component({
selector: "oms-feature-return-details-order-group-item-controls",
templateUrl: "./return-details-order-group-item-controls.component.html",
styleUrls: ["./return-details-order-group-item-controls.component.scss"],
selector: 'oms-feature-return-details-order-group-item-controls',
templateUrl: './return-details-order-group-item-controls.component.html',
styleUrls: ['./return-details-order-group-item-controls.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
@@ -37,7 +37,7 @@ import { FormsModule } from "@angular/forms";
],
providers: [
provideLoggerContext({
component: "ReturnDetailsOrderGroupItemControlsComponent",
component: 'ReturnDetailsOrderGroupItemControlsComponent',
}),
],
})
@@ -89,6 +89,13 @@ export class ReturnDetailsOrderGroupItemControlsComponent {
canReturnReceiptItem = computed(() => canReturnReceiptItem(this.item()));
dropdownLabel = computed(() => {
const category = this.productCategory();
return !!category && category !== ProductCategory.Unknown
? category
: 'Produktart';
});
setProductCategory(category: ProductCategory | undefined) {
if (!category) {
category = ProductCategory.Unknown;

View File

@@ -44,21 +44,25 @@
</div>
<div class="text-isa-neutral-900 flex flex-col gap-2" uiItemRowProdcutInfo>
<div class="flex flex-row gap-2 items-center justify-start">
<ng-icon [name]="i.product.format | lowercase"></ng-icon>
@if (!!i?.product?.format) {
<ng-icon [name]="i.product.format | lowercase"></ng-icon>
}
<span class="isa-text-body-2-bold truncate">
{{ i.product.formatDetail }}
{{ i?.product?.formatDetail }}
</span>
</div>
<div
class="text-isa-neutral-600 isa-text-body-2-regular"
data-what="product-info"
[attr.data-which]="i.product.ean"
[attr.data-which]="i?.product?.ean"
>
{{ i.product.manufacturer }} | {{ i.product.ean }}
</div>
<div class="text-isa-neutral-600 isa-text-body-2-regular">
{{ i.product.publicationDate | date: "dd. MMM yyyy" }}
{{ i?.product?.manufacturer }} | {{ i?.product?.ean }}
</div>
@if (!!i?.product?.publicationDate) {
<div class="text-isa-neutral-600 isa-text-body-2-regular">
{{ i.product.publicationDate | date: 'dd. MMM yyyy' }}
</div>
}
</div>
<oms-feature-return-details-order-group-item-controls [item]="i">
</oms-feature-return-details-order-group-item-controls>

View File

@@ -124,8 +124,8 @@ export class DropdownOptionComponent<T> implements Highlightable {
'(keydown.enter)': 'select(keyManger.activeItem); close()',
'(keydown.escape)': 'close()',
'(click)':
'disabled() ? $event.stopImmediatePropagation() : (isOpen() ? close() : open())',
'(closeOnScroll)': 'close()',
'disabled() ? $event.stopPropagation() : (isOpen() ? close() : open())',
'(closeOnScroll)': '(isOpen() ? close() : "")',
},
})
export class DropdownButtonComponent<T>

View File

@@ -47,6 +47,7 @@ export class CloseOnScrollDirective implements OnDestroy {
#document = inject(DOCUMENT);
#scrollListener?: (event: Event) => void;
#isActive = false;
#pendingActivation?: number;
/**
* When true, the directive listens for scroll events.
@@ -81,50 +82,47 @@ export class CloseOnScrollDirective implements OnDestroy {
if (this.#isActive) {
return;
}
this.#scrollListener = (event: Event) => {
const target = event.target as HTMLElement;
const excludeElement = this.closeOnScrollExclude();
// Check if scroll happened within the excluded element
if (excludeElement?.contains(target)) {
return;
}
// Emit close event - scroll happened outside excluded element
this.#logger.debug('Scroll detected outside panel, emitting close');
this.closeOnScroll.emit();
};
// Use capture: true to catch scroll events from ALL elements (scroll events don't bubble)
// Use passive: true for better performance (we don't call preventDefault)
this.#document.defaultView?.addEventListener(
'scroll',
this.#scrollListener,
{
capture: true,
passive: true,
},
);
this.#isActive = true;
this.#logger.debug('Activated scroll listener');
// Delay listener registration to skip scroll events caused by:
// 1. Stale scroll events from before activation
// 2. iOS Safari's automatic "scroll into view" when focusing elements
// Using setTimeout with 100ms to ensure iOS scroll-into-view completes
this.#pendingActivation = window.setTimeout(() => {
this.#scrollListener = (event: Event) => {
const excludeElement = this.closeOnScrollExclude();
if (excludeElement?.contains(event.target as HTMLElement)) {
return;
}
this.#logger.debug('Scroll detected outside panel, emitting close');
this.closeOnScroll.emit();
};
this.#document.defaultView?.addEventListener(
'scroll',
this.#scrollListener,
{ capture: true, passive: true },
);
this.#logger.debug('Activated scroll listener');
}, 100);
}
#deactivate(): void {
if (!this.#isActive || !this.#scrollListener) {
return;
if (this.#pendingActivation) {
clearTimeout(this.#pendingActivation);
this.#pendingActivation = undefined;
}
this.#document.defaultView?.removeEventListener(
'scroll',
this.#scrollListener,
{ capture: true },
);
this.#scrollListener = undefined;
this.#isActive = false;
if (this.#scrollListener) {
this.#document.defaultView?.removeEventListener(
'scroll',
this.#scrollListener,
{ capture: true },
);
this.#scrollListener = undefined;
}
this.#logger.debug('Deactivated scroll listener');
this.#isActive = false;
}
ngOnDestroy(): void {

254
package-lock.json generated
View File

@@ -32,6 +32,7 @@
"@ngrx/store-devtools": "^20.0.0",
"angular-oauth2-oidc": "^20.0.2",
"angular-oauth2-oidc-jwks": "^20.0.0",
"angularx-qrcode": "^20.0.0",
"date-fns": "^4.1.0",
"jsbarcode": "^3.12.1",
"lodash": "^4.17.21",
@@ -15055,6 +15056,19 @@
"tslib": "^2.5.2"
}
},
"node_modules/angularx-qrcode": {
"version": "20.0.0",
"resolved": "https://registry.npmjs.org/angularx-qrcode/-/angularx-qrcode-20.0.0.tgz",
"integrity": "sha512-WZolRZztQsQxOXqodNSDicxPWNO79t/AT4wts+DxwYdtdXb1RELfZjtax9oGMQQ6mEZ6bwk5GqBGEDB3Y+cSqw==",
"license": "MIT",
"dependencies": {
"qrcode": "1.5.4",
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/core": "^20.0.0"
}
},
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
@@ -15098,7 +15112,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -15108,7 +15121,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -15794,37 +15806,28 @@
}
},
"node_modules/body-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
"integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==",
"dev": true,
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.0",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.6.3",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.0",
"raw-body": "^3.0.0",
"type-is": "^2.0.0"
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/body-parser/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bonjour-service": {
@@ -16189,7 +16192,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -16630,7 +16632,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -16643,7 +16644,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/colord": {
@@ -18307,6 +18307,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -18595,6 +18604,12 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -29306,9 +29321,9 @@
}
},
"node_modules/node-forge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
"integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.2.tgz",
"integrity": "sha512-6xKiQ+cph9KImrRh0VsjH2d8/GXA4FIMlgU4B757iI1ApvcyA9VlouP0yZJha01V+huImO+kKMU7ih+2+E14fw==",
"dev": true,
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
@@ -30412,7 +30427,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -30673,7 +30687,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -30943,6 +30956,15 @@
"node": ">=8"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/portfinder": {
"version": "1.0.38",
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz",
@@ -32100,6 +32122,156 @@
],
"license": "MIT"
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
@@ -32583,7 +32755,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -32599,6 +32770,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -33812,6 +33989,12 @@
"node": ">= 18"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@@ -34599,7 +34782,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -37831,6 +38013,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -37876,7 +38064,6 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
@@ -37942,14 +38129,12 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -37959,7 +38144,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",

View File

@@ -45,6 +45,7 @@
"@ngrx/store-devtools": "^20.0.0",
"angular-oauth2-oidc": "^20.0.2",
"angular-oauth2-oidc-jwks": "^20.0.0",
"angularx-qrcode": "^20.0.0",
"date-fns": "^4.1.0",
"jsbarcode": "^3.12.1",
"lodash": "^4.17.21",