new Landing page, stripped app
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { ControlValueAccessor } from '@angular/forms';
|
||||
|
||||
import { Subscription } from 'rxjs';
|
||||
import { ValidationMessagesService } from '../validation-messages.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-base-input',
|
||||
template: ``,
|
||||
imports: [],
|
||||
})
|
||||
export abstract class BaseInputComponent implements ControlValueAccessor {
|
||||
@Input() value: any = '';
|
||||
validationMessage: string = '';
|
||||
onChange: any = () => {};
|
||||
onTouched: any = () => {};
|
||||
subscription: Subscription | null = null;
|
||||
@Input() label: string = '';
|
||||
// @Input() id: string = '';
|
||||
@Input() name: string = '';
|
||||
isTooltipVisible = false;
|
||||
constructor(protected validationMessagesService: ValidationMessagesService) {}
|
||||
ngOnInit() {
|
||||
this.subscription = this.validationMessagesService.messages$.subscribe(() => {
|
||||
this.updateValidationMessage();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.subscription) {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
writeValue(value: any): void {
|
||||
if (value !== undefined) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
registerOnChange(fn: any): void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
|
||||
registerOnTouched(fn: any): void {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
updateValidationMessage(): void {
|
||||
this.validationMessage = this.validationMessagesService.getMessage(this.name);
|
||||
}
|
||||
setDisabledState?(isDisabled: boolean): void {}
|
||||
toggleTooltip(event: Event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.isTooltipVisible = !this.isTooltipVisible;
|
||||
}
|
||||
}
|
||||
54
bizmatch-client/src/app/components/confirmation.component.ts
Normal file
54
bizmatch-client/src/app/components/confirmation.component.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { AsyncPipe, NgIf } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { ConfirmationService } from '../services/confirmation.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-confirmation',
|
||||
imports: [AsyncPipe, NgIf],
|
||||
template: `
|
||||
<div *ngIf="confirmationService.modalVisible$ | async" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center">
|
||||
<div class="relative p-4 w-full max-w-md max-h-full">
|
||||
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||
<button
|
||||
(click)="confirmationService.reject()"
|
||||
type="button"
|
||||
class="absolute top-3 end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
>
|
||||
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
|
||||
</svg>
|
||||
<span class="sr-only">Close modal</span>
|
||||
</button>
|
||||
<div class="p-4 md:p-5 text-center">
|
||||
<svg class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 11V6m0 8h.01M19 10a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
@let confirmation = (confirmationService.confirmation$ | async);
|
||||
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
|
||||
{{ confirmation?.message }}
|
||||
</h3>
|
||||
@if(confirmation?.buttons==='both'){
|
||||
<button
|
||||
(click)="confirmationService.accept()"
|
||||
type="button"
|
||||
class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center mr-2"
|
||||
>
|
||||
Yes, I'm sure
|
||||
</button>
|
||||
<button
|
||||
(click)="confirmationService.reject()"
|
||||
type="button"
|
||||
class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
|
||||
>
|
||||
No, cancel
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ConfirmationComponent {
|
||||
constructor(public confirmationService: ConfirmationService) {}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<!-- Main modal -->
|
||||
<div *ngIf="eMailService.modalVisible$ | async" id="authentication-modal" tabindex="-1" class="z-40 fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center">
|
||||
<div class="relative p-4 w-full max-w-md max-h-full">
|
||||
<!-- Modal content -->
|
||||
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||
<!-- Modal header -->
|
||||
<div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Email listing to a friend</h3>
|
||||
<button
|
||||
(click)="eMailService.reject()"
|
||||
type="button"
|
||||
class="end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
>
|
||||
<svg class="w-3 h-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
|
||||
</svg>
|
||||
<span class="sr-only">Close modal</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Modal body -->
|
||||
<div class="p-4 md:p-5">
|
||||
<form class="space-y-4" action="#">
|
||||
<div>
|
||||
<app-validated-input label="Your Email" name="yourEmail" [(ngModel)]="shareByEMail.yourEmail"></app-validated-input>
|
||||
</div>
|
||||
<div>
|
||||
<app-validated-input label="Your Name" name="yourName" [(ngModel)]="shareByEMail.yourName"></app-validated-input>
|
||||
</div>
|
||||
<div>
|
||||
<app-validated-input label="Your Friend's EMail" name="recipientEmail" [(ngModel)]="shareByEMail.recipientEmail"></app-validated-input>
|
||||
</div>
|
||||
|
||||
<button
|
||||
(click)="sendMail()"
|
||||
class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
>
|
||||
Send EMail
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
40
bizmatch-client/src/app/components/email/email.component.ts
Normal file
40
bizmatch-client/src/app/components/email/email.component.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||
import { ShareByEMail } from '../../../../../bizmatch-server/src/models/db.model';
|
||||
import { MailService } from '../../services/mail.service';
|
||||
import { ValidatedInputComponent } from '../validated-input/validated-input.component';
|
||||
import { ValidationMessagesService } from '../validation-messages.service';
|
||||
import { EMailService } from './email.service';
|
||||
|
||||
@UntilDestroy()
|
||||
@Component({
|
||||
selector: 'app-email',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ValidatedInputComponent],
|
||||
templateUrl: './email.component.html',
|
||||
template: ``,
|
||||
})
|
||||
export class EMailComponent {
|
||||
shareByEMail: ShareByEMail = {};
|
||||
constructor(public eMailService: EMailService, private mailService: MailService, private validationMessagesService: ValidationMessagesService) {}
|
||||
ngOnInit() {
|
||||
this.eMailService.shareByEMail$.pipe(untilDestroyed(this)).subscribe(val => {
|
||||
this.shareByEMail = val;
|
||||
});
|
||||
}
|
||||
async sendMail() {
|
||||
try {
|
||||
const result = await this.mailService.mailToFriend(this.shareByEMail);
|
||||
this.eMailService.accept(this.shareByEMail);
|
||||
} catch (error) {
|
||||
if (error.error && Array.isArray(error.error?.message)) {
|
||||
this.validationMessagesService.updateMessages(error.error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
|
||||
}
|
||||
}
|
||||
33
bizmatch-client/src/app/components/email/email.service.ts
Normal file
33
bizmatch-client/src/app/components/email/email.service.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import { ShareByEMail } from '../../../../../bizmatch-server/src/models/db.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class EMailService {
|
||||
private modalVisibleSubject = new Subject<boolean>();
|
||||
private shareByEMailSubject = new Subject<ShareByEMail>();
|
||||
private resolvePromise!: (value: boolean | ShareByEMail) => void;
|
||||
|
||||
modalVisible$: Observable<boolean> = this.modalVisibleSubject.asObservable();
|
||||
shareByEMail$: Observable<ShareByEMail> = this.shareByEMailSubject.asObservable();
|
||||
|
||||
showShareByEMail(shareByEMail: ShareByEMail): Promise<boolean | ShareByEMail> {
|
||||
this.shareByEMailSubject.next(shareByEMail);
|
||||
this.modalVisibleSubject.next(true);
|
||||
return new Promise<boolean | ShareByEMail>(resolve => {
|
||||
this.resolvePromise = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
accept(value: ShareByEMail): void {
|
||||
this.modalVisibleSubject.next(false);
|
||||
this.resolvePromise(value);
|
||||
}
|
||||
|
||||
reject(): void {
|
||||
this.modalVisibleSubject.next(false);
|
||||
this.resolvePromise(false);
|
||||
}
|
||||
}
|
||||
1049
bizmatch-client/src/app/components/footer/footer.component.html
Normal file
1049
bizmatch-client/src/app/components/footer/footer.component.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
:host {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.order-2 {
|
||||
order: 2;
|
||||
}
|
||||
.order-3 {
|
||||
order: 3;
|
||||
}
|
||||
}
|
||||
section p {
|
||||
display: block;
|
||||
margin-block-start: 1em;
|
||||
margin-block-end: 1em;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 0px;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
@Component({
|
||||
selector: 'app-footer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, RouterModule, FontAwesomeModule],
|
||||
templateUrl: './footer.component.html',
|
||||
styleUrl: './footer.component.scss',
|
||||
})
|
||||
export class FooterComponent {
|
||||
privacyVisible = false;
|
||||
termsVisible = false;
|
||||
currentYear: number = new Date().getFullYear();
|
||||
isHomeRoute = false;
|
||||
constructor(private router: Router) {}
|
||||
ngOnInit() {}
|
||||
}
|
||||
124
bizmatch-client/src/app/components/header/header.component.html
Normal file
124
bizmatch-client/src/app/components/header/header.component.html
Normal file
@@ -0,0 +1,124 @@
|
||||
<nav class="bg-white border-gray-200 dark:bg-gray-900 print:hidden">
|
||||
<div class="flex flex-wrap items-center justify-between mx-auto p-4">
|
||||
<a routerLink="/home" class="flex items-center space-x-3 rtl:space-x-reverse">
|
||||
<img src="assets/images/header-logo.png" class="h-8" alt="Flowbite Logo" />
|
||||
</a>
|
||||
<div class="flex items-center md:order-2 space-x-3 rtl:space-x-reverse">
|
||||
<!-- Filter button -->
|
||||
@if(isFilterUrl()){
|
||||
<button
|
||||
type="button"
|
||||
#triggerButton
|
||||
(click)="openModal()"
|
||||
id="filterDropdownButton"
|
||||
class="max-sm:hidden px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
|
||||
>
|
||||
<i class="fas fa-filter mr-2"></i>Filter ({{ getNumberOfFiltersSet() }})
|
||||
</button>
|
||||
<!-- Sort button -->
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
id="sortDropdownButton"
|
||||
class="max-sm:hidden px-4 py-2 text-sm font-medium bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
|
||||
(click)="toggleSortDropdown()"
|
||||
[ngClass]="{ 'text-blue-500': selectOptions.getSortByOption(criteria?.sortBy) !== 'Sort', 'text-gray-900': selectOptions.getSortByOption(criteria?.sortBy) === 'Sort' }"
|
||||
>
|
||||
<i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(criteria?.sortBy) }}
|
||||
</button>
|
||||
|
||||
<!-- Sort options dropdown -->
|
||||
<div *ngIf="sortDropdownVisible" class="absolute right-0 z-50 w-48 md:mt-2 max-md:mt-20 max-md:mr-[-2.5rem] bg-white border border-gray-200 rounded-lg drop-shadow-custom-bg dark:bg-gray-800 dark:border-gray-600">
|
||||
<ul class="py-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
@for(item of sortByOptions; track item){
|
||||
<li (click)="sortBy(item.value)" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">{{ item.selectName ? item.selectName : item.name }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="flex text-sm bg-gray-400 rounded-full md:me-0 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
|
||||
id="user-menu-button"
|
||||
aria-expanded="false"
|
||||
[attr.data-dropdown-toggle]="user ? 'user-login' : 'user-unknown'"
|
||||
data-dropdown-placement="bottom"
|
||||
>
|
||||
<span class="sr-only">Open user menu</span>
|
||||
@if(isProfessional || (authService.isAdmin() | async) && user?.hasProfile){
|
||||
<img class="w-8 h-8 rounded-full object-cover" src="{{ profileUrl }}" alt="user photo" />
|
||||
} @else {
|
||||
<i class="flex justify-center items-center text-stone-50 w-8 h-8 rounded-full fa-solid fa-bars"></i>
|
||||
}
|
||||
</button>
|
||||
<!-- Dropdown menu -->
|
||||
@if(user){
|
||||
<div class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow dark:bg-gray-700 dark:divide-gray-600" id="user-login">
|
||||
<div class="px-4 py-3">
|
||||
<span class="block text-sm text-gray-900 dark:text-white">Welcome, {{ user.firstname }} </span>
|
||||
<span class="block text-sm text-gray-500 truncate dark:text-gray-400">{{ user.email }}</span>
|
||||
</div>
|
||||
<ul class="py-2" aria-labelledby="user-menu-button">
|
||||
<li>
|
||||
<a routerLink="/account" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Account</a>
|
||||
</li>
|
||||
@if(isProfessional || (authService.isAdmin() | async) && user?.hasProfile){
|
||||
<li>
|
||||
<a routerLink="/createBusinessListing" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Create Listing</a>
|
||||
</li>
|
||||
}
|
||||
<li>
|
||||
<a routerLink="/myListings" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">My Listings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a routerLink="/myFavorites" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">My Favorites</a>
|
||||
</li>
|
||||
<li>
|
||||
<a routerLink="/logout" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Logout</a>
|
||||
</li>
|
||||
</ul>
|
||||
@if(authService.isAdmin() | async){
|
||||
<ul class="py-2">
|
||||
<li>
|
||||
<a routerLink="admin/users" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Users (Admin)</a>
|
||||
</li>
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow dark:bg-gray-700 dark:divide-gray-600" id="user-unknown">
|
||||
<ul class="py-2" aria-labelledby="user-menu-button">
|
||||
<li>
|
||||
<a routerLink="/login" [queryParams]="{ mode: 'login' }" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Log In</a>
|
||||
</li>
|
||||
<li>
|
||||
<a routerLink="/login" [queryParams]="{ mode: 'register' }" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Sign Up</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile filter button -->
|
||||
<div class="md:hidden flex justify-center pb-4">
|
||||
<button
|
||||
(click)="openModal()"
|
||||
type="button"
|
||||
id="filterDropdownMobileButton"
|
||||
class="w-full mx-4 px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
|
||||
>
|
||||
<i class="fas fa-filter mr-2"></i>Filter ({{ getNumberOfFiltersSet() }})
|
||||
</button>
|
||||
<!-- Sorting -->
|
||||
<button
|
||||
(click)="toggleSortDropdown()"
|
||||
type="button"
|
||||
id="sortDropdownMobileButton"
|
||||
class="mx-4 w-1/2 px-4 py-2 text-sm font-medium bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
|
||||
[ngClass]="{ 'text-blue-500': selectOptions.getSortByOption(criteria?.sortBy) !== 'Sort', 'text-gray-900': selectOptions.getSortByOption(criteria?.sortBy) === 'Sort' }"
|
||||
>
|
||||
<i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(criteria?.sortBy) }}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -0,0 +1,13 @@
|
||||
::ng-deep p-menubarsub{
|
||||
margin-left: auto;
|
||||
}
|
||||
::ng-deep .p-tabmenu .p-tabmenu-nav .p-tabmenuitem .p-menuitem-link{
|
||||
border:1px solid #ffffff;
|
||||
}
|
||||
::ng-deep .p-tabmenu .p-tabmenu-nav .p-tabmenuitem.p-highlight .p-menuitem-link {
|
||||
border-bottom: 2px solid #3B82F6 !important;
|
||||
}
|
||||
::ng-deep .p-menubar{
|
||||
border:unset;
|
||||
background: unset;
|
||||
}
|
||||
200
bizmatch-client/src/app/components/header/header.component.ts
Normal file
200
bizmatch-client/src/app/components/header/header.component.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { BreakpointObserver } from '@angular/cdk/layout';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, HostListener } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NavigationEnd, Router, RouterModule } from '@angular/router';
|
||||
import { faUserGear } from '@fortawesome/free-solid-svg-icons';
|
||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||
import { debounceTime, filter, Observable, Subject, Subscription } from 'rxjs';
|
||||
import { SortByOptions, User } from '../../../../../bizmatch-server/src/models/db.model';
|
||||
import { BusinessListingCriteria, emailToDirName, KeycloakUser, KeyValueAsSortBy } from '../../../../../bizmatch-server/src/models/main.model';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
import { SharedService } from '../../services/shared.service';
|
||||
|
||||
import { Collapse, Dropdown } from 'flowbite';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { CriteriaChangeService } from '../../services/criteria-change.service';
|
||||
import { ListingsService } from '../../services/listings.service';
|
||||
import { ModalService } from '../../services/modal.service';
|
||||
import { SearchService } from '../../services/search.service';
|
||||
import { SelectOptionsService } from '../../services/select-options.service';
|
||||
import { UserService } from '../../services/user.service';
|
||||
import { assignProperties, compareObjects, createEmptyBusinessListingCriteria, getCriteriaProxy, map2User } from '../../utils/utils';
|
||||
@UntilDestroy()
|
||||
@Component({
|
||||
selector: 'header',
|
||||
imports: [CommonModule, RouterModule, FormsModule],
|
||||
templateUrl: './header.component.html',
|
||||
styleUrl: './header.component.scss',
|
||||
})
|
||||
export class HeaderComponent {
|
||||
public buildVersion = environment.buildVersion;
|
||||
user$: Observable<KeycloakUser>;
|
||||
keycloakUser: KeycloakUser;
|
||||
user: User;
|
||||
activeItem;
|
||||
faUserGear = faUserGear;
|
||||
profileUrl: string;
|
||||
env = environment;
|
||||
private filterDropdown: Dropdown | null = null;
|
||||
isMobile: boolean = false;
|
||||
private destroy$ = new Subject<void>();
|
||||
prompt: string;
|
||||
private subscription: Subscription;
|
||||
criteria: BusinessListingCriteria;
|
||||
private routerSubscription: Subscription | undefined;
|
||||
baseRoute: string;
|
||||
sortDropdownVisible: boolean;
|
||||
sortByOptions: KeyValueAsSortBy[] = [];
|
||||
numberOfBroker$: Observable<number>;
|
||||
numberOfCommercial$: Observable<number>;
|
||||
constructor(
|
||||
private router: Router,
|
||||
private userService: UserService,
|
||||
private sharedService: SharedService,
|
||||
private breakpointObserver: BreakpointObserver,
|
||||
private modalService: ModalService,
|
||||
private searchService: SearchService,
|
||||
private criteriaChangeService: CriteriaChangeService,
|
||||
public selectOptions: SelectOptionsService,
|
||||
public authService: AuthService,
|
||||
private listingService: ListingsService,
|
||||
) {}
|
||||
@HostListener('document:click', ['$event'])
|
||||
handleGlobalClick(event: Event) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.id !== 'sortDropdownButton' && target.id !== 'sortDropdownMobileButton') {
|
||||
this.sortDropdownVisible = false;
|
||||
}
|
||||
}
|
||||
async ngOnInit() {
|
||||
const token = await this.authService.getToken();
|
||||
this.keycloakUser = map2User(token);
|
||||
if (this.keycloakUser) {
|
||||
this.user = await this.userService.getByMail(this.keycloakUser?.email);
|
||||
this.profileUrl = this.user.hasProfile ? `${this.env.imageBaseUrl}/pictures/profile/${emailToDirName(this.user.email)}.avif?_ts=${new Date().getTime()}` : `/assets/images/placeholder.png`;
|
||||
}
|
||||
|
||||
this.sharedService.currentProfilePhoto.subscribe(photoUrl => {
|
||||
this.profileUrl = photoUrl;
|
||||
});
|
||||
|
||||
this.checkCurrentRoute(this.router.url);
|
||||
this.setupSortByOptions();
|
||||
|
||||
this.routerSubscription = this.router.events.pipe(filter(event => event instanceof NavigationEnd)).subscribe((event: any) => {
|
||||
this.checkCurrentRoute(event.urlAfterRedirects);
|
||||
this.setupSortByOptions();
|
||||
});
|
||||
this.subscription = this.criteriaChangeService.criteriaChange$.pipe(debounceTime(400)).subscribe(() => {
|
||||
this.criteria = getCriteriaProxy(this.baseRoute, this);
|
||||
});
|
||||
this.userService.currentUser.pipe(untilDestroyed(this)).subscribe(u => {
|
||||
this.user = u;
|
||||
});
|
||||
}
|
||||
private checkCurrentRoute(url: string): void {
|
||||
this.baseRoute = url.split('/')[1]; // Nimmt den ersten Teil der Route nach dem ersten '/'
|
||||
const specialRoutes = [, '', ''];
|
||||
this.criteria = getCriteriaProxy(this.baseRoute, this);
|
||||
// this.searchService.search(this.criteria);
|
||||
}
|
||||
setupSortByOptions() {
|
||||
this.sortByOptions = [];
|
||||
if (this.isProfessionalListing()) {
|
||||
this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => s.type === 'professional')];
|
||||
}
|
||||
if (this.isBusinessListing()) {
|
||||
this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => s.type === 'business' || s.type === 'listing')];
|
||||
}
|
||||
if (this.isCommercialPropertyListing()) {
|
||||
this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => s.type === 'commercial' || s.type === 'listing')];
|
||||
}
|
||||
this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => !s.type)];
|
||||
}
|
||||
ngAfterViewInit() {}
|
||||
|
||||
async openModal() {
|
||||
const modalResult = await this.modalService.showModal(this.criteria);
|
||||
if (modalResult.accepted) {
|
||||
this.searchService.search(this.criteria);
|
||||
} else {
|
||||
this.criteria = assignProperties(this.criteria, modalResult.criteria);
|
||||
}
|
||||
}
|
||||
navigateWithState(dest: string, state: any) {
|
||||
this.router.navigate([dest], { state: state });
|
||||
}
|
||||
|
||||
isActive(route: string): boolean {
|
||||
return this.router.url === route;
|
||||
}
|
||||
isEmailUsUrl(): boolean {
|
||||
return ['/emailUs'].includes(this.router.url);
|
||||
}
|
||||
isFilterUrl(): boolean {
|
||||
return ['/businessListings', '/commercialPropertyListings', '/brokerListings'].includes(this.router.url);
|
||||
}
|
||||
isBusinessListing(): boolean {
|
||||
return ['/businessListings'].includes(this.router.url);
|
||||
}
|
||||
isCommercialPropertyListing(): boolean {
|
||||
return ['/commercialPropertyListings'].includes(this.router.url);
|
||||
}
|
||||
isProfessionalListing(): boolean {
|
||||
return ['/brokerListings'].includes(this.router.url);
|
||||
}
|
||||
// isSortingUrl(): boolean {
|
||||
// return ['/businessListings', '/commercialPropertyListings'].includes(this.router.url);
|
||||
// }
|
||||
closeDropdown() {
|
||||
const dropdownButton = document.getElementById('user-menu-button');
|
||||
const dropdownMenu = this.user ? document.getElementById('user-login') : document.getElementById('user-unknown');
|
||||
|
||||
if (dropdownButton && dropdownMenu) {
|
||||
const dropdown = new Dropdown(dropdownMenu, dropdownButton);
|
||||
dropdown.hide();
|
||||
}
|
||||
}
|
||||
closeMobileMenu() {
|
||||
const targetElement = document.getElementById('navbar-user');
|
||||
const triggerElement = document.querySelector('[data-collapse-toggle="navbar-user"]');
|
||||
|
||||
if (targetElement instanceof HTMLElement && triggerElement instanceof HTMLElement) {
|
||||
const collapse = new Collapse(targetElement, triggerElement);
|
||||
collapse.collapse();
|
||||
}
|
||||
}
|
||||
closeMenusAndSetCriteria(path: string) {
|
||||
this.closeDropdown();
|
||||
this.closeMobileMenu();
|
||||
const criteria = getCriteriaProxy(path, this);
|
||||
criteria.page = 1;
|
||||
criteria.start = 0;
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
getNumberOfFiltersSet() {
|
||||
if (this.criteria?.criteriaType === 'businessListings') {
|
||||
return compareObjects(createEmptyBusinessListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius', 'sortBy']);
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
sortBy(sortBy: SortByOptions) {
|
||||
this.criteria.sortBy = sortBy;
|
||||
this.sortDropdownVisible = false;
|
||||
this.searchService.search(this.criteria);
|
||||
}
|
||||
toggleSortDropdown() {
|
||||
this.sortDropdownVisible = !this.sortDropdownVisible;
|
||||
}
|
||||
get isProfessional() {
|
||||
return this.user?.customerType === 'professional';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!-- Modal -->
|
||||
<div *ngIf="showModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center">
|
||||
<div class="bg-white p-5 rounded-lg shadow-xl" style="width: 90%; max-width: 600px">
|
||||
<h3 class="text-lg font-semibold mb-4">Crop Image</h3>
|
||||
<image-cropper (loadImageFailed)="loadImageFailed()" [imageChangedEvent]="imageChangedEvent" [maintainAspectRatio]="false" format="png" (imageCropped)="imageCropped($event)"></image-cropper>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button (click)="closeModal()" class="mr-2 px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300">Cancel</button>
|
||||
<button (click)="uploadImage()" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" #fileInput style="display: none" (change)="fileChangeEvent($event)" accept="image/*" />
|
||||
@@ -0,0 +1,6 @@
|
||||
::ng-deep image-cropper {
|
||||
justify-content: center;
|
||||
& > div {
|
||||
width: unset !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ElementRef, Input, output, ViewChild } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||
import { ImageCroppedEvent, ImageCropperComponent } from 'ngx-image-cropper';
|
||||
import { UploadParams } from '../../../../../bizmatch-server/src/models/main.model';
|
||||
import { ImageService } from '../../services/image.service';
|
||||
import { ListingsService } from '../../services/listings.service';
|
||||
export interface UploadReponse {
|
||||
success: boolean;
|
||||
type: 'uploadPropertyPicture' | 'uploadCompanyLogo' | 'uploadProfile';
|
||||
}
|
||||
@Component({
|
||||
selector: 'app-image-crop-and-upload',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, RouterModule, FontAwesomeModule, ImageCropperComponent],
|
||||
templateUrl: './image-crop-and-upload.component.html',
|
||||
styleUrl: './image-crop-and-upload.component.scss',
|
||||
})
|
||||
export class ImageCropAndUploadComponent {
|
||||
showModal = false;
|
||||
imageChangedEvent: any = '';
|
||||
croppedImage: Blob | null = null;
|
||||
@Input() uploadParams: UploadParams;
|
||||
uploadFinished = output<UploadReponse>();
|
||||
@ViewChild('fileInput', { static: true }) fileInput!: ElementRef<HTMLInputElement>;
|
||||
|
||||
constructor(private imageService: ImageService, private listingsService: ListingsService) {}
|
||||
|
||||
ngOnInit() {}
|
||||
ngOnChanges() {
|
||||
this.openFileDialog();
|
||||
}
|
||||
openFileDialog() {
|
||||
if (this.uploadParams) {
|
||||
this.fileInput.nativeElement.click();
|
||||
}
|
||||
}
|
||||
|
||||
fileChangeEvent(event: any): void {
|
||||
this.imageChangedEvent = event;
|
||||
this.showModal = true;
|
||||
}
|
||||
|
||||
imageCropped(event: ImageCroppedEvent) {
|
||||
this.croppedImage = event.blob;
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
this.imageChangedEvent = null;
|
||||
this.croppedImage = null;
|
||||
this.showModal = false;
|
||||
this.fileInput.nativeElement.value = '';
|
||||
this.uploadFinished.emit({ success: false, type: this.uploadParams.type });
|
||||
}
|
||||
|
||||
async uploadImage() {
|
||||
if (this.croppedImage) {
|
||||
await this.imageService.uploadImage(this.croppedImage, this.uploadParams.type, this.uploadParams.imagePath, this.uploadParams.serialId);
|
||||
this.closeModal();
|
||||
this.uploadFinished.emit({ success: true, type: this.uploadParams.type });
|
||||
}
|
||||
}
|
||||
|
||||
loadImageFailed() {
|
||||
console.error('Load image failed');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Message, MessageService } from '../../services/message.service';
|
||||
import { MessageComponent } from './message.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-message-container',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MessageComponent],
|
||||
template: `
|
||||
<div class="fixed top-5 right-5 z-50 flex flex-col items-end">
|
||||
<app-message
|
||||
*ngFor="let message of messages"
|
||||
[message]="message"
|
||||
(close)="removeMessage(message)"
|
||||
>
|
||||
</app-message>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class MessageContainerComponent implements OnInit {
|
||||
messages: Message[] = [];
|
||||
|
||||
constructor(private messageService: MessageService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.messageService.messages$.subscribe((messages) => {
|
||||
this.messages = messages;
|
||||
});
|
||||
}
|
||||
|
||||
removeMessage(message: Message): void {
|
||||
this.messageService.removeMessage(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { animate, style, transition, trigger } from '@angular/animations';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { Message } from '../../services/message.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-message',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div [@toastAnimation]="'in'" [ngClass]="getClasses()" role="alert">
|
||||
<div class="ms-3 text-sm font-medium">{{ message.text }}</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="onClose()"
|
||||
class="ms-auto -mx-1.5 -my-1.5 rounded-lg p-1.5 inline-flex items-center justify-center h-8 w-8"
|
||||
[ngClass]="getCloseButtonClasses()"
|
||||
aria-label="Close"
|
||||
>
|
||||
<span class="sr-only">Close</span>
|
||||
<svg
|
||||
class="w-3 h-3"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 14 14"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
animations: [
|
||||
trigger('toastAnimation', [
|
||||
transition(':enter', [
|
||||
style({ transform: 'translateY(100%)', opacity: 0 }),
|
||||
animate(
|
||||
'300ms ease-out',
|
||||
style({ transform: 'translateY(0)', opacity: 1 })
|
||||
),
|
||||
]),
|
||||
transition(':leave', [
|
||||
animate(
|
||||
'300ms ease-in',
|
||||
style({ transform: 'translateY(100%)', opacity: 0 })
|
||||
),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
})
|
||||
export class MessageComponent {
|
||||
@Input() message!: Message;
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
onClose(): void {
|
||||
this.close.emit();
|
||||
}
|
||||
|
||||
getClasses(): string {
|
||||
return `flex items-center w-full max-w-xs p-4 mb-4 text-gray-500 rounded-lg shadow ${this.getSeverityClasses()}`;
|
||||
}
|
||||
|
||||
getCloseButtonClasses(): string {
|
||||
switch (this.message.severity) {
|
||||
case 'success':
|
||||
return 'text-green-600 hover:bg-green-200 focus:ring-green-400';
|
||||
case 'danger':
|
||||
return 'text-red-600 hover:bg-red-200 focus:ring-red-400';
|
||||
case 'warning':
|
||||
return 'text-yellow-600 hover:bg-yellow-200 focus:ring-yellow-400';
|
||||
default:
|
||||
return 'text-blue-600 hover:bg-blue-200 focus:ring-blue-400';
|
||||
}
|
||||
}
|
||||
|
||||
private getSeverityClasses(): string {
|
||||
switch (this.message.severity) {
|
||||
case 'success':
|
||||
return 'bg-green-100 text-green-700';
|
||||
case 'danger':
|
||||
return 'bg-red-100 text-red-700';
|
||||
case 'warning':
|
||||
return 'bg-yellow-100 text-yellow-700';
|
||||
default:
|
||||
return 'bg-blue-100 text-blue-700';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<p>paginator works!</p>
|
||||
@@ -0,0 +1,98 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-paginator',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<nav class="my-2" aria-label="Page navigation">
|
||||
<ul class="flex justify-center items-center -space-x-px h-8 text-sm">
|
||||
<li>
|
||||
<a
|
||||
(click)="onPageChange(currentPage - 1)"
|
||||
[class.pointer-events-none]="currentPage === 1"
|
||||
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-e-0 border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
|
||||
>
|
||||
<span class="sr-only">Previous</span>
|
||||
<svg class="w-2.5 h-2.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 1 1 5l4 4" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
<ng-container *ngFor="let page of visiblePages">
|
||||
<li *ngIf="page !== '...'">
|
||||
<a
|
||||
(click)="onPageChange(page)"
|
||||
[ngClass]="
|
||||
page === currentPage
|
||||
? 'z-10 flex items-center justify-center px-3 h-8 leading-tight text-blue-600 border border-blue-300 bg-blue-50 hover:bg-blue-100 hover:text-blue-700 dark:border-gray-700 dark:bg-gray-700 dark:text-white'
|
||||
: 'flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white'
|
||||
"
|
||||
>
|
||||
{{ page }}
|
||||
</a>
|
||||
</li>
|
||||
<li *ngIf="page === '...'">
|
||||
<span class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">...</span>
|
||||
</li>
|
||||
</ng-container>
|
||||
<li>
|
||||
<a
|
||||
(click)="onPageChange(currentPage + 1)"
|
||||
[class.pointer-events-none]="currentPage === pageCount"
|
||||
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
|
||||
>
|
||||
<span class="sr-only">Next</span>
|
||||
<svg class="w-2.5 h-2.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
`,
|
||||
})
|
||||
export class PaginatorComponent implements OnChanges {
|
||||
@Input() page = 1;
|
||||
@Input() pageCount = 1;
|
||||
@Output() pageChange = new EventEmitter<number>();
|
||||
|
||||
currentPage = 1;
|
||||
visiblePages: (number | string)[] = [];
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['page'] || changes['pageCount']) {
|
||||
this.currentPage = this.page;
|
||||
this.updateVisiblePages();
|
||||
}
|
||||
}
|
||||
|
||||
updateVisiblePages(): void {
|
||||
const totalPages = this.pageCount;
|
||||
const current = this.currentPage;
|
||||
|
||||
if (totalPages <= 6) {
|
||||
this.visiblePages = Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
} else {
|
||||
if (current <= 3) {
|
||||
this.visiblePages = [1, 2, 3, 4, '...', totalPages];
|
||||
} else if (current >= totalPages - 2) {
|
||||
this.visiblePages = [1, '...', totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
|
||||
} else {
|
||||
this.visiblePages = [1, '...', current - 1, current, current + 1, '...', totalPages];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onPageChange(page: number | string): void {
|
||||
if (typeof page === 'string') {
|
||||
return;
|
||||
}
|
||||
if (page >= 1 && page <= this.pageCount && page !== this.currentPage) {
|
||||
this.currentPage = page;
|
||||
this.pageChange.emit(page);
|
||||
this.updateVisiblePages();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
<div
|
||||
*ngIf="modalService.modalVisible$ | async"
|
||||
class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center"
|
||||
>
|
||||
<div class="relative w-full max-w-4xl max-h-full">
|
||||
<div class="relative bg-white rounded-lg shadow">
|
||||
<div class="flex items-start justify-between p-4 border-b rounded-t">
|
||||
@if(criteria.criteriaType==='businessListings'){
|
||||
<h3 class="text-xl font-semibold text-gray-900">
|
||||
Business Listing Search
|
||||
</h3>
|
||||
} @else if (criteria.criteriaType==='commercialPropertyListings'){
|
||||
<h3 class="text-xl font-semibold text-gray-900">
|
||||
Property Listing Search
|
||||
</h3>
|
||||
} @else {
|
||||
<h3 class="text-xl font-semibold text-gray-900">
|
||||
Professional Listing Search
|
||||
</h3>
|
||||
}
|
||||
<button
|
||||
(click)="close()"
|
||||
type="button"
|
||||
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ml-auto inline-flex justify-center items-center"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 14 14"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sr-only">Close Modal</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex space-x-4 mb-4">
|
||||
<button
|
||||
class="text-blue-600 font-medium border-b-2 border-blue-600 pb-2"
|
||||
>
|
||||
Classic Search
|
||||
</button>
|
||||
<i
|
||||
data-tooltip-target="tooltip-light"
|
||||
class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-blue-500"
|
||||
(click)="clearFilter()"
|
||||
></i>
|
||||
<div
|
||||
id="tooltip-light"
|
||||
role="tooltip"
|
||||
class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg shadow-sm opacity-0 tooltip"
|
||||
>
|
||||
Clear all Filter
|
||||
<div class="tooltip-arrow" data-popper-arrow></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="state"
|
||||
class="block mb-2 text-sm font-medium text-gray-900"
|
||||
>Location - State</label
|
||||
>
|
||||
<ng-select
|
||||
class="custom"
|
||||
[items]="selectOptions?.states"
|
||||
bindLabel="name"
|
||||
bindValue="value"
|
||||
[ngModel]="criteria.state"
|
||||
(ngModelChange)="setState($event)"
|
||||
name="state"
|
||||
>
|
||||
</ng-select>
|
||||
</div>
|
||||
<!-- <div>
|
||||
<app-validated-city
|
||||
label="Location - City"
|
||||
name="city"
|
||||
[ngModel]="criteria.city"
|
||||
(ngModelChange)="setCity($event)"
|
||||
labelClasses="text-gray-900 font-medium"
|
||||
[state]="criteria.state"
|
||||
></app-validated-city>
|
||||
</div> -->
|
||||
<!-- New section for city search type -->
|
||||
<div *ngIf="criteria.city">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900"
|
||||
>Search Type</label
|
||||
>
|
||||
<div class="flex items-center space-x-4">
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
class="form-radio"
|
||||
name="searchType"
|
||||
[(ngModel)]="criteria.searchType"
|
||||
value="exact"
|
||||
/>
|
||||
<span class="ml-2">Exact City</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
class="form-radio"
|
||||
name="searchType"
|
||||
[(ngModel)]="criteria.searchType"
|
||||
value="radius"
|
||||
/>
|
||||
<span class="ml-2">Radius Search</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- New section for radius selection -->
|
||||
<div
|
||||
*ngIf="criteria.city && criteria.searchType === 'radius'"
|
||||
class="space-y-2"
|
||||
>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900"
|
||||
>Select Radius (in miles)</label
|
||||
>
|
||||
<div class="flex flex-wrap">
|
||||
@for (radius of [5, 20, 50, 100, 200, 300, 400, 500]; track
|
||||
radius) {
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 text-xs font-medium text-center border border-gray-200 hover:bg-gray-500 hover:text-white"
|
||||
[ngClass]="
|
||||
criteria.radius === radius
|
||||
? 'text-white bg-gray-500'
|
||||
: 'text-gray-900 bg-white'
|
||||
"
|
||||
(click)="criteria.radius = radius"
|
||||
>
|
||||
{{ radius }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="price"
|
||||
class="block mb-2 text-sm font-medium text-gray-900"
|
||||
>Price</label
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<app-validated-price
|
||||
name="price-from"
|
||||
[(ngModel)]="criteria.minPrice"
|
||||
placeholder="From"
|
||||
inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"
|
||||
></app-validated-price>
|
||||
<span>-</span>
|
||||
<app-validated-price
|
||||
name="price-to"
|
||||
[(ngModel)]="criteria.maxPrice"
|
||||
placeholder="To"
|
||||
inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"
|
||||
></app-validated-price>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="salesRevenue"
|
||||
class="block mb-2 text-sm font-medium text-gray-900"
|
||||
>Sales Revenue</label
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<app-validated-price
|
||||
name="salesRevenue-from"
|
||||
[(ngModel)]="criteria.minRevenue"
|
||||
placeholder="From"
|
||||
inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"
|
||||
></app-validated-price>
|
||||
<span>-</span>
|
||||
|
||||
<app-validated-price
|
||||
name="salesRevenue-to"
|
||||
[(ngModel)]="criteria.maxRevenue"
|
||||
placeholder="To"
|
||||
inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"
|
||||
></app-validated-price>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="cashflow"
|
||||
class="block mb-2 text-sm font-medium text-gray-900"
|
||||
>Cashflow</label
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<app-validated-price
|
||||
name="cashflow-from"
|
||||
[(ngModel)]="criteria.minCashFlow"
|
||||
placeholder="From"
|
||||
inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"
|
||||
></app-validated-price>
|
||||
<span>-</span>
|
||||
<app-validated-price
|
||||
name="cashflow-to"
|
||||
[(ngModel)]="criteria.maxCashFlow"
|
||||
placeholder="To"
|
||||
inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"
|
||||
></app-validated-price>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="title"
|
||||
class="block mb-2 text-sm font-medium text-gray-900"
|
||||
>Title / Description (Free Search)</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
[(ngModel)]="criteria.title"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
|
||||
placeholder="e.g. Restaurant"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900"
|
||||
>Category</label
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
@for(tob of selectOptions.typesOfBusiness; track tob){
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="automotive"
|
||||
[ngModel]="isTypeOfBusinessClicked(tob)"
|
||||
(ngModelChange)="categoryClicked($event, tob.value)"
|
||||
value="{{ tob.value }}"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label
|
||||
for="automotive"
|
||||
class="ml-2 text-sm font-medium text-gray-900"
|
||||
>{{ tob.name }}</label
|
||||
>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900"
|
||||
>Type of Property</label
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
[(ngModel)]="criteria.realEstateChecked"
|
||||
(ngModelChange)="
|
||||
onCheckboxChange('realEstateChecked', $event)
|
||||
"
|
||||
type="checkbox"
|
||||
name="realEstateChecked"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label
|
||||
for="realEstateChecked"
|
||||
class="ml-2 text-sm font-medium text-gray-900"
|
||||
>Real Estate</label
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
[(ngModel)]="criteria.leasedLocation"
|
||||
(ngModelChange)="onCheckboxChange('leasedLocation', $event)"
|
||||
type="checkbox"
|
||||
name="leasedLocation"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label
|
||||
for="leasedLocation"
|
||||
class="ml-2 text-sm font-medium text-gray-900"
|
||||
>Leased Location</label
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
[(ngModel)]="criteria.franchiseResale"
|
||||
(ngModelChange)="
|
||||
onCheckboxChange('franchiseResale', $event)
|
||||
"
|
||||
type="checkbox"
|
||||
name="franchiseResale"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label
|
||||
for="franchiseResale"
|
||||
class="ml-2 text-sm font-medium text-gray-900"
|
||||
>Franchise</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="numberEmployees"
|
||||
class="block mb-2 text-sm font-medium text-gray-900"
|
||||
>Number of Employees</label
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
type="number"
|
||||
id="numberEmployees-from"
|
||||
[(ngModel)]="criteria.minNumberEmployees"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
|
||||
placeholder="From"
|
||||
/>
|
||||
<span>-</span>
|
||||
<input
|
||||
type="number"
|
||||
id="numberEmployees-to"
|
||||
[(ngModel)]="criteria.maxNumberEmployees"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
|
||||
placeholder="To"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="establishedSince"
|
||||
class="block mb-2 text-sm font-medium text-gray-900"
|
||||
>Established Since</label
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
type="number"
|
||||
id="establishedSince-From"
|
||||
[(ngModel)]="criteria.establishedSince"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
|
||||
placeholder="YYYY"
|
||||
/>
|
||||
<span>-</span>
|
||||
<input
|
||||
type="number"
|
||||
id="establishedSince-To"
|
||||
[(ngModel)]="criteria.establishedUntil"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
|
||||
placeholder="YYYY"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="brokername"
|
||||
class="block mb-2 text-sm font-medium text-gray-900"
|
||||
>Broker Name / Company Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="brokername"
|
||||
[(ngModel)]="criteria.brokerName"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
|
||||
placeholder="e.g. Brokers Invest"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center p-6 space-x-2 border-t border-gray-200 rounded-b"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
(click)="modalService.accept()"
|
||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
|
||||
>
|
||||
Search ({{ numberOfResults$ | async }})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="close()"
|
||||
class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
:host ::ng-deep .ng-select.custom .ng-select-container {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
|
||||
height: 46px;
|
||||
border-radius: 0.5rem;
|
||||
.ng-value-container .ng-input {
|
||||
top: 10px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { AsyncPipe, CommonModule, NgIf } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { NgSelectModule } from '@ng-select/ng-select';
|
||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||
import { debounceTime, Observable, of, Subject, Subscription } from 'rxjs';
|
||||
import { BusinessListingCriteria, CountyResult, GeoResult, KeyValue, KeyValueStyle } from '../../../../../bizmatch-server/src/models/main.model';
|
||||
|
||||
import { ModalService } from '../../services/modal.service';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||
import { CriteriaChangeService } from '../../services/criteria-change.service';
|
||||
import { ListingsService } from '../../services/listings.service';
|
||||
import { SelectOptionsService } from '../../services/select-options.service';
|
||||
import { UserService } from '../../services/user.service';
|
||||
import { resetBusinessListingCriteria } from '../../utils/utils';
|
||||
import { ValidatedPriceComponent } from '../validated-price/validated-price.component';
|
||||
@UntilDestroy()
|
||||
@Component({
|
||||
selector: 'app-search-modal',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, RouterModule, FontAwesomeModule, AsyncPipe, NgIf, NgSelectModule, ValidatedPriceComponent],
|
||||
templateUrl: './search-modal.component.html',
|
||||
styleUrl: './search-modal.component.scss',
|
||||
})
|
||||
export class SearchModalComponent {
|
||||
// cities$: Observable<GeoResult[]>;
|
||||
counties$: Observable<CountyResult[]>;
|
||||
// cityLoading = false;
|
||||
countyLoading = false;
|
||||
// cityInput$ = new Subject<string>();
|
||||
countyInput$ = new Subject<string>();
|
||||
private criteriaChangeSubscription: Subscription;
|
||||
public criteria: BusinessListingCriteria;
|
||||
backupCriteria: BusinessListingCriteria;
|
||||
numberOfResults$: Observable<number>;
|
||||
cancelDisable = false;
|
||||
constructor(public selectOptions: SelectOptionsService, public modalService: ModalService, private criteriaChangeService: CriteriaChangeService, private listingService: ListingsService, private userService: UserService) {}
|
||||
ngOnInit() {
|
||||
this.setupCriteriaChangeListener();
|
||||
this.modalService.message$.pipe(untilDestroyed(this)).subscribe(msg => {
|
||||
this.criteria = msg as BusinessListingCriteria;
|
||||
this.backupCriteria = JSON.parse(JSON.stringify(msg));
|
||||
this.setTotalNumberOfResults();
|
||||
});
|
||||
this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => {
|
||||
if (val) {
|
||||
this.criteria.page = 1;
|
||||
this.criteria.start = 0;
|
||||
}
|
||||
});
|
||||
// this.loadCities();
|
||||
// this.loadCounties();
|
||||
}
|
||||
|
||||
ngOnChanges() {}
|
||||
categoryClicked(checked: boolean, value: string) {
|
||||
if (checked) {
|
||||
this.criteria.types.push(value);
|
||||
} else {
|
||||
const index = this.criteria.types.findIndex(t => t === value);
|
||||
if (index > -1) {
|
||||
this.criteria.types.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
// private loadCounties() {
|
||||
// this.counties$ = concat(
|
||||
// of([]), // default items
|
||||
// this.countyInput$.pipe(
|
||||
// distinctUntilChanged(),
|
||||
// tap(() => (this.countyLoading = true)),
|
||||
// switchMap(term =>
|
||||
// this.geoService.findCountiesStartingWith(term).pipe(
|
||||
// catchError(() => of([])), // empty list on error
|
||||
// map(counties => counties.map(county => county.name)), // transform the list of objects to a list of city names
|
||||
// tap(() => (this.countyLoading = false)),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
setCity(city) {
|
||||
if (city) {
|
||||
this.criteria.city = city;
|
||||
this.criteria.state = city.state;
|
||||
} else {
|
||||
this.criteria.city = null;
|
||||
this.criteria.radius = null;
|
||||
this.criteria.searchType = 'exact';
|
||||
}
|
||||
}
|
||||
setState(state: string) {
|
||||
if (state) {
|
||||
this.criteria.state = state;
|
||||
} else {
|
||||
this.criteria.state = null;
|
||||
this.setCity(null);
|
||||
}
|
||||
}
|
||||
private setupCriteriaChangeListener() {
|
||||
this.criteriaChangeSubscription = this.criteriaChangeService.criteriaChange$.pipe(debounceTime(400)).subscribe(() => {
|
||||
this.setTotalNumberOfResults();
|
||||
this.cancelDisable = true;
|
||||
});
|
||||
}
|
||||
trackByFn(item: GeoResult) {
|
||||
return item.id;
|
||||
}
|
||||
search() {
|
||||
console.log('Search criteria:', this.criteria);
|
||||
}
|
||||
// getCounties() {
|
||||
// this.geoService.findCountiesStartingWith('');
|
||||
// }
|
||||
closeModal() {
|
||||
console.log('Closing modal');
|
||||
}
|
||||
isTypeOfBusinessClicked(v: KeyValueStyle) {
|
||||
return this.criteria.types.find(t => t === v.value);
|
||||
}
|
||||
isTypeOfProfessionalClicked(v: KeyValue) {
|
||||
return this.criteria.types.find(t => t === v.value);
|
||||
}
|
||||
setTotalNumberOfResults() {
|
||||
if (this.criteria) {
|
||||
console.log(`Getting total number of results for ${this.criteria.criteriaType}`);
|
||||
if (this.criteria.criteriaType === 'businessListings' || this.criteria.criteriaType === 'commercialPropertyListings') {
|
||||
this.numberOfResults$ = this.listingService.getNumberOfListings(this.criteria);
|
||||
} else if (this.criteria.criteriaType === 'brokerListings') {
|
||||
this.numberOfResults$ = this.userService.getNumberOfBroker();
|
||||
} else {
|
||||
this.numberOfResults$ = of();
|
||||
}
|
||||
}
|
||||
}
|
||||
clearFilter() {
|
||||
resetBusinessListingCriteria(this.criteria);
|
||||
}
|
||||
close() {
|
||||
this.modalService.reject(this.backupCriteria);
|
||||
}
|
||||
onCheckboxChange(checkbox: string, value: boolean) {
|
||||
// Deaktivieren Sie alle Checkboxes
|
||||
(<BusinessListingCriteria>this.criteria).realEstateChecked = false;
|
||||
(<BusinessListingCriteria>this.criteria).leasedLocation = false;
|
||||
(<BusinessListingCriteria>this.criteria).franchiseResale = false;
|
||||
|
||||
// Aktivieren Sie nur die aktuell ausgewählte Checkbox
|
||||
this.criteria[checkbox] = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<div [id]="id" role="tooltip" class="max-w-72 w-max absolute z-50 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 dark:bg-gray-700">
|
||||
{{ text }}
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
/* Diese Styles kannst du in deine globale styles.css oder in eine eigene tooltip.component.css-Datei packen */
|
||||
|
||||
.tooltip-arrow {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: inherit;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.arrow-top {
|
||||
top: -4px;
|
||||
left: calc(50% - 4px);
|
||||
}
|
||||
|
||||
.arrow-right {
|
||||
right: -4px;
|
||||
top: calc(50% - 4px);
|
||||
}
|
||||
|
||||
.arrow-bottom {
|
||||
bottom: -4px;
|
||||
left: calc(50% - 4px);
|
||||
}
|
||||
|
||||
.arrow-left {
|
||||
left: -4px;
|
||||
top: calc(50% - 4px);
|
||||
}
|
||||
167
bizmatch-client/src/app/components/tooltip/tooltip.component.ts
Normal file
167
bizmatch-client/src/app/components/tooltip/tooltip.component.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AfterViewInit, Component, ElementRef, Input, OnChanges, OnDestroy, Renderer2, SimpleChanges } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-tooltip',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './tooltip.component.html',
|
||||
})
|
||||
export class TooltipComponent implements AfterViewInit, OnDestroy, OnChanges {
|
||||
@Input() id: string;
|
||||
@Input() text: string;
|
||||
@Input() isVisible: boolean = false;
|
||||
@Input() position: 'top' | 'right' | 'bottom' | 'left' = 'top';
|
||||
|
||||
private tooltipElement: HTMLElement | null = null;
|
||||
private arrowElement: HTMLElement | null = null;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private parentElement: HTMLElement | null = null;
|
||||
|
||||
constructor(private elementRef: ElementRef, private renderer: Renderer2) {}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.tooltipElement = document.getElementById(this.id);
|
||||
this.parentElement = this.elementRef.nativeElement.parentElement;
|
||||
|
||||
if (this.tooltipElement && this.parentElement) {
|
||||
// Create arrow element
|
||||
this.arrowElement = this.renderer.createElement('div');
|
||||
this.renderer.addClass(this.arrowElement, 'tooltip-arrow');
|
||||
this.renderer.appendChild(this.tooltipElement, this.arrowElement);
|
||||
|
||||
// Setup resize observer
|
||||
this.setupResizeObserver();
|
||||
|
||||
// Initial positioning
|
||||
this.updatePosition();
|
||||
|
||||
// Initial visibility
|
||||
this.updateTooltipVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if (changes['isVisible'] && this.tooltipElement) {
|
||||
this.updateTooltipVisibility();
|
||||
}
|
||||
|
||||
if ((changes['position'] || changes['isVisible']) && this.isVisible) {
|
||||
setTimeout(() => this.updatePosition(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private setupResizeObserver() {
|
||||
if (window.ResizeObserver && this.parentElement) {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
if (this.isVisible) {
|
||||
this.updatePosition();
|
||||
}
|
||||
});
|
||||
|
||||
this.resizeObserver.observe(this.parentElement);
|
||||
if (this.tooltipElement) {
|
||||
this.resizeObserver.observe(this.tooltipElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateTooltipVisibility() {
|
||||
if (!this.tooltipElement) return;
|
||||
|
||||
if (this.isVisible) {
|
||||
this.renderer.removeClass(this.tooltipElement, 'invisible');
|
||||
this.renderer.removeClass(this.tooltipElement, 'opacity-0');
|
||||
this.renderer.addClass(this.tooltipElement, 'visible');
|
||||
this.renderer.addClass(this.tooltipElement, 'opacity-100');
|
||||
this.updatePosition();
|
||||
} else {
|
||||
this.renderer.removeClass(this.tooltipElement, 'visible');
|
||||
this.renderer.removeClass(this.tooltipElement, 'opacity-100');
|
||||
this.renderer.addClass(this.tooltipElement, 'invisible');
|
||||
this.renderer.addClass(this.tooltipElement, 'opacity-0');
|
||||
}
|
||||
}
|
||||
|
||||
private updatePosition() {
|
||||
if (!this.tooltipElement || !this.parentElement || !this.arrowElement) return;
|
||||
|
||||
const parentRect = this.parentElement.getBoundingClientRect();
|
||||
const tooltipRect = this.tooltipElement.getBoundingClientRect();
|
||||
|
||||
// Reset any previous positioning
|
||||
this.renderer.removeStyle(this.tooltipElement, 'top');
|
||||
this.renderer.removeStyle(this.tooltipElement, 'right');
|
||||
this.renderer.removeStyle(this.tooltipElement, 'bottom');
|
||||
this.renderer.removeStyle(this.tooltipElement, 'left');
|
||||
|
||||
// Reset arrow classes
|
||||
this.renderer.removeClass(this.arrowElement, 'arrow-top');
|
||||
this.renderer.removeClass(this.arrowElement, 'arrow-right');
|
||||
this.renderer.removeClass(this.arrowElement, 'arrow-bottom');
|
||||
this.renderer.removeClass(this.arrowElement, 'arrow-left');
|
||||
|
||||
let top: number = 0;
|
||||
let left: number = 0;
|
||||
|
||||
switch (this.position) {
|
||||
case 'top':
|
||||
top = -tooltipRect.height - 8;
|
||||
left = (parentRect.width - tooltipRect.width) / 2;
|
||||
this.renderer.addClass(this.arrowElement, 'arrow-bottom');
|
||||
break;
|
||||
case 'right':
|
||||
top = (parentRect.height - tooltipRect.height) / 2;
|
||||
left = parentRect.width + 8;
|
||||
this.renderer.addClass(this.arrowElement, 'arrow-left');
|
||||
break;
|
||||
case 'bottom':
|
||||
top = parentRect.height + 8;
|
||||
left = (parentRect.width - tooltipRect.width) / 2;
|
||||
this.renderer.addClass(this.arrowElement, 'arrow-top');
|
||||
break;
|
||||
case 'left':
|
||||
top = (parentRect.height - tooltipRect.height) / 2;
|
||||
left = -tooltipRect.width - 8;
|
||||
this.renderer.addClass(this.arrowElement, 'arrow-right');
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply positioning
|
||||
this.renderer.setStyle(this.tooltipElement, 'top', `${top}px`);
|
||||
this.renderer.setStyle(this.tooltipElement, 'left', `${left}px`);
|
||||
|
||||
// Make sure the tooltip stays within viewport
|
||||
this.adjustToViewport();
|
||||
}
|
||||
|
||||
private adjustToViewport() {
|
||||
if (!this.tooltipElement) return;
|
||||
|
||||
const tooltipRect = this.tooltipElement.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Adjust horizontal position
|
||||
if (tooltipRect.left < 0) {
|
||||
this.renderer.setStyle(this.tooltipElement, 'left', '0px');
|
||||
} else if (tooltipRect.right > viewportWidth) {
|
||||
const newLeft = Math.max(0, viewportWidth - tooltipRect.width);
|
||||
this.renderer.setStyle(this.tooltipElement, 'left', `${newLeft}px`);
|
||||
}
|
||||
|
||||
// Adjust vertical position
|
||||
if (tooltipRect.top < 0) {
|
||||
this.renderer.setStyle(this.tooltipElement, 'top', '0px');
|
||||
} else if (tooltipRect.bottom > viewportHeight) {
|
||||
const newTop = Math.max(0, viewportHeight - tooltipRect.height);
|
||||
this.renderer.setStyle(this.tooltipElement, 'top', `${newTop}px`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<div>
|
||||
<label for="type" class="block text-sm font-bold text-gray-700 mb-1 relative w-fit {{ labelClasses }}"
|
||||
>{{ label }} @if(validationMessage){
|
||||
<div
|
||||
attr.data-tooltip-target="tooltip-{{ name }}"
|
||||
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
|
||||
(click)="toggleTooltip($event)"
|
||||
(touchstart)="toggleTooltip($event)"
|
||||
>
|
||||
!
|
||||
</div>
|
||||
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage" [isVisible]="isTooltipVisible"></app-tooltip>
|
||||
}
|
||||
</label>
|
||||
<ng-select
|
||||
class="custom"
|
||||
[multiple]="false"
|
||||
[hideSelected]="true"
|
||||
[trackByFn]="trackByFn"
|
||||
[minTermLength]="2"
|
||||
[loading]="cityLoading"
|
||||
typeToSearchText="Please enter 2 or more characters"
|
||||
[typeahead]="cityInput$"
|
||||
ngModel="{{ value?.name }} {{ value ? '-' : '' }} {{ value?.state }}"
|
||||
(ngModelChange)="onInputChange($event)"
|
||||
>
|
||||
@for (city of cities$ | async; track city.id) {
|
||||
<ng-option [value]="city">{{ city.name }} - {{ city.state }}</ng-option>
|
||||
}
|
||||
</ng-select>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
:host ::ng-deep .ng-select.custom .ng-select-container {
|
||||
// --tw-bg-opacity: 1;
|
||||
// background-color: rgb(249 250 251 / var(--tw-bg-opacity));
|
||||
// height: 42px;
|
||||
border-radius: 0.5rem;
|
||||
.ng-value-container .ng-input {
|
||||
top: 10px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, forwardRef, Input } from '@angular/core';
|
||||
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { NgSelectModule } from '@ng-select/ng-select';
|
||||
import { catchError, concat, distinctUntilChanged, Observable, of, Subject, switchMap, tap } from 'rxjs';
|
||||
import { GeoResult } from '../../../../../bizmatch-server/src/models/main.model';
|
||||
import { City } from '../../../../../bizmatch-server/src/models/server.model';
|
||||
import { GeoService } from '../../services/geo.service';
|
||||
import { SelectOptionsService } from '../../services/select-options.service';
|
||||
import { BaseInputComponent } from '../base-input/base-input.component';
|
||||
import { TooltipComponent } from '../tooltip/tooltip.component';
|
||||
import { ValidationMessagesService } from '../validation-messages.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-validated-city',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgSelectModule, TooltipComponent],
|
||||
templateUrl: './validated-city.component.html',
|
||||
styleUrl: './validated-city.component.scss',
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => ValidatedCityComponent),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class ValidatedCityComponent extends BaseInputComponent {
|
||||
@Input() items;
|
||||
@Input() labelClasses: string;
|
||||
@Input() state: string;
|
||||
cities$: Observable<GeoResult[]>;
|
||||
cityInput$ = new Subject<string>();
|
||||
countyInput$ = new Subject<string>();
|
||||
cityLoading = false;
|
||||
constructor(validationMessagesService: ValidationMessagesService, private geoService: GeoService, public selectOptions: SelectOptionsService) {
|
||||
super(validationMessagesService);
|
||||
}
|
||||
|
||||
override ngOnInit() {
|
||||
super.ngOnInit();
|
||||
this.loadCities();
|
||||
}
|
||||
onInputChange(event: City): void {
|
||||
this.value = event; //{ ...event, longitude: parseFloat(event.longitude), latitude: parseFloat(event.latitude) };
|
||||
this.onChange(this.value);
|
||||
}
|
||||
private loadCities() {
|
||||
this.cities$ = concat(
|
||||
of([]), // default items
|
||||
this.cityInput$.pipe(
|
||||
distinctUntilChanged(),
|
||||
tap(() => (this.cityLoading = true)),
|
||||
switchMap(term =>
|
||||
this.geoService.findCitiesStartingWith(term, this.state).pipe(
|
||||
catchError(() => of([])), // empty list on error
|
||||
// map(cities => cities.map(city => city.city)), // transform the list of objects to a list of city names
|
||||
tap(() => (this.cityLoading = false)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
trackByFn(item: GeoResult) {
|
||||
return item.id;
|
||||
}
|
||||
compareFn = (item, selected) => {
|
||||
return item.id === selected.id;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<div>
|
||||
@if(label){
|
||||
<label for="type" class="block text-sm font-bold text-gray-700 mb-1 relative w-fit {{ labelClasses }}"
|
||||
>{{ label }} @if(validationMessage){
|
||||
<div
|
||||
attr.data-tooltip-target="tooltip-{{ name }}"
|
||||
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
|
||||
(click)="toggleTooltip($event)"
|
||||
(touchstart)="toggleTooltip($event)"
|
||||
>
|
||||
!
|
||||
</div>
|
||||
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage" [isVisible]="isTooltipVisible"></app-tooltip>
|
||||
}
|
||||
</label>
|
||||
}
|
||||
<ng-select
|
||||
class="custom"
|
||||
[multiple]="false"
|
||||
[hideSelected]="true"
|
||||
[trackByFn]="trackByFn"
|
||||
[minTermLength]="2"
|
||||
[loading]="countyLoading"
|
||||
typeToSearchText="Please enter 2 or more characters"
|
||||
[typeahead]="countyInput$"
|
||||
ngModel="{{ value }}"
|
||||
(ngModelChange)="onInputChange($event)"
|
||||
[readonly]="readonly"
|
||||
>
|
||||
@for (county of counties$ | async; track county.id) {
|
||||
<ng-option [value]="county">{{ county }}</ng-option>
|
||||
}
|
||||
</ng-select>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
:host ::ng-deep .ng-select.custom .ng-select-container {
|
||||
// --tw-bg-opacity: 1;
|
||||
// background-color: rgb(249 250 251 / var(--tw-bg-opacity));
|
||||
// height: 42px;
|
||||
border-radius: 0.5rem;
|
||||
.ng-value-container .ng-input {
|
||||
top: 10px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, forwardRef, Input } from '@angular/core';
|
||||
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { NgSelectModule } from '@ng-select/ng-select';
|
||||
import { catchError, concat, distinctUntilChanged, map, Observable, of, Subject, switchMap, tap } from 'rxjs';
|
||||
import { CountyResult, GeoResult } from '../../../../../bizmatch-server/src/models/main.model';
|
||||
import { City } from '../../../../../bizmatch-server/src/models/server.model';
|
||||
import { GeoService } from '../../services/geo.service';
|
||||
import { SelectOptionsService } from '../../services/select-options.service';
|
||||
import { BaseInputComponent } from '../base-input/base-input.component';
|
||||
import { TooltipComponent } from '../tooltip/tooltip.component';
|
||||
import { ValidationMessagesService } from '../validation-messages.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-validated-county',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgSelectModule, TooltipComponent],
|
||||
templateUrl: './validated-county.component.html',
|
||||
styleUrl: './validated-county.component.scss',
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => ValidatedCountyComponent),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class ValidatedCountyComponent extends BaseInputComponent {
|
||||
@Input() items;
|
||||
@Input() labelClasses: string;
|
||||
@Input() state: string;
|
||||
@Input() readonly = false;
|
||||
counties$: Observable<CountyResult[]>;
|
||||
countyLoading = false;
|
||||
countyInput$ = new Subject<string>();
|
||||
constructor(validationMessagesService: ValidationMessagesService, private geoService: GeoService, public selectOptions: SelectOptionsService) {
|
||||
super(validationMessagesService);
|
||||
}
|
||||
|
||||
override ngOnInit() {
|
||||
super.ngOnInit();
|
||||
this.loadCounties();
|
||||
}
|
||||
onInputChange(event: City): void {
|
||||
this.value = event; //{ ...event, longitude: parseFloat(event.longitude), latitude: parseFloat(event.latitude) };
|
||||
this.onChange(this.value);
|
||||
}
|
||||
private loadCounties() {
|
||||
this.counties$ = concat(
|
||||
of([]), // default items
|
||||
this.countyInput$.pipe(
|
||||
distinctUntilChanged(),
|
||||
tap(() => (this.countyLoading = true)),
|
||||
switchMap(term =>
|
||||
this.geoService.findCountiesStartingWith(term, this.state ? [this.state] : null).pipe(
|
||||
catchError(() => of([])), // empty list on error
|
||||
map(counties => counties.map(county => county.name)), // transform the list of objects to a list of city names
|
||||
tap(() => (this.countyLoading = false)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
trackByFn(item: GeoResult) {
|
||||
return item.id;
|
||||
}
|
||||
compareFn = (item, selected) => {
|
||||
return item.id === selected.id;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<div>
|
||||
<label [for]="name" class="block text-sm font-bold text-gray-700 mb-1 relative w-fit">
|
||||
{{ label }}
|
||||
@if(validationMessage){
|
||||
<div
|
||||
attr.data-tooltip-target="tooltip-{{ name }}"
|
||||
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
|
||||
(click)="toggleTooltip($event)"
|
||||
(touchstart)="toggleTooltip($event)"
|
||||
>
|
||||
!
|
||||
</div>
|
||||
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage" [isVisible]="isTooltipVisible"></app-tooltip>
|
||||
}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
[id]="name"
|
||||
[ngModel]="value"
|
||||
(ngModelChange)="onInputChange($event)"
|
||||
(blur)="onTouched()"
|
||||
[attr.name]="name"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
[mask]="mask"
|
||||
[dropSpecialCharacters]="false"
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, forwardRef, Input } from '@angular/core';
|
||||
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { NgxMaskDirective, NgxMaskPipe, provideNgxMask } from 'ngx-mask';
|
||||
import { BaseInputComponent } from '../base-input/base-input.component';
|
||||
import { TooltipComponent } from '../tooltip/tooltip.component';
|
||||
import { ValidationMessagesService } from '../validation-messages.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-validated-input',
|
||||
templateUrl: './validated-input.component.html',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TooltipComponent, NgxMaskDirective, NgxMaskPipe],
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => ValidatedInputComponent),
|
||||
multi: true,
|
||||
},
|
||||
provideNgxMask(),
|
||||
],
|
||||
})
|
||||
export class ValidatedInputComponent extends BaseInputComponent {
|
||||
@Input() kind: 'text' | 'number' | 'email' = 'text';
|
||||
@Input() mask: string;
|
||||
constructor(validationMessagesService: ValidationMessagesService) {
|
||||
super(validationMessagesService);
|
||||
}
|
||||
|
||||
onInputChange(event: string | number): void {
|
||||
if (this.kind === 'number') {
|
||||
if (typeof event === 'number') {
|
||||
this.value = event;
|
||||
} else {
|
||||
this.value = parseFloat(event);
|
||||
}
|
||||
} else {
|
||||
const text = event as string;
|
||||
this.value = text?.length > 0 ? event : null;
|
||||
}
|
||||
// this.value = event?.length > 0 ? (this.kind === 'number' ? parseFloat(event) : event) : null;
|
||||
this.onChange(this.value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<div>
|
||||
<label for="type" class="block text-sm font-bold text-gray-700 mb-1 relative w-fit {{ labelClasses }}"
|
||||
>{{ label }} @if(validationMessage){
|
||||
<div
|
||||
attr.data-tooltip-target="tooltip-{{ name }}"
|
||||
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
|
||||
(click)="toggleTooltip($event)"
|
||||
(touchstart)="toggleTooltip($event)"
|
||||
>
|
||||
!
|
||||
</div>
|
||||
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage" [isVisible]="isTooltipVisible"></app-tooltip>
|
||||
}
|
||||
</label>
|
||||
<ng-select
|
||||
class="custom"
|
||||
[multiple]="false"
|
||||
[hideSelected]="true"
|
||||
[trackByFn]="trackByFn"
|
||||
[minTermLength]="2"
|
||||
[loading]="placeLoading"
|
||||
typeToSearchText="Please enter 2 or more characters"
|
||||
[typeahead]="placeInput$"
|
||||
ngModel="{{ formatGeoAddress(value) }}"
|
||||
(ngModelChange)="onInputChange($event)"
|
||||
>
|
||||
@for (place of places$ | async; track place.place_id) {
|
||||
<ng-option [value]="place">{{ formatPlaceAddress(place) }}</ng-option>
|
||||
}
|
||||
</ng-select>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
:host ::ng-deep .ng-select.custom .ng-select-container {
|
||||
// --tw-bg-opacity: 1;
|
||||
// background-color: rgb(249 250 251 / var(--tw-bg-opacity));
|
||||
// height: 42px;
|
||||
border-radius: 0.5rem;
|
||||
.ng-value-container .ng-input {
|
||||
top: 10px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, forwardRef, Input } from '@angular/core';
|
||||
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { NgSelectModule } from '@ng-select/ng-select';
|
||||
import { catchError, concat, debounceTime, distinctUntilChanged, Observable, of, Subject, switchMap, tap } from 'rxjs';
|
||||
import { GeoResult } from '../../../../../bizmatch-server/src/models/main.model';
|
||||
import { Place } from '../../../../../bizmatch-server/src/models/server.model';
|
||||
import { GeoService } from '../../services/geo.service';
|
||||
import { SelectOptionsService } from '../../services/select-options.service';
|
||||
import { BaseInputComponent } from '../base-input/base-input.component';
|
||||
import { TooltipComponent } from '../tooltip/tooltip.component';
|
||||
import { ValidationMessagesService } from '../validation-messages.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-validated-location',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgSelectModule, TooltipComponent],
|
||||
templateUrl: './validated-location.component.html',
|
||||
styleUrl: './validated-location.component.scss',
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => ValidatedLocationComponent),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class ValidatedLocationComponent extends BaseInputComponent {
|
||||
@Input() items;
|
||||
@Input() labelClasses: string;
|
||||
places$: Observable<Place[]>;
|
||||
placeInput$ = new Subject<string>();
|
||||
placeLoading = false;
|
||||
constructor(validationMessagesService: ValidationMessagesService, private geoService: GeoService, public selectOptions: SelectOptionsService) {
|
||||
super(validationMessagesService);
|
||||
}
|
||||
|
||||
override ngOnInit() {
|
||||
super.ngOnInit();
|
||||
this.loadCities();
|
||||
}
|
||||
onInputChange(event: Place): void {
|
||||
this.value = event; //{ ...event, longitude: parseFloat(event.longitude), latitude: parseFloat(event.latitude) };
|
||||
if (event) {
|
||||
this.value = {
|
||||
id: event?.place_id,
|
||||
name: event?.address.city,
|
||||
county: event?.address.county,
|
||||
street: event?.address.road,
|
||||
housenumber: event?.address.house_number,
|
||||
state: event?.address['ISO3166-2-lvl4'].substr(3),
|
||||
latitude: event ? parseFloat(event?.lat) : undefined,
|
||||
longitude: event ? parseFloat(event?.lon) : undefined,
|
||||
};
|
||||
}
|
||||
this.onChange(this.value);
|
||||
}
|
||||
private loadCities() {
|
||||
this.places$ = concat(
|
||||
of([]), // default items
|
||||
this.placeInput$.pipe(
|
||||
debounceTime(300),
|
||||
distinctUntilChanged(),
|
||||
tap(() => (this.placeLoading = true)),
|
||||
switchMap(term =>
|
||||
this.geoService.findLocationStartingWith(term).pipe(
|
||||
catchError(() => of([])), // empty list on error
|
||||
// map(cities => cities.map(city => city.city)), // transform the list of objects to a list of city names
|
||||
tap(() => (this.placeLoading = false)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
trackByFn(item: GeoResult) {
|
||||
return item.id;
|
||||
}
|
||||
compareFn = (item, selected) => {
|
||||
return item.id === selected.id;
|
||||
};
|
||||
formatGeoAddress(geoResult: GeoResult | null | undefined): string {
|
||||
// Überprüfen, ob geoResult null oder undefined ist
|
||||
if (!geoResult) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let addressParts: string[] = [];
|
||||
|
||||
// Füge Hausnummer hinzu, wenn vorhanden
|
||||
if (geoResult.housenumber) {
|
||||
addressParts.push(geoResult.housenumber);
|
||||
}
|
||||
|
||||
// Füge Straße hinzu, wenn vorhanden
|
||||
if (geoResult.street) {
|
||||
addressParts.push(geoResult.street);
|
||||
}
|
||||
|
||||
// Kombiniere Hausnummer und Straße
|
||||
let address = addressParts.join(' ');
|
||||
|
||||
// Füge Namen hinzu, wenn vorhanden
|
||||
if (geoResult.name) {
|
||||
address = address ? `${address}, ${geoResult.name}` : geoResult.name;
|
||||
}
|
||||
|
||||
// Füge County hinzu, wenn vorhanden
|
||||
if (geoResult.county) {
|
||||
address = address ? `${address}, ${geoResult.county}` : geoResult.county;
|
||||
}
|
||||
|
||||
// Füge Bundesland hinzu, wenn vorhanden
|
||||
if (geoResult.state) {
|
||||
address = address ? `${address} - ${geoResult.state}` : geoResult.state;
|
||||
}
|
||||
|
||||
return address;
|
||||
}
|
||||
formatPlaceAddress(place: Place | null | undefined): string {
|
||||
// Überprüfen, ob place null oder undefined ist
|
||||
if (!place) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const { house_number, road, city, county, state } = place.address;
|
||||
|
||||
let addressParts: string[] = [];
|
||||
|
||||
// Füge Hausnummer hinzu, wenn vorhanden
|
||||
if (house_number) {
|
||||
addressParts.push(house_number);
|
||||
}
|
||||
|
||||
// Füge Straße hinzu, wenn vorhanden
|
||||
if (road) {
|
||||
addressParts.push(road);
|
||||
}
|
||||
|
||||
// Kombiniere Hausnummer und Straße
|
||||
let address = addressParts.join(' ');
|
||||
|
||||
// Füge Stadt hinzu, wenn vorhanden
|
||||
if (city) {
|
||||
address = address ? `${address}, ${city}` : city;
|
||||
}
|
||||
|
||||
// Füge County hinzu, wenn vorhanden
|
||||
if (county) {
|
||||
address = address ? `${address}, ${county}` : county;
|
||||
}
|
||||
|
||||
// Füge Bundesland hinzu, wenn vorhanden
|
||||
if (state) {
|
||||
address = address ? `${address} - ${state}` : state;
|
||||
}
|
||||
|
||||
return address;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<div>
|
||||
<label for="type" class="block text-sm font-bold text-gray-700 mb-1 relative w-fit"
|
||||
>{{ label }} @if(validationMessage){
|
||||
<div
|
||||
attr.data-tooltip-target="tooltip-{{ name }}"
|
||||
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
|
||||
(click)="toggleTooltip($event)"
|
||||
(touchstart)="toggleTooltip($event)"
|
||||
>
|
||||
!
|
||||
</div>
|
||||
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage" [isVisible]="isTooltipVisible"></app-tooltip>
|
||||
}
|
||||
</label>
|
||||
<ng-select [items]="items" bindLabel="name" bindValue="value" [(ngModel)]="value" (ngModelChange)="onInputChange($event)" name="type"> </ng-select>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, forwardRef, Input } from '@angular/core';
|
||||
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { NgSelectModule } from '@ng-select/ng-select';
|
||||
import { BaseInputComponent } from '../base-input/base-input.component';
|
||||
import { TooltipComponent } from '../tooltip/tooltip.component';
|
||||
import { ValidationMessagesService } from '../validation-messages.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-validated-ng-select',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgSelectModule, TooltipComponent],
|
||||
templateUrl: './validated-ng-select.component.html',
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => ValidatedNgSelectComponent),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class ValidatedNgSelectComponent extends BaseInputComponent {
|
||||
@Input() items;
|
||||
constructor(validationMessagesService: ValidationMessagesService) {
|
||||
super(validationMessagesService);
|
||||
}
|
||||
onInputChange(event: Event): void {
|
||||
this.value = event;
|
||||
this.onChange(this.value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<div>
|
||||
@if(label){
|
||||
<label [for]="name" class="block text-sm font-bold text-gray-700 mb-1 relative w-fit">
|
||||
{{ label }}
|
||||
@if(validationMessage){
|
||||
<div
|
||||
attr.data-tooltip-target="tooltip-{{ name }}"
|
||||
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
|
||||
(click)="toggleTooltip($event)"
|
||||
(touchstart)="toggleTooltip($event)"
|
||||
>
|
||||
!
|
||||
</div>
|
||||
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage" [isVisible]="isTooltipVisible"></app-tooltip>
|
||||
}
|
||||
</label>
|
||||
}
|
||||
<input
|
||||
type="text"
|
||||
[id]="name"
|
||||
[ngModel]="value"
|
||||
(ngModelChange)="onInputChange($event)"
|
||||
(blur)="onTouched()"
|
||||
[attr.name]="name"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 {{ inputClasses }}"
|
||||
[options]="{ prefix: '$', thousands: ',', decimal: '.', precision: 0, align: 'left' }"
|
||||
currencyMask
|
||||
autocomplete="off"
|
||||
[placeholder]="placeholder"
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, forwardRef, Input } from '@angular/core';
|
||||
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { NgxCurrencyDirective } from 'ngx-currency';
|
||||
import { BaseInputComponent } from '../base-input/base-input.component';
|
||||
import { TooltipComponent } from '../tooltip/tooltip.component';
|
||||
import { ValidationMessagesService } from '../validation-messages.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-validated-price',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TooltipComponent, NgxCurrencyDirective],
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => ValidatedPriceComponent),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
templateUrl: './validated-price.component.html',
|
||||
styles: `:host{width:100%}`,
|
||||
})
|
||||
export class ValidatedPriceComponent extends BaseInputComponent {
|
||||
@Input() inputClasses: string;
|
||||
@Input() placeholder: string = '';
|
||||
constructor(validationMessagesService: ValidationMessagesService) {
|
||||
super(validationMessagesService);
|
||||
}
|
||||
|
||||
onInputChange(event: Event): void {
|
||||
this.value = !event ? null : event;
|
||||
this.onChange(this.value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<label [for]="name" class="block text-sm font-bold text-gray-700 mb-1 relative w-fit">
|
||||
{{ label }}
|
||||
@if(validationMessage){
|
||||
<div
|
||||
attr.data-tooltip-target="tooltip-{{ name }}"
|
||||
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
|
||||
(click)="toggleTooltip($event)"
|
||||
(touchstart)="toggleTooltip($event)"
|
||||
>
|
||||
!
|
||||
</div>
|
||||
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage" [isVisible]="isTooltipVisible"></app-tooltip>
|
||||
}
|
||||
</label>
|
||||
<quill-editor [(ngModel)]="value" (ngModelChange)="onInputChange($event)" (onBlur)="onTouched()" [id]="name" [attr.name]="name" [modules]="quillModules"></quill-editor>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, forwardRef } from '@angular/core';
|
||||
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { QuillModule } from 'ngx-quill';
|
||||
import { BaseInputComponent } from '../base-input/base-input.component';
|
||||
import { TooltipComponent } from '../tooltip/tooltip.component';
|
||||
import { ValidationMessagesService } from '../validation-messages.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-validated-quill',
|
||||
templateUrl: './validated-quill.component.html',
|
||||
styles: `quill-editor {
|
||||
width: 100%;
|
||||
}`,
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, QuillModule, TooltipComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => ValidatedQuillComponent),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class ValidatedQuillComponent extends BaseInputComponent {
|
||||
quillModules = {
|
||||
toolbar: [['bold', 'italic', 'underline', 'strike'], [{ list: 'ordered' }, { list: 'bullet' }], [{ header: [1, 2, 3, 4, 5, 6, false] }], [{ color: [] }, { background: [] }], ['clean']],
|
||||
};
|
||||
constructor(validationMessagesService: ValidationMessagesService) {
|
||||
super(validationMessagesService);
|
||||
}
|
||||
|
||||
onInputChange(event: Event): void {
|
||||
this.value = event;
|
||||
this.onChange(this.value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<div>
|
||||
<label [for]="name" class="block text-sm font-bold text-gray-700 mb-1 relative w-fit">
|
||||
{{ label }}
|
||||
@if(validationMessage){
|
||||
<div
|
||||
attr.data-tooltip-target="tooltip-{{ name }}"
|
||||
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
|
||||
(click)="toggleTooltip($event)"
|
||||
(touchstart)="toggleTooltip($event)"
|
||||
>
|
||||
!
|
||||
</div>
|
||||
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage" [isVisible]="isTooltipVisible"></app-tooltip>
|
||||
}
|
||||
</label>
|
||||
<select
|
||||
[disabled]="disabled"
|
||||
[id]="name"
|
||||
[name]="name"
|
||||
[ngModel]="value"
|
||||
(change)="onSelectChange($event)"
|
||||
(blur)="onTouched()"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="" disabled selected>Select an option</option>
|
||||
<option *ngFor="let option of options" [value]="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, EventEmitter, forwardRef, Input, Output } from '@angular/core';
|
||||
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { BaseInputComponent } from '../base-input/base-input.component';
|
||||
import { TooltipComponent } from '../tooltip/tooltip.component';
|
||||
import { ValidationMessagesService } from '../validation-messages.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-validated-select',
|
||||
templateUrl: './validated-select.component.html',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TooltipComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => ValidatedSelectComponent),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class ValidatedSelectComponent extends BaseInputComponent {
|
||||
@Input() options: Array<{ value: any; label: string }> = [];
|
||||
@Input() disabled = false;
|
||||
@Output() valueChange = new EventEmitter<any>();
|
||||
|
||||
constructor(validationMessagesService: ValidationMessagesService) {
|
||||
super(validationMessagesService);
|
||||
}
|
||||
|
||||
onSelectChange(event: Event): void {
|
||||
const value = (event.target as HTMLSelectElement).value;
|
||||
this.value = value;
|
||||
this.onChange(value);
|
||||
this.valueChange.emit(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<div>
|
||||
<label [for]="name" class="block text-sm font-bold text-gray-700 mb-1 relative w-fit">
|
||||
{{ label }}
|
||||
@if(validationMessage){
|
||||
<div
|
||||
attr.data-tooltip-target="tooltip-{{ name }}"
|
||||
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
|
||||
(click)="toggleTooltip($event)"
|
||||
(touchstart)="toggleTooltip($event)"
|
||||
>
|
||||
!
|
||||
</div>
|
||||
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage" [isVisible]="isTooltipVisible"></app-tooltip>
|
||||
}
|
||||
</label>
|
||||
<textarea [id]="name" [ngModel]="value" (ngModelChange)="onInputChange($event)" [attr.name]="name" class="w-full p-2 border border-gray-300 rounded-md" rows="3"></textarea>
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, forwardRef } from '@angular/core';
|
||||
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { BaseInputComponent } from '../base-input/base-input.component';
|
||||
import { TooltipComponent } from '../tooltip/tooltip.component';
|
||||
import { ValidationMessagesService } from '../validation-messages.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-validated-textarea',
|
||||
templateUrl: './validated-textarea.component.html',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TooltipComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => ValidatedTextareaComponent),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class ValidatedTextareaComponent extends BaseInputComponent {
|
||||
constructor(validationMessagesService: ValidationMessagesService) {
|
||||
super(validationMessagesService);
|
||||
}
|
||||
|
||||
onInputChange(event: string): void {
|
||||
this.value = event?.length > 0 ? event : null;
|
||||
this.onChange(this.value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
|
||||
export interface ValidationMessage {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const VALIDATION_MESSAGES = new InjectionToken<ValidationMessagesService>('VALIDATION_MESSAGES');
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ValidationMessagesService {
|
||||
private messagesSubject = new BehaviorSubject<ValidationMessage[]>([]);
|
||||
messages$: Observable<ValidationMessage[]> = this.messagesSubject.asObservable();
|
||||
|
||||
updateMessages(messages: ValidationMessage[]): void {
|
||||
this.messagesSubject.next(messages);
|
||||
}
|
||||
|
||||
clearMessages(): void {
|
||||
this.messagesSubject.next([]);
|
||||
}
|
||||
|
||||
getMessage(field: string): string | null {
|
||||
const messages = this.messagesSubject.value;
|
||||
const message = messages.find(m => m.field === field);
|
||||
return message ? message.message : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user